Database Caching at Scale · Cache Consistency Strategies
Write-Through and Write-Behind
Updating the cache on every write, or the database later — and what you can lose.
Scenario
Example app: a video platform counts views. Writing every view to Postgres (40,000/s) is too expensive, so views are buffered in Redis and flushed to the database every 30 seconds (write-behind). Video metadata uses write-through: the service writes the database and the cache together.
What you see in production
After a Redis failover, 18 minutes of view counts disappeared. Creators noticed their numbers dropped. Separately, write-through metadata sometimes shows values that were rolled back in the database.
The naive code
def record_view(video_id):
r.incr(f"views:{video_id}") # only in Redis until the next flush
def flush(): # every 30 s
for key in r.scan_iter("views:*"):
n = int(r.getset(key, 0))
db.add_views(key.split(":")[1], n) # crash between GETSET and commit = lost views
def update_title(video_id, title):
r.hset(f"video:{video_id}", "title", title) # cache first…
db.update_title(video_id, title) # …then the database may fail or roll back
Reproduce it
A write-behind buffer that loses its unflushed increments in a crash, versus a buffer that keeps increments until the database commit succeeds:
def run(ack_after_commit):
buffer, db_views, pending = 0, 0, 0
for second in range(1, 7):
buffer += 1000 # 1,000 views per second
if second % 3 == 0: # flush every 3 s; the second flush crashes before commit
crash = second == 6
if ack_after_commit:
pending, buffer = pending + buffer, 0 # claimed but kept (e.g. Redis Stream pending entries)
if crash:
continue # pending entries survive and are re-processed on restart
db_views += pending # commit…
pending = 0 # …then acknowledge
else:
taken, buffer = buffer, 0 # GETSET: removed from Redis immediately
if crash:
continue # these views are gone
db_views += taken
return db_views, buffer + pending
for mode in (False, True):
saved, recoverable = run(mode)
print(f"{'ack after commit' if mode else 'GETSET then write':18}: in database {saved:,}, "
f"recoverable after restart {recoverable:,}, lost {6000 - saved - recoverable:,}")
Output
GETSET then write : in database 3,000, recoverable after restart 0, lost 3,000 ack after commit : in database 3,000, recoverable after restart 3,000, lost 0
Root cause
- Write-behind makes the cache the temporary system of record. Anything not yet flushed is lost when the cache loses data (restart without persistence, failover with async replication, a flush job crash).
- Write-through writes two systems without a shared transaction: if the database write fails after the cache write, the cache serves data that never existed.
The fix
- Write-through: write the database first, then update/delete the cache after commit (and prefer delete over update — see the stale-set lesson).
- Write-behind: use it only for data where loss is acceptable or recoverable, and make flushes durable and idempotent:
- Append increments to a durable log (Redis Stream with AOF, or Kafka) and acknowledge only after the database commit.
- Make flushes idempotent (batch ids) so a retry after a crash doesn't double-count.
- Keep the raw events (or periodic snapshots) to rebuild counts.
def record_view(video_id):
r.xadd("views", {"video": video_id}, maxlen=10_000_000, approximate=True)
def flush(consumer="flusher-1"):
batch = r.xreadgroup("flush", consumer, {"views": ">"}, count=50_000) or []
for _, entries in batch:
counts = Counter(e[1][b"video"].decode() for e in entries)
batch_id = entries[-1][0].decode()
with db.begin() as tx:
tx.execute(text("INSERT INTO view_batches (id) VALUES (:id) ON CONFLICT DO NOTHING"), {"id": batch_id})
for vid, n in counts.items():
tx.execute(text("UPDATE videos SET views = views + :n WHERE id = :v"), {"n": n, "v": vid})
r.xack("views", "flush", *[e[0] for e in entries]) # ack only after commit
@Transactional
public void updateTitle(long id, String title) {
videos.updateTitle(id, title);
events.publishEvent(new VideoChanged(id)); // @TransactionalEventListener(AFTER_COMMIT) → cache.evict(id)
}
(A complete version also skips batches whose id is already recorded, so a retried batch isn't counted twice.)
Trade-offs
- Write-behind gives huge write throughput and absorbs bursts, at the price of durability and extra machinery.
- Write-through keeps reads fast after writes but adds latency to writes and caches data that may never be read.
- For money or inventory, don't use write-behind at all.
Detect it in production
- Buffer size and age of the oldest unflushed increment; flush failures.
- Reconciliation: periodically recompute counts from raw events and compare.
Key points
- Write-behind: fast, but the cache holds data not yet in the database — make flushes durable, idempotent and acknowledged after commit.
- Write-through: database first, cache after commit.
- Never use write-behind for data you can't afford to lose.
Practice questions
What risk does a write-behind cache add?
Answer
Writes acknowledged by the cache but not yet flushed to the database are lost if the cache loses data (restart, failover, flush job crash).
How do you make a write-behind flush safe?
Answer
Keep increments in a durable log (e.g. a Redis Stream with persistence or Kafka), commit the batch to the database idempotently (batch id), and acknowledge/remove entries only after the commit succeeds.
In write-through, why write the database before the cache?
Answer
If the database write fails or rolls back after the cache was updated, the cache serves data that never existed. Writing the database first and invalidating after commit avoids that.
Open in the app to mark lessons complete, take notes and track your progress.