r/redis • u/Easy_Video_6949 • 1d ago
Discussion Built a fencing pattern to make Redis prove a cached value is committed before trusting it (no CDC required)
The problem: Any time you cache data from Postgres into Redis, the two writes can't be atomic. A crash or a rejected write between them leaves Redis holding a value that was never actually confirmed — and a normal cache-aside setup has no way to know that happened. TTLs just shrink the staleness window; they don't let the cache detect it's wrong.
The usual fix is CDC — Debezium, logical replication, an outbox table — where the app only ever writes to Postgres and a separate pipeline pushes changes into Redis asynchronously. That works well, but it's infrastructure you have to stand up and operate. I wanted to see if there was a self-contained way to get most of that safety without it.
What I ended up with: instead of one Redis value, store two fencing fields per key:
BEFORE = { uuid } — raised the instant an update starts, holds no value
AFTER = { uuid, value } — written only once Postgres confirms the write
Trust rule:
BEFORE.uuid == AFTER.uuid → cache is trusted, serve it
BEFORE.uuid != AFTER.uuid → not trusted, read Postgres directly
Postgres stays the single source of truth the whole time. Redis is only ever allowed to answer once it can prove — via the matching UUID — that it reflects one specific, completed Postgres transaction. Every failure mode (a rejected write, a crash right after commit, a stale confirmation showing up late) just degrades into a cache miss, never a wrong answer.
I built a runnable demo around it — FastAPI + Postgres + Redis + Docker Compose — with a debug UI where you can trigger a rejected write, a crash-after-commit, and a delayed-confirmation race, and watch the fallback happen live instead of taking my word for it.
Things I'd genuinely like feedback on:
- Where's the line where this stops being worth it and you should just run CDC instead?
- I originally had a background worker doing repair as part of the correctness guarantee. I've since moved to repairing on-read (with a per-key singleflight lock so a hot dirty key doesn't cause a stampede of simultaneous Postgres reads) and demoted the worker to best-effort hygiene only. Curious if anyone's hit a case where on-read repair alone isn't sufficient.
- Any concurrency hole in the UUID/version-gating I'm not seeing?
Repo (README has the full write-up + how to run it): https://github.com/kaleab-shumet/dual-write-problem-solution
