The problem: links can change now
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
| Pattern | On a write | Reads right after | Risk |
|---|---|---|---|
| Write-around + delete | Write the database, delete the cache entry | One miss, then fresh | A small race window (06.04 Invalidation and staleness) |
| Write-through | Write the database and the cache, as one step | Fresh and already cached | Slower writes; caches data that may never be read |
| Write-behind (write-back) | Write the cache now; the database later, in batches | Fresh from the cache | Writes lost if the cache dies before flushing |
| Write-around, no delete | Write the database only | Stale until the TTL expires | Staleness bounded only by the TTL |
Write-around with delete is the default for most systems, and it's what Snip uses for edits:
// 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:
4 messages. Use Next and Back to follow them in order.
Text version
- Edit A (summer) → Postgres: UPDATE → summer — Edit A commits first.
- Edit B (autumn) → Postgres: UPDATE → autumn — Edit B commits second, so the database's final value is autumn.
- Edit B (autumn) → Redis: SET autumn — B is quick and writes the cache first.
- 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
DocsDynamoDB 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 ↗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?
Recap
Interview questions
Compare cache-aside, write-through and write-behind. Which would you choose?