{"id":"cache-101","title":"Cache 101: failure modes, topologies and consistency","sections":[{"id":"overview","title":"Overview","html":"<p>Putting an in-memory cache in front of a database is usually the first fix anyone suggests when queries get slow. The idea fits in one sentence: check Redis, and if the key isn't there, query the database and store the result in Redis with a one-hour TTL.</p>\n<p>Most of the trouble comes later, in production. When a service takes 50,000 requests a second, one hot key expiring can send thousands of identical queries to the database at once (a <strong>thundering herd</strong>). A cache restart, or a batch of keys that all expire together, can do the same thing across the whole key space (a <strong>cache avalanche</strong>). Requests for IDs that don't exist skip the cache every time and land on the database (<strong>cache penetration</strong>). And two writers updating the cache and the database in different orders can leave the cache holding a stale value until its TTL runs out.</p>\n<p>This chapter covers those three failure modes and their standard fixes, where caches sit in a system, how Redis is built and how it compares with Memcached, Redis Sentinel and Redis Cluster with its 16,384 hash slots, and how to keep a cache consistent with the database, ending with invalidation driven by change data capture (CDC). It's a companion to the primer's <a href=\"/learn/cache\">cache</a> chapter and builds on <a href=\"/learn/reliability-patterns\">reliability patterns</a>.</p>","headings":[]},{"id":"failure-modes-and-how-to-prevent-them","title":"Failure modes and how to prevent them","html":"<h3 id=\"failure-modes-and-how-to-prevent-them\">Failure modes and how to prevent them</h3>\n<p>All three failure modes end the same way: requests the cache was supposed to absorb reach the database instead, faster than it can serve them.</p>\n<figure class=\"diagram\" data-text=\"Stampede one hot key expires -> Database overloaded (same query × N); Avalanche many keys expire or cache restarts -> Database overloaded (misses on every key); Penetration keys that don&#x27;t exist -> Database overloaded (every lookup)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 640 265\" width=\"640\" role=\"img\" aria-label=\"Three ways load gets past the cache\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge dg-dashed\" x1=\"129.3\" y1=\"79\" x2=\"272\" y2=\"186\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"320\" y1=\"79\" x2=\"320\" y2=\"186\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"510.7\" y1=\"79\" x2=\"368\" y2=\"186\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-box\" x=\"12\" y=\"8\" width=\"136\" height=\"68\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"30.5\">Stampede</tspan><tspan x=\"80\" y=\"46.5\">one hot key</tspan><tspan x=\"80\" y=\"62.5\">expires</tspan></text><rect class=\"dg-node dg-box\" x=\"252\" y=\"8\" width=\"136\" height=\"68\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"30.5\">Avalanche</tspan><tspan x=\"320\" y=\"46.5\">many keys expire</tspan><tspan x=\"320\" y=\"62.5\">or cache restarts</tspan></text><rect class=\"dg-node dg-box\" x=\"492\" y=\"8\" width=\"136\" height=\"68\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"30.5\">Penetration</tspan><tspan x=\"560\" y=\"46.5\">keys that</tspan><tspan x=\"560\" y=\"62.5\">don't exist</tspan></text><path class=\"dg-node dg-store\" d=\"M252,198 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M252,198 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"221.5\">Database</tspan><tspan x=\"320\" y=\"237.5\">overloaded</tspan></text><rect class=\"dg-edge-bg\" x=\"152.3\" y=\"123.5\" width=\"96.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"200.7\" y=\"136.5\" text-anchor=\"middle\">same query × N</text><rect class=\"dg-edge-bg\" x=\"256.1\" y=\"123.5\" width=\"127.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"320\" y=\"136.5\" text-anchor=\"middle\">misses on every key</text><rect class=\"dg-edge-bg\" x=\"397.1\" y=\"123.5\" width=\"84.4\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"439.3\" y=\"136.5\" text-anchor=\"middle\">every lookup</text></svg></div><figcaption>Three ways load gets past the cache</figcaption></figure>\n<h4 id=\"cache-stampede-thundering-herd\">Cache stampede (thundering herd)</h4>\n<p>A <strong>cache stampede</strong> happens when a very popular key, such as the front-page story on a news site, expires or is evicted. Every request that arrives before someone puts the value back is a miss, and every miss runs the same query.</p>\n<p>Some numbers, as an illustration. Say the key gets 10,000 reads a second and takes 200 ms to rebuild from the database:</p>\n<ul>\n<li>While the key is missing, 10,000 × 0.2 s = <strong>2,000 requests</strong> miss it.</li>\n<li>Each of them runs the same expensive query.</li>\n<li>The database's connection pool fills up, queries queue, and the 200 ms rebuild gets slower, which keeps the key missing for longer and lets even more requests through. Without a fix this feeds on itself.</li>\n</ul>\n<p>There are three common fixes.</p>\n<h5 id=\"fix-1-a-lock-so-only-one-request-rebuilds\">Fix 1: a lock, so only one request rebuilds</h5>\n<p>On a miss, a request tries to take a short-lived lock for that key before going to the database. In Redis that's one command, <code>SET lock:&#x3C;key> &#x3C;random-token> NX PX 5000</code>, which succeeds only if the lock doesn't exist yet and expires on its own after 5 seconds in case the holder dies.</p>\n<ul>\n<li><strong>The request that gets the lock</strong> queries the database, writes the value into the cache, and releases the lock. Release it with a small Lua script that deletes the lock only if it still holds your token, so you never delete a lock that expired and was taken by someone else.</li>\n<li><strong>Every other request</strong> waits briefly (say 50 ms) and reads the cache again, or serves a slightly stale copy if you keep one around.</li>\n</ul>\n<p>This costs a lock round trip on every miss and makes waiting requests slower, but the database sees one query instead of 2,000. Facebook's memcache paper describes a similar idea called <strong>leases</strong>: the cache itself hands out a token to the first missing client and tells the others to retry shortly.</p>\n<h5 id=\"fix-2-probabilistic-early-expiration-xfetch\">Fix 2: probabilistic early expiration (XFetch)</h5>\n<p>The 2015 paper <em>Optimal Probabilistic Cache Stampede Prevention</em> by Andrea Vattani, Flavio Chierichetti and Keegan Lowenstein describes an algorithm called <strong>XFetch</strong>. Instead of waiting for the key to expire, each reader decides at random whether to refresh it early, and the chance of refreshing goes up as expiry gets closer.</p>\n<p>Store two extra things with the value: <code>delta</code>, how long the last rebuild took, and <code>expiry</code>, the time the value expires. On every read:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>if now − delta × beta × ln(rand()) ≥ expiry:</span></span>\n<span class=\"line\"><span>    recompute the value, store it with a new expiry and a new delta</span></span>\n<span class=\"line\"><span>else:</span></span>\n<span class=\"line\"><span>    return the cached value</span></span></code></pre>\n<ul>\n<li><code>rand()</code> is a uniform random number in (0, 1]. Its natural log is zero or negative, so <code>−delta × beta × ln(rand())</code> is a positive amount of time. That amount follows an exponential distribution with mean <code>delta × beta</code>.</li>\n<li>In words: refresh early if a random \"head start\" is larger than the time left before expiry (<code>expiry − now</code>).</li>\n<li><code>beta</code> defaults to 1. Larger values refresh earlier; smaller values refresh later.</li>\n</ul>\n<p>When expiry is far away, the head start almost never covers the time left, so nobody refreshes. In the last few multiples of <code>delta</code> before expiry, the probability climbs quickly. With many readers, one of them is very likely to refresh before the key expires, so most readers never see a miss. Scaling by <code>delta</code> matters: a value that takes 2 seconds to rebuild starts refreshing much earlier than one that takes 20 ms.</p>\n<p>It's probabilistic, so it doesn't guarantee a single refresh. Two readers can both win the draw at nearly the same moment, which just means two queries instead of one. A key read only rarely may still expire before anyone refreshes it, but a rarely read key can't cause a stampede anyway. The paper shows this exponential rule is the best trade-off between refreshing too early (wasted work) and too late (a stampede). No locks or coordination are needed. Teams often combine it with a lock as a backstop.</p>\n<h5 id=\"fix-3-refresh-in-the-background\">Fix 3: refresh in the background</h5>\n<p>For a handful of known hot keys, don't let them expire at all. A background job rebuilds them on a schedule and overwrites the cache entry. Readers never miss. This works well for a small, known set of keys and doesn't scale to millions of them.</p>\n<h4 id=\"cache-avalanche\">Cache avalanche</h4>\n<p>A <strong>cache avalanche</strong> is the same problem across many keys: a large share of the key space misses at the same time. Two common causes:</p>\n<ul>\n<li><strong>Keys that expire together.</strong> A batch job warms the cache at 02:00 and sets every key with the same TTL, for example <code>redis.set(key, value, ex=3600)</code>. At 03:00 every one of those keys expires within the same second, and all their traffic hits the database.</li>\n<li><strong>A cache node restarting empty.</strong> If a Redis node holding a third of the keys dies and comes back with no data, every request for those keys misses until they're rebuilt.</li>\n</ul>\n<h5 id=\"fix-add-jitter-to-ttls\">Fix: add jitter to TTLs</h5>\n<p>Don't give a batch of keys the same TTL. Add a random amount (<strong>jitter</strong>) to each:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">import</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> random</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">def</span><span style=\"color:#6F42C1;--shiki-dark:#B392F0\"> cache_set_with_jitter</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">(redis, key, value, base_ttl</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">3600</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">, jitter_pct</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">0.15</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">):</span></span>\n<span class=\"line\"><span style=\"color:#6A737D;--shiki-dark:#6A737D\">    # ±15% of one hour: somewhere between 3,060 and 4,140 seconds</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    spread </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> base_ttl </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">*</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> jitter_pct</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    ttl </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> base_ttl </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">+</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> random.uniform(</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">-</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">spread, spread)</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    redis.set(key, value, </span><span style=\"color:#E36209;--shiki-dark:#FFAB70\">ex</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">int</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">(ttl))</span></span></code></pre>\n<p>The keys now expire spread over about 18 minutes instead of in one second, and the database sees a steady trickle of rebuilds.</p>\n<p>For the restart case, jitter doesn't help. What helps is replicas that can take over with their data intact (see Sentinel and Cluster below), warming a new node before it takes traffic, and putting a <a href=\"/learn/reliability-patterns\">circuit breaker or rate limit</a> in front of the database so a burst of misses gets shed instead of taking it down.</p>\n<h4 id=\"cache-penetration\">Cache penetration</h4>\n<p><strong>Cache penetration</strong> happens when clients ask for keys that don't exist in the database at all, for example <code>GET /users/-9999</code>, or a scanner trying random IDs. The usual cache-aside code does this:</p>\n<ol>\n<li>The request misses the cache.</li>\n<li>It queries the database and finds no row.</li>\n<li>There's nothing to cache, so nothing is cached.</li>\n<li>The next request for the same ID does exactly the same thing.</li>\n</ol>\n<p>The cache never helps. Someone sending 20,000 made-up IDs a second sends 20,000 queries a second straight to the database.</p>\n<h5 id=\"fix-1-cache-the-not-found\">Fix 1: cache the \"not found\"</h5>\n<p>When the database returns nothing, cache a marker for that key with a short TTL:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">redis.set(</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"user:-9999\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">, </span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"__none__\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">, </span><span style=\"color:#E36209;--shiki-dark:#FFAB70\">ex</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">60</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">)</span></span></code></pre>\n<p>For the next 60 seconds, requests for that ID get a 404 from the cache. Keep the TTL short, because the ID might be created later. This works well for repeated misses on the same IDs. It does little against random IDs, since each new ID is a new miss, and it fills the cache with markers.</p>\n<h5 id=\"fix-2-a-bloom-filter-in-front-of-the-cache\">Fix 2: a Bloom filter in front of the cache</h5>\n<p>A <strong>Bloom filter</strong> is a compact data structure that answers \"is this key in the set?\" with either \"definitely not\" or \"probably yes\". It's an array of m bits and k hash functions:</p>\n<ul>\n<li>To add a key, hash it k times and set those k bits to 1.</li>\n<li>To check a key, hash it k times. If any of those bits is 0, the key was never added. If all are 1, it was probably added, though other keys might have set those bits (a false positive).</li>\n</ul>\n<p>You size it for the false positive rate you want. About 9.6 bits per key gives a 1% false positive rate, so 100 million keys fit in roughly 120 MB.</p>\n<p>To use it against penetration, add every ID to the filter when the row is created, and check the filter before the cache:</p>\n<ul>\n<li><strong>\"Definitely not\"</strong>: return 404 without touching the cache or the database.</li>\n<li><strong>\"Probably yes\"</strong>: go on to the cache and then the database as usual. About 1% of requests for missing IDs still get through, which is fine.</li>\n</ul>\n<figure class=\"diagram\" data-text=\"Client GET /users/:id <-> Bloom filter (&#x22;definitely not&#x22;: 404); Bloom filter -> Redis cache (&#x22;probably yes&#x22;); Redis cache -> Database (cache miss)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 -13 640 240\" width=\"640\" role=\"img\" aria-label=\"A Bloom filter turns away IDs that don&#x27;t exist\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"153\" y1=\"42\" x2=\"247\" y2=\"42\" marker-end=\"url(#dg-arrow)\" marker-start=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"391\" y1=\"42\" x2=\"487\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"560\" y1=\"74\" x2=\"560\" y2=\"152\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-client\" x=\"12\" y=\"16\" width=\"136\" height=\"52\" rx=\"26\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"38.5\">Client</tspan><tspan x=\"80\" y=\"54.5\">GET /users/:id</tspan></text><rect class=\"dg-node dg-box\" x=\"252\" y=\"18\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"46.5\">Bloom filter</tspan></text><path class=\"dg-node dg-store\" d=\"M492,20 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,20 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"49.5\">Redis cache</tspan></text><path class=\"dg-node dg-store\" d=\"M492,164 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,164 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"193.5\">Database</tspan></text><rect class=\"dg-edge-bg\" x=\"129.9\" y=\"-6\" width=\"140.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"200\" y=\"7\" text-anchor=\"middle\">\"definitely not\": 404</text><rect class=\"dg-edge-bg\" x=\"390.6\" y=\"-9\" width=\"96.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"439\" y=\"4\" text-anchor=\"middle\">\"probably yes\"</text><rect class=\"dg-edge-bg\" x=\"524\" y=\"104\" width=\"72\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"560\" y=\"117\" text-anchor=\"middle\">cache miss</text></svg></div><figcaption>A Bloom filter turns away IDs that don't exist</figcaption></figure>\n<p>Two caveats. A plain Bloom filter can't remove keys, so deleted IDs stay \"probably yes\" until you rebuild it periodically (or use a variant such as a counting Bloom filter or cuckoo filter). And the filter must learn about a new ID before anyone asks for it, so add to it in the same code path that creates the row, or a new user can briefly get a 404.</p>","headings":[{"id":"failure-modes-and-how-to-prevent-them","title":"Failure modes and how to prevent them","depth":3},{"id":"cache-stampede-thundering-herd","title":"Cache stampede (thundering herd)","depth":4},{"id":"cache-avalanche","title":"Cache avalanche","depth":4},{"id":"cache-penetration","title":"Cache penetration","depth":4}]},{"id":"where-caches-sit","title":"Where caches sit","html":"<h3 id=\"where-caches-sit\">Where caches sit</h3>\n<p>Caches appear at several layers. The two you design for most often in a back end are a cache inside each application process and a shared cache on the network.</p>\n<figure class=\"diagram\" data-text=\"Client -> App server 1 L1 local cache; Client -> App server 2 L1 local cache; App server 1 L1 local cache -> L2: Redis cluster (L1 miss); App server 2 L1 local cache -> L2: Redis cluster (L1 miss); L2: Redis cluster -> Database (L2 miss)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 640 287\" width=\"640\" role=\"img\" aria-label=\"A two-tier cache\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"151\" y1=\"84.3\" x2=\"247\" y2=\"60.3\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"151\" y1=\"119.8\" x2=\"247\" y2=\"143.8\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"391\" y1=\"59.8\" x2=\"487\" y2=\"83.8\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"391\" y1=\"144.3\" x2=\"487\" y2=\"120.3\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"560\" y1=\"136\" x2=\"560\" y2=\"212\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-client\" x=\"12\" y=\"78\" width=\"136\" height=\"48\" rx=\"24\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"106.5\">Client</tspan></text><rect class=\"dg-node dg-box\" x=\"252\" y=\"16\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"38.5\">App server 1</tspan><tspan x=\"320\" y=\"54.5\">L1 local cache</tspan></text><rect class=\"dg-node dg-box\" x=\"252\" y=\"136\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"158.5\">App server 2</tspan><tspan x=\"320\" y=\"174.5\">L1 local cache</tspan></text><path class=\"dg-node dg-store\" d=\"M492,78 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,78 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"101.5\">L2: Redis</tspan><tspan x=\"560\" y=\"117.5\">cluster</tspan></text><path class=\"dg-node dg-store\" d=\"M492,224 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,224 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"253.5\">Database</tspan></text><rect class=\"dg-edge-bg\" x=\"412.3\" y=\"62.8\" width=\"53.4\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"439\" y=\"75.8\" text-anchor=\"middle\">L1 miss</text><rect class=\"dg-edge-bg\" x=\"412.3\" y=\"123.3\" width=\"53.4\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"439\" y=\"136.3\" text-anchor=\"middle\">L1 miss</text><rect class=\"dg-edge-bg\" x=\"533.3\" y=\"165\" width=\"53.4\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"560\" y=\"178\" text-anchor=\"middle\">L2 miss</text></svg></div><figcaption>A two-tier cache</figcaption></figure>\n<h4 id=\"local-l1-vs-distributed-l2-caches\">Local (L1) vs distributed (L2) caches</h4>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n<div class=\"table-wrap\"><table><thead><tr><th></th><th>Local, in-process (Caffeine, Guava, a plain LRU map)</th><th>Distributed (Redis, Memcached)</th></tr></thead><tbody><tr><td>Where it lives</td><td>In the application's own memory</td><td>Separate servers, reached over the network</td></tr><tr><td>Read latency</td><td>Well under a microsecond: a hash lookup in memory</td><td>Typically a few hundred microseconds to about a millisecond, mostly network round trip</td></tr><tr><td>Throughput</td><td>Limited by the app's CPU, often millions of lookups a second</td><td>Roughly 100,000 or more simple operations a second per Redis node</td></tr><tr><td>Consistency</td><td>Each server has its own copy, and copies can disagree</td><td>One copy per key, shared by every app server</td></tr><tr><td>Size</td><td>Limited by the process's memory, usually a few GB</td><td>Can grow to terabytes by adding nodes</td></tr><tr><td>Survives a deploy</td><td>No, it's empty after a restart</td><td>Yes</td></tr></tbody></table></div>\n<p>Large systems often use both, as a <strong>two-tier cache</strong>:</p>\n<ul>\n<li>Each app server keeps a small L1 with a very short TTL, a few seconds. That bounds how stale it can get.</li>\n<li>On an L1 miss, it reads the shared L2.</li>\n<li>When an entity changes, a message on Redis pub/sub or Kafka tells every app server to drop its L1 copy. The short TTL covers any message that gets lost.</li>\n</ul>\n<p>The L1 takes the hottest keys off the network entirely, which also protects the L2 from a single hot key overloading one Redis node.</p>","headings":[{"id":"where-caches-sit","title":"Where caches sit","depth":3},{"id":"local-l1-vs-distributed-l2-caches","title":"Local (L1) vs distributed (L2) caches","depth":4}]},{"id":"redis-internals-and-memcached","title":"Redis internals and Memcached","html":"<h3 id=\"redis-internals-and-memcached\">Redis internals and Memcached</h3>\n<p>A single Redis process commonly handles more than 100,000 simple operations a second. Keeping data in RAM is part of the reason, but there are three others.</p>\n<h4 id=\"a-single-thread-for-commands\">A single thread for commands</h4>\n<p>Redis runs every command on <strong>one thread</strong>.</p>\n<ul>\n<li>There are no locks inside Redis and no contention between threads. Each command runs to completion before the next starts, so every single command is atomic. <code>INCR</code> from two clients can't lose an update.</li>\n<li>One thread serves thousands of connections using I/O multiplexing (<strong>epoll</strong> on Linux, <strong>kqueue</strong> on macOS and BSD): the kernel reports which sockets have data, and Redis handles each in turn.</li>\n<li>Since Redis 6.0, reading and writing sockets can be spread over extra I/O threads, but commands themselves still execute on the main thread.</li>\n</ul>\n<p>The cost is that one slow command blocks everyone. <code>KEYS *</code> on a large database, or deleting a set with millions of members, stalls every client for its duration. Use <code>SCAN</code> and <code>UNLINK</code> (which frees memory in the background) instead.</p>\n<h4 id=\"data-structures-built-for-memory\">Data structures built for memory</h4>\n<p>Redis stores typed values, and picks a compact encoding when a value is small:</p>\n<ul>\n<li><strong>Strings</strong>: binary-safe, with length stored so <code>STRLEN</code> is O(1).</li>\n<li><strong>Hashes, lists and small sorted sets</strong>: stored as a compact <strong>listpack</strong> (one contiguous block of memory) while small, and converted to a real hash table, list or skip list once they pass a configurable size. For hashes the default limit is 128 entries.</li>\n<li><strong>Sets of integers</strong>: stored as a sorted integer array (intset) up to 512 members by default.</li>\n<li><strong>Sorted sets</strong>: a <strong>skip list</strong> plus a hash table, giving O(log N) inserts and rank and range queries. This is why Redis is popular for <a href=\"/learn/design-gaming-leaderboard\">leaderboards</a>.</li>\n<li><strong>HyperLogLog</strong>: estimates the number of distinct items in a set using at most 12 KB, with a standard error of 0.81%.</li>\n<li><strong>Bitmaps</strong>: one bit per user ID, for things like daily active user flags.</li>\n</ul>\n<h4 id=\"redis-vs-memcached\">Redis vs Memcached</h4>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n<div class=\"table-wrap\"><table><thead><tr><th></th><th>Redis</th><th>Memcached</th></tr></thead><tbody><tr><td>Values</td><td>Strings, hashes, lists, sets, sorted sets, streams and more, with server-side operations on them</td><td>Opaque byte strings</td></tr><tr><td>Threads</td><td>One thread runs commands (I/O can use more)</td><td>Multi-threaded, so one process uses all cores</td></tr><tr><td>Persistence</td><td>Optional snapshots (RDB) and append-only log (AOF)</td><td>None, memory only</td></tr><tr><td>Replication and failover</td><td>Built in, plus Sentinel and Cluster</td><td>None; clients spread keys across servers with consistent hashing</td></tr><tr><td>Typical use</td><td>Cache plus counters, queues, rate limits, leaderboards, locks</td><td>A large, simple, fast key-value cache</td></tr></tbody></table></div>\n<p>If all you need is a big cache of serialized objects, Memcached is simple and scales well on many-core machines. Most teams choose Redis because the data structures, replication and persistence let one system cover several jobs.</p>","headings":[{"id":"redis-internals-and-memcached","title":"Redis internals and Memcached","depth":3},{"id":"a-single-thread-for-commands","title":"A single thread for commands","depth":4},{"id":"data-structures-built-for-memory","title":"Data structures built for memory","depth":4},{"id":"redis-vs-memcached","title":"Redis vs Memcached","depth":4}]},{"id":"redis-sentinel-vs-redis-cluster","title":"Redis Sentinel vs Redis Cluster","html":"<h3 id=\"redis-sentinel-vs-redis-cluster\">Redis Sentinel vs Redis Cluster</h3>\n<p>Redis offers two ways to run more than one node. They solve different problems: Sentinel keeps one dataset available, and Cluster splits the dataset across nodes.</p>\n<figure class=\"diagram\" data-text=\"Primary -> Replica (replicates); Sentinels (3 processes) -> Primary (monitor); Sentinels (3 processes) -> Replica (promote); Node A slots 0–5460; Node B slots 5461–10922; Node C slots 10923–16383\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 784 237\" width=\"784\" role=\"img\" aria-label=\"Sentinel (failover) and Cluster (sharding)\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"80\" y1=\"74\" x2=\"80\" y2=\"152\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"249\" y1=\"92.7\" x2=\"153\" y2=\"63.9\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"249\" y1=\"135.3\" x2=\"153\" y2=\"164.1\" marker-end=\"url(#dg-arrow)\"></line><path class=\"dg-node dg-store\" d=\"M12,20 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M12,20 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"49.5\">Primary</tspan></text><path class=\"dg-node dg-store\" d=\"M12,164 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M12,164 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"193.5\">Replica</tspan></text><rect class=\"dg-node dg-box\" x=\"252\" y=\"88\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"110.5\">Sentinels</tspan><tspan x=\"320\" y=\"126.5\">(3 processes)</tspan></text><path class=\"dg-node dg-store\" d=\"M476,18 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M476,18 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"544\" y=\"41.5\">Node A</tspan><tspan x=\"544\" y=\"57.5\">slots 0–5460</tspan></text><path class=\"dg-node dg-store\" d=\"M636,82 a68,7 0 0,1 136,0 v64 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M636,82 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"704\" y=\"105.5\">Node B</tspan><tspan x=\"704\" y=\"121.5\">slots</tspan><tspan x=\"704\" y=\"137.5\">5461–10922</tspan></text><path class=\"dg-node dg-store\" d=\"M476,154 a68,7 0 0,1 136,0 v64 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M476,154 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"544\" y=\"177.5\">Node C</tspan><tspan x=\"544\" y=\"193.5\">slots</tspan><tspan x=\"544\" y=\"209.5\">10923–16383</tspan></text><rect class=\"dg-edge-bg\" x=\"44\" y=\"104\" width=\"72\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"80\" y=\"117\" text-anchor=\"middle\">replicates</text><rect class=\"dg-edge-bg\" x=\"174.3\" y=\"69.3\" width=\"53.4\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"201\" y=\"82.3\" text-anchor=\"middle\">monitor</text><rect class=\"dg-edge-bg\" x=\"174.3\" y=\"140.7\" width=\"53.4\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"201\" y=\"153.7\" text-anchor=\"middle\">promote</text></svg></div><figcaption>Sentinel (failover) and Cluster (sharding)</figcaption></figure>\n<h4 id=\"redis-sentinel\">Redis Sentinel</h4>\n<ul>\n<li>You run one primary and one or more replicas, all holding the full dataset.</li>\n<li>A group of Sentinel processes (at least three, on separate machines) checks them with heartbeats.</li>\n<li>When enough Sentinels to reach the configured quorum agree the primary is down, they elect a leader among themselves, which promotes a replica and tells clients where the new primary is.</li>\n<li>Sentinel gives you <strong>high availability, not more write capacity</strong>. Every write still goes to one primary, and the dataset must still fit on one machine.</li>\n</ul>\n<p>Replication is asynchronous, so writes acknowledged by the old primary just before it failed can be lost in a failover.</p>\n<h4 id=\"redis-cluster\">Redis Cluster</h4>\n<p>Redis Cluster splits the key space across several primaries, each with its own replicas.</p>\n<ul>\n<li>\n<p>The key space is divided into <strong>16,384 hash slots</strong>. Each key belongs to one slot:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>slot = CRC16(key) mod 16384</span></span></code></pre>\n</li>\n<li>\n<p>Each primary owns a set of slots. With three primaries, a default setup gives node A slots 0–5,460, node B slots 5,461–10,922 and node C slots 10,923–16,383.</p>\n</li>\n<li>\n<p>Moving data between nodes means moving slots. Adding a node is a matter of handing it some slots from the others; keys never need to be rehashed.</p>\n</li>\n<li>\n<p><strong>Hash tags</strong> keep related keys together. If a key contains <code>{...}</code>, only the part inside the braces is hashed. <code>{user:100}:profile</code> and <code>{user:100}:orders</code> both hash <code>user:100</code>, so they land in the same slot. That matters because multi-key commands (<code>MGET</code>, <code>MULTI</code>/<code>EXEC</code> transactions, Lua scripts) only work when all their keys are in one slot; otherwise Redis returns a <code>CROSSSLOT</code> error.</p>\n</li>\n<li>\n<p><strong>Clients route requests themselves.</strong> A cluster-aware client keeps a copy of the slot-to-node map and sends each command straight to the right node. If the slot has moved, the node replies with a redirect such as <code>-MOVED 3999 10.0.0.2:6379</code>, and the client updates its map and retries there. While a slot is mid-migration, a node can answer <code>-ASK</code> instead, which redirects just that one request.</p>\n</li>\n</ul>\n<p>Why 16,384? The Redis author's explanation is about message size: nodes gossip a bitmap of the slots they own, and 16,384 bits is 2 KB, which keeps heartbeat messages small while still allowing far more slots than any realistic cluster has nodes (the recommended maximum is about 1,000 nodes).</p>","headings":[{"id":"redis-sentinel-vs-redis-cluster","title":"Redis Sentinel vs Redis Cluster","depth":3},{"id":"redis-sentinel","title":"Redis Sentinel","depth":4},{"id":"redis-cluster","title":"Redis Cluster","depth":4}]},{"id":"keeping-the-cache-consistent-with-the-database","title":"Keeping the cache consistent with the database","html":"<h3 id=\"keeping-the-cache-consistent-with-the-database\">Keeping the cache consistent with the database</h3>\n<p>When data changes, two questions come up: update the cache or delete the key? And touch the database first or the cache first? The wrong answers leave a stale value in the cache that nothing will correct until the TTL expires.</p>\n<h4 id=\"why-update-the-database-then-update-the-cache-goes-wrong\">Why \"update the database, then update the cache\" goes wrong</h4>\n<p>Two writers change the same user at nearly the same time:</p>\n<ol>\n<li>Writer A sets the name to \"Ann\" in the database.</li>\n<li>Writer B sets the name to \"Bea\" in the database. The database now says \"Bea\".</li>\n<li>Writer B writes \"Bea\" into the cache.</li>\n<li>Writer A, delayed by a slow network or a GC pause, writes \"Ann\" into the cache.</li>\n</ol>\n<p>The database says \"Bea\" and the cache says \"Ann\", and every reader sees \"Ann\" until the TTL expires. The two writes reached the two systems in different orders, and nothing ties them together.</p>\n<h4 id=\"cache-aside-with-delete-after-commit\">Cache-aside with delete after commit</h4>\n<p>The usual pattern is <strong>cache-aside</strong> (also called lazy loading), with deletes instead of updates:</p>\n<ol>\n<li><strong>Reads:</strong> check the cache. On a miss, read the database and put the result in the cache.</li>\n<li><strong>Writes:</strong> update the database. After the transaction <strong>commits</strong>, delete the key from the cache.</li>\n</ol>\n<p>Deleting has no ordering problem between writers: two deletes in any order leave the same result, an empty slot. The next read loads whatever the database holds at that moment.</p>\n<figure class=\"diagram\" data-text=\"App server -> Database (1. write, commit); App server -> Cache (2. DEL key)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 512 203\" width=\"512\" role=\"img\" aria-label=\"Cache-aside: reads fill the cache, writes delete from it\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"151\" y1=\"89.9\" x2=\"359\" y2=\"54.4\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"151\" y1=\"114.1\" x2=\"359\" y2=\"149.6\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-box\" x=\"12\" y=\"78\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"106.5\">App server</tspan></text><path class=\"dg-node dg-store\" d=\"M364,20 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M364,20 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"432\" y=\"49.5\">Database</tspan></text><path class=\"dg-node dg-store\" d=\"M364,140 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M364,140 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"432\" y=\"169.5\">Cache</tspan></text><rect class=\"dg-edge-bg\" x=\"200.4\" y=\"63.2\" width=\"109.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"255\" y=\"76.2\" text-anchor=\"middle\">1. write, commit</text><rect class=\"dg-edge-bg\" x=\"219\" y=\"122.8\" width=\"72\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"255\" y=\"135.8\" text-anchor=\"middle\">2. DEL key</text></svg></div><figcaption>Cache-aside: reads fill the cache, writes delete from it</figcaption></figure>\n<h4 id=\"why-not-delete-the-cache-first\">Why not delete the cache first?</h4>\n<p>Deleting before the database write has a wide race window:</p>\n<ol>\n<li>Writer A deletes <code>user:1</code> from the cache, then starts its database transaction to change \"Alice\" to \"Bob\".</li>\n<li>Before A commits, reader B gets a miss on <code>user:1</code>.</li>\n<li>B reads the database and gets the old value, \"Alice\", because A hasn't committed.</li>\n<li>B writes \"Alice\" into the cache.</li>\n<li>A commits \"Bob\".</li>\n</ol>\n<p>The database says \"Bob\", the cache says \"Alice\", and nothing will fix it before the TTL. The window is the whole length of A's transaction, which can be many milliseconds, so this happens regularly under load.</p>\n<p>Delete-after-commit still has a race, just a much narrower one. A reader misses, reads the old value from the database, and then stalls. Meanwhile a writer commits and deletes the key. Then the reader wakes up and writes the old value into the cache. For this to happen, the reader's database read has to happen before the commit and its cache write after the delete, which means the reader was slower than the whole write. It's rare, but it does happen at scale. Common defenses:</p>\n<ul>\n<li><strong>Always set a TTL</strong>, so any stale value has a limit on how long it lasts.</li>\n<li><strong>Delayed double delete</strong>: delete again a second or so after the write, catching a late stale fill.</li>\n<li><strong>Leases or versioned writes</strong>: let the cache reject a fill that started before the most recent delete. Facebook's memcache leases do this.</li>\n</ul>\n<h4 id=\"invalidation-with-change-data-capture\">Invalidation with change data capture</h4>\n<p>Delete-after-commit has one more gap. If the app server crashes, or loses its connection to Redis, after the commit but before the <code>DEL</code>, the key is never deleted. You can retry, but a crash loses the retry too.</p>\n<p><strong>Change data capture (CDC)</strong> moves invalidation out of the application. The database already records every committed change in its log (the PostgreSQL write-ahead log or the MySQL binlog). A CDC tool reads that log and turns each change into an event.</p>\n<figure class=\"diagram\" data-text=\"App server -> Database WAL / binlog (SQL writes only); Database WAL / binlog -> Debezium (CDC) (reads the log); Debezium (CDC) -> Kafka topic (change event); Kafka topic -> Invalidation worker; Invalidation worker -> Redis (DEL user:42)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 -15 640 242\" width=\"640\" role=\"img\" aria-label=\"Cache invalidation driven by the database log\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"151\" y1=\"42\" x2=\"247\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"391\" y1=\"42\" x2=\"487\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"560\" y1=\"71\" x2=\"560\" y2=\"157\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"489\" y1=\"186\" x2=\"393\" y2=\"186\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"249\" y1=\"186\" x2=\"153\" y2=\"186\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-box\" x=\"12\" y=\"18\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"46.5\">App server</tspan></text><path class=\"dg-node dg-store\" d=\"M252,18 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M252,18 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"41.5\">Database</tspan><tspan x=\"320\" y=\"57.5\">WAL / binlog</tspan></text><rect class=\"dg-node dg-box\" x=\"492\" y=\"16\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"38.5\">Debezium</tspan><tspan x=\"560\" y=\"54.5\">(CDC)</tspan></text><rect class=\"dg-node dg-queue\" x=\"492\" y=\"162\" width=\"136\" height=\"48\" rx=\"6\"></rect><line class=\"dg-line\" x1=\"612\" y1=\"170\" x2=\"612\" y2=\"202\"></line><line class=\"dg-line\" x1=\"602\" y1=\"170\" x2=\"602\" y2=\"202\"></line><line class=\"dg-line\" x1=\"592\" y1=\"170\" x2=\"592\" y2=\"202\"></line><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"546\" y=\"190.5\">Kafka topic</tspan></text><rect class=\"dg-node dg-box\" x=\"252\" y=\"160\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"182.5\">Invalidation</tspan><tspan x=\"320\" y=\"198.5\">worker</tspan></text><path class=\"dg-node dg-store\" d=\"M12,164 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M12,164 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"193.5\">Redis</tspan></text><rect class=\"dg-edge-bg\" x=\"147.5\" y=\"-11\" width=\"103\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"199\" y=\"2\" text-anchor=\"middle\">SQL writes only</text><rect class=\"dg-edge-bg\" x=\"393.7\" y=\"-11\" width=\"90.6\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"439\" y=\"2\" text-anchor=\"middle\">reads the log</text><rect class=\"dg-edge-bg\" x=\"517.8\" y=\"105\" width=\"84.4\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"560\" y=\"118\" text-anchor=\"middle\">change event</text><rect class=\"dg-edge-bg\" x=\"161.9\" y=\"177\" width=\"78.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"201\" y=\"190\" text-anchor=\"middle\">DEL user:42</text></svg></div><figcaption>Cache invalidation driven by the database log</figcaption></figure>\n<p>How it works:</p>\n<ol>\n<li>The application runs ordinary SQL transactions. It has no cache invalidation code.</li>\n<li>The database writes each change to its log as part of committing.</li>\n<li><strong>Debezium</strong> (usually running as a Kafka Connect connector) reads the log through the database's replication interface: logical decoding in PostgreSQL, the binlog in MySQL. It emits events only for transactions that committed, so a rolled-back write never causes an invalidation.</li>\n<li>Each change becomes an event on a <strong>Kafka</strong> topic, one topic per table, named like <code>&#x3C;prefix>.&#x3C;schema>.&#x3C;table></code>. The event carries the row's key, and the before and after values.</li>\n<li>An invalidation worker consumes the topic and runs <code>DEL user:42</code> in Redis.</li>\n</ol>\n<p>What you get:</p>\n<ul>\n<li><strong>No coupling in the write path.</strong> A Redis outage can't fail or slow down a database transaction.</li>\n<li><strong>No lost invalidations.</strong> The worker commits its Kafka offset only after the <code>DEL</code> succeeds. If it crashes, it restarts from the last committed offset and replays some events. Delivery is at least once, and since deleting a key twice is harmless, the duplicates don't matter.</li>\n<li><strong>Every writer is covered</strong>, including batch jobs, admin scripts and other services that write to the same tables and know nothing about the cache.</li>\n</ul>\n<p>What it costs: a pipeline (Kafka, Kafka Connect, Debezium) you have to run and monitor, and invalidation that lags the commit by the pipeline's delay, usually milliseconds to a second or two. During that lag readers can see the old value, so CDC gives you eventual consistency, not read-your-writes. It also doesn't close the stale-fill race described above, so keep TTLs. The <a href=\"/learn/event-streaming\">event streaming</a> chapter covers Kafka in more depth.</p>","headings":[{"id":"keeping-the-cache-consistent-with-the-database","title":"Keeping the cache consistent with the database","depth":3},{"id":"why-update-the-database-then-update-the-cache-goes-wrong","title":"Why \"update the database, then update the cache\" goes wrong","depth":4},{"id":"cache-aside-with-delete-after-commit","title":"Cache-aside with delete after commit","depth":4},{"id":"why-not-delete-the-cache-first","title":"Why not delete the cache first?","depth":4},{"id":"invalidation-with-change-data-capture","title":"Invalidation with change data capture","depth":4}]},{"id":"check-yourself","title":"Check yourself","html":"<h3 id=\"check-yourself\">Check yourself</h3>\n<details>\n<summary>How does XFetch avoid a stampede without any locks, and why is the early-refresh window scaled by how long the value takes to compute?</summary>\n<p>Every reader runs the check <code>now − delta × beta × ln(rand()) ≥ expiry</code>. Because <code>−ln(rand())</code> is exponentially distributed, each reader draws a random head start whose average is <code>delta × beta</code>, where <code>delta</code> is how long the last rebuild took. If the head start reaches past the expiry time, that reader refreshes the value early and resets the expiry. Far from expiry the chance is close to zero; in the last few multiples of <code>delta</code> it rises quickly. With a hot key, many readers draw every second, so one is very likely to refresh before the key expires, and the rest never see a miss. No coordination is needed, though two readers occasionally refresh at once.</p>\n<p>Scaling by <code>delta</code> matters because the refresh has to finish before the key expires. A value that takes 2 seconds to build needs to start refreshing seconds ahead; one that takes 20 ms only needs a few tens of milliseconds.</p>\n</details>\n<details>\n<summary>A batch job warms 5 million keys at 02:00 with a TTL of exactly one hour. What happens at 03:00, and what one-line change fixes it?</summary>\n<p>At 03:00 all 5 million keys expire within about a second. Every request for any of them misses at once, and the database gets the whole read load the cache was absorbing: a cache avalanche. The fix is jitter: set each key's TTL to the base value plus a random offset, for example 3,600 s ± 15%, so the expirations spread over about 18 minutes and the database sees a steady stream of rebuilds instead of a wall.</p>\n</details>\n<details>\n<summary>Why is \"delete the cache, then update the database\" worse than \"update the database, then delete the cache\"?</summary>\n<p>If you delete first, any reader that arrives while the write transaction is still open misses the cache, reads the old committed value from the database, and puts it back in the cache. When the writer commits, the cache holds the old value and nothing will remove it before the TTL. The window is the whole transaction, so this happens often.</p>\n<p>If you update first and delete after the commit, the only race needs a reader that read the old value before the commit and then wrote it into the cache after the delete, meaning the reader was slower than the entire write. That's much rarer. TTLs, a delayed second delete, or leases handle what's left.</p>\n</details>\n<details>\n<summary>How does Redis Cluster decide which node holds a key, and how do you make sure `user:42`'s profile and orders can be read in one `MGET`?</summary>\n<p>Redis Cluster computes <code>CRC16(key) mod 16384</code> to get one of 16,384 hash slots, and each primary owns a set of slots. Clients cache the slot map and follow <code>MOVED</code> redirects when it changes. By default, <code>user:42:profile</code> and <code>user:42:orders</code> hash to unrelated slots, probably on different nodes, and a multi-key command across them fails with a <code>CROSSSLOT</code> error. Name them <code>{user:42}:profile</code> and <code>{user:42}:orders</code>: when a key contains braces, only the text inside is hashed, so both keys land in the slot for <code>user:42</code> and <code>MGET</code> works.</p>\n</details>\n<details>\n<summary>Your service caches users, but a nightly batch job and a second service also write to the users table. Why is CDC a better fit here than delete-after-commit in your code?</summary>\n<p>Delete-after-commit only works for writes that go through your code. The batch job and the other service don't know about your cache, so their changes never trigger a delete, and the cache serves stale users until TTLs expire. CDC reads the database's own log, so every committed change produces an invalidation event no matter who wrote it. It also survives crashes: the worker resumes from its last Kafka offset, and repeating a <code>DEL</code> is harmless. The cost is running the pipeline and accepting a short lag before invalidation.</p>\n</details>\n<h3 id=\"further-reading\">Further reading</h3>\n<ul>\n<li><a href=\"https://www.vldb.org/pvldb/vol8/p886-vattani.pdf\" target=\"_blank\" rel=\"noopener noreferrer\">Optimal Probabilistic Cache Stampede Prevention</a> (Vattani, Chierichetti and Lowenstein, VLDB 2015): the XFetch algorithm and why the exponential rule is the right one.</li>\n<li><a href=\"https://www.usenix.org/conference/nsdi13/technical-sessions/presentation/nishtala\" target=\"_blank\" rel=\"noopener noreferrer\">Scaling Memcache at Facebook</a> (Nishtala et al., NSDI 2013): leases against stampedes and stale sets, and invalidation across regions.</li>\n<li><a href=\"https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/\" target=\"_blank\" rel=\"noopener noreferrer\">Redis cluster specification</a>: hash slots, hash tags, gossip, and <code>MOVED</code> and <code>ASK</code> redirects.</li>\n<li><a href=\"https://debezium.io/documentation/reference/stable/architecture.html\" target=\"_blank\" rel=\"noopener noreferrer\">Debezium architecture</a>: how Debezium reads database logs and publishes change events through Kafka Connect.</li>\n<li><a href=\"https://dl.acm.org/doi/10.1145/362686.362692\" target=\"_blank\" rel=\"noopener noreferrer\">Space/Time Trade-offs in Hash Coding with Allowable Errors</a> (Burton H. Bloom, 1970): the paper that introduced Bloom filters.</li>\n</ul>","headings":[{"id":"check-yourself","title":"Check yourself","depth":3},{"id":"further-reading","title":"Further reading","depth":3}]}],"sourceFiles":[]}