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

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:

Write-behind: what a crash losesTry it yourself
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

(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.