sehaj/learn
System design/06 Caching

06.02 · concept · 11 min

Cache-aside, write-through, write-behind

Reading through a cache is the easy half. When the data changes, do you write the cache, the database, or both, and in which order? Cache-aside, read-through, write-through, write-around and write-behind, compared.

Until now, a Snip link never changed once created, so a cached copy was never wrong. Then Snip launches a paid plan, and its most requested feature is editing a link's destination: a campaign link x7Kp that pointed at a spring sale should point at the summer sale from Monday. Snip now has to decide what happens to the cached copy when the database row changes.

There are two halves to this. Reads: who fills the cache on a miss? Writes: when data changes, what happens to the cache and the database, and in what order?

Reads: cache-aside or read-through

  • Cache-aside (Snip since 02.04 Read replicas and a cache): the app checks the cache, and on a miss reads the database and fills the cache itself. The cache knows nothing about the database.
  • Read-through: the app only talks to the cache, and the cache (or a library wrapping it) loads from the database on a miss. The logic is the same, but it lives in one place instead of in every caller.

Both give the same results. Read-through is tidier when many services share the logic; cache-aside is simpler to start with and works with any cache.

Writes: four choices

PatternOn a writeReads right afterRisk
Write-around + deleteWrite the database, delete the cache entryOne miss, then freshA small race window (06.04 Invalidation and staleness)
Write-throughWrite the database and the cache, as one stepFresh and already cachedSlower writes; caches data that may never be read
Write-behind (write-back)Write the cache now; the database later, in batchesFresh from the cacheWrites lost if the cache dies before flushing
Write-around, no deleteWrite the database onlyStale until the TTL expiresStaleness bounded only by the TTL

Write-around with delete is the default for most systems, and it's what Snip uses for edits:

ts
// redis is an ioredis client; db is a pg-promise database.

// Reading: cache-aside
async function destination(code: string): Promise<string | null> {
  const hit = await redis.get(`link:${code}`);
  if (hit !== null) return hit;
  const row = await db.oneOrNone("SELECT long_url FROM links WHERE code = $1", [code]);
  if (row) await redis.set(`link:${code}`, row.long_url, "EX", 3600);
  return row?.long_url ?? null;
}

// Editing: update the database first, then delete the cached copy
async function editDestination(code: string, longUrl: string) {
  await db.none("UPDATE links SET long_url = $2 WHERE code = $1", [code, longUrl]);
  await redis.del(`link:${code}`);
}

Why delete, and why in that order

Delete, don't overwrite. Suppose two edits to x7Kp happen at almost the same moment, and each writer sets the cache to its new value after updating the database. The database ends with whichever update committed last, but the cache ends with whichever SET arrived last, and those needn't match:

Two edits that each overwrite the cache
Edit A (summer)Edit B (autumn)PostgresRedis1 UPDATE → summer2 UPDATE → autumn3 SET autumn4 SET summer

4 messages. Use Next and Back to follow them in order.

Text version
  1. Edit A (summer) → Postgres: UPDATE → summer — Edit A commits first.
  2. Edit B (autumn) → Postgres: UPDATE → autumn — Edit B commits second, so the database's final value is autumn.
  3. Edit B (autumn) → Redis: SET autumn — B is quick and writes the cache first.
  4. Edit A (summer) → Redis: SET summer (fails) — A was paused for a moment (a slow network, a garbage collection) and writes the cache last. The cache now says summer, the database says autumn, and nothing will fix it until the TTL expires.

If both writers delete instead, the order doesn't matter: the entry is gone either way, and the next read fills it from the database, which has the right answer. Deletes are also safe to repeat, so a retried delete can't do harm.

Database first, then cache. Delete the cache first and a read can slip in between, find nothing, read the old row from the database and put it back in the cache, before your update commits. Updating the database first shrinks that window to a much rarer case, which 06.04 Invalidation and staleness covers. And if the delete fails after the update succeeds, the TTL is the safety net, which is one reason every entry has one.

Write-through: always warm, always paying

With write-through, every write goes to the database and the cache together, so reads after a write are hits. It suits data that's read soon after it's written. The costs: every write pays for two stores, and you cache things that may never be read. In practice "write-through" is usually still database first, then cache, so the two-writer race above still applies unless the cache rejects older versions (06.04 Invalidation and staleness).

Snip doesn't use it for links: most links are created and then clicked a handful of times, and an edit is rare.

Write-behind: fast, until the cache dies

With write-behind, the app writes only to the cache and the cache, or a worker, copies changes to the database later, in batches. Writes are as fast as the cache, and many updates to the same key collapse into one database write.

The danger is in the gap: anything written but not yet flushed exists only in the cache's memory. Snip considered it for click counts, with INCR clicks:x7Kp on every redirect and a worker writing the totals to Postgres every few seconds. It's fast, but a Redis node crashing would lose up to a flush interval of clicks for every code on that node. Snip keeps its click queue (02.06 Async workers and queues), which holds events durably until workers have counted them, because owners pay for accurate counts. Write-behind fits data where losing the last few seconds is acceptable: view counters, "last seen" times, rate-limit counters.

Seen in production

Amazon

Docs

DynamoDB Accelerator (DAX)

DAX describes itself as a write-through cache in front of DynamoDB. A write goes to DynamoDB first; only after DynamoDB confirms it does DAX update its item cache and reply, and a write that fails isn't cached. But DAX keeps two caches, and only the item cache is refreshed: query and scan results live in a separate query cache, and "updates to the item cache, or to the underlying DynamoDB table, do not invalidate" them. Writes made directly to DynamoDB, bypassing DAX, don't refresh its item cache either.

What we foundA write-through cache, where the subtle part is which cached results a write does and doesn't refresh.

DAX and DynamoDB consistency models (AWS documentation)docs.aws.amazon.com ↗
Predict first

Snip's edit code deletes the cache entry first, then updates the database. Users occasionally see an old destination for up to an hour after an edit. Why?

Commit to an answer before you read on.

Recap

Interview questions

MidfreqMust-knowConcept

Compare cache-aside, write-through and write-behind. Which would you choose?

Report a mistakeEdit this page
☰ Caching06.02 · 48/147next →