The previous lesson left Kilometre Zero's catalogue in PostgreSQL with jsonb and a promise: that Redis would be put in front of it. This lesson keeps the promise and closes the module. A cache keeps a copy of data that is expensive to obtain (a database query, a gRPC call to inventory, a resized image) somewhere faster and closer, so as not to pay that cost again while the copy is still useful. It sounds simple, and in its basic form it is; the hard part is everything surrounding "while it is still useful": when the copy expires, how it is invalidated when the data changes, what happens when thousands of requests discover at once that it has expired, what to do about the key everybody asks for, and how to stop the cache returning something the database no longer says. We will look at the patterns (cache-aside, read-through, write-through, write-behind, refresh-ahead), the classic problems and their solutions, the Memcached/Redis comparison, and Redis Cluster with its 16,384 slots as a reincarnation of the partitioning of 04-01. The hands-on part puts it all into services/catalog/cache.py, with invalidation driven by the stock.updated events from Kafka, and measures the difference in latency with and without the cache during "Artisan Cheese Week".
Contents
- Why cache: latency and load on the database
- Where to cache: client, CDN, application and distributed cache
- Caching patterns
- Invalidation and TTL: the two hard problems
- Classic problems: stampede, hot keys, penetration
- Consistency between cache and database
- Memcached versus Redis
- Redis Cluster: slots, replicas and failover
- Hands-on:
services/catalog/cache.pyand Redis Cluster - Common Mistakes and Tips
- Exercises
- Conclusion
- Why cache: latency and load on the database
Caching addresses two different problems that tend to show up together: latency (the request takes too long) and load (the origin cannot cope with that many requests). A few numbers to get a feel for the difference:
| Source of the data | Typical latency | Requests/s per instance |
|---|---|---|
| The process's local memory (a Python dictionary) | 100 ns | Millions |
| Redis / Memcached on the same network | 0.2–1 ms | 100,000–1,000,000 |
| PostgreSQL, simple indexed query | 1–5 ms | 10,000–50,000 |
PostgreSQL, query with joins and jsonb |
5–50 ms | 500–5,000 |
| gRPC call to another service (which in turn queries its own database) | 5–20 ms | Depends on the service |
Kilometre Zero's sums for "Artisan Cheese Week": the website serves 6 million product views a day, concentrated in 12 hours, with a peak of 3 times the average: about 420 views/s at peak. Each view builds the product page with one catalogue query (product + producer + photos + variants, about 8 ms in PostgreSQL) and one gRPC call to inventory for the stock per market (another 4 ms). Without a cache: 420 queries/s of 8 ms each on PostgreSQL (taking up 3.4 s of CPU for every second: three or four cores just for the catalogue, and growing with the campaign) and a minimum latency of 12 ms per page. But the catalogue changes about 300 times a day (prices, descriptions, photos), that is, one write for every 20,000 reads. With a cache serving 98% of the views, PostgreSQL receives 8 queries/s instead of 420, and the page is built in under 1 ms. The read/write ratio is the first indicator that a piece of data is cacheable; the second is how much staleness it can tolerate, and the catalogue tolerates seconds.
- Where to cache: client, CDN, application and distributed cache
A request passes through several layers, and each of them can cache:
flowchart LR
N[Anna's browser<br/>HTTP cache] --> CDN[CDN<br/>nearby edge]
CDN --> W[Web / API]
W --> L[Local cache<br/>in-process]
L --> R[(Redis<br/>distributed cache)]
R --> DB[(PostgreSQL<br/>km0_catalog)]
W --> I[inventory gRPC]
| Layer | What it caches | Scope | Advantage | Limit |
|---|---|---|---|---|
| Client (browser, app) | HTTP responses with Cache-Control, ETag (04-03) |
One user | Zero latency, no network | Only serves that user; invalidation impossible (expiry only) |
| CDN (08-04) | Static content and responses cacheable by URL | All users near an edge | Absorbs the egress (04-03) and the image traffic | Only for responses that are identical for everyone; invalidation by URL, with a delay |
| Local application cache | Objects in the process's memory (functools.lru_cache, cachetools) |
One instance | Nanoseconds; no dependencies | Each instance has its own copy (10 instances = 10 cold, out-of-sync copies); lost on restart; eats into the process's memory |
| Distributed cache (Redis, Memcached) | Serialized objects by key | All instances of the service | A single shared copy; survives application restarts; tens of GB of capacity | One network hop (~0.5 ms); another system to operate |
In practice they are combined: a small local cache with a very short TTL (1–5 s) for the hottest keys, in front of Redis for everything, in front of the database. This lesson focuses on the distributed cache, which is the one that solves the problem of load on the database in a way that is shared by all the instances of catalog.
- Caching patterns
How the application, the cache and the database relate to each other defines the pattern:
| Pattern | Read | Write | Who talks to the DB | Advantages | Drawbacks | Typical use |
|---|---|---|---|---|---|---|
| Cache-aside (lazy loading) | The application checks the cache; on a miss, it reads the DB and writes to the cache | The application writes to the DB and invalidates (deletes) the key | The application | Simple; only what is requested gets cached; the cache can go down without losing data | Every miss costs two round trips; the first read is slow; the application contains the logic | The most common: catalogue, profiles |
| Read-through | The application asks the cache; the cache reads the DB if it does not have the value | Same as cache-aside | The cache (or a library) | The application knows nothing about the DB; centralised logic | Needs a programmable cache (or a layer); same miss cost | Caffeine-style libraries, ORM caches |
| Write-through | As read-through | The application writes to the cache and the cache writes synchronously to the DB | The cache | The cache is always up to date; reads are always warm after a write | Slower writes (two systems); data gets cached that perhaps nobody reads | Data that is read right after being written |
| Write-behind (write-back) | As read-through | The application writes to the cache; the cache writes to the DB later, in batches | The cache | Extremely fast writes; groups writes together | If the cache dies, pending writes are lost; the DB lags behind | View counters, metrics |
| Refresh-ahead | The cache recomputes hot keys before they expire | Any | The cache | No misses on hot keys; stable latency | You have to predict what will be requested; extra load on the DB | Home pages, rankings, most-viewed product pages |
sequenceDiagram
participant A as catalog (app)
participant R as Redis
participant DB as PostgreSQL
Note over A,DB: Cache-aside: read with a miss
A->>R: GET product:aged-cheese
R-->>A: (nil)
A->>DB: SELECT … WHERE slug = 'aged-cheese'
DB-->>A: row
A->>R: SET product:aged-cheese <json> EX 300
Note over A,DB: Read with a hit
A->>R: GET product:aged-cheese
R-->>A: <json>
Note over A,DB: Write
A->>DB: UPDATE product SET price = 15.20 WHERE slug = 'aged-cheese'
A->>R: DEL product:aged-cheese
Kilometre Zero uses cache-aside for the catalogue, with the event-driven invalidation variant of section 6, and refresh-ahead for the 200 most visited product pages during campaigns. Stock, which changes with every reservation, is not cached in the same way: the page shows "available / only a few left / sold out" derived from a value with a 10 s TTL, and the actual reservation always goes to inventory; it is an example of how staleness tolerance is decided per piece of data, not per service.
- Invalidation and TTL: the two hard problems
"There are only two hard things in computer science: cache invalidation and naming things" (Phil Karlton). The line is a joke because it is true: deciding when a copy stops being valid requires knowing when the original changed, and in a distributed system nobody has that information completely and in time. There are two mechanisms, and they are used together:
- TTL (time to live): each key expires after N seconds. It is simple, robust (the cache cleans itself up even if invalidation fails), and it bounds the maximum staleness. Its limit: it is a blind compromise. A 5-minute TTL on the price of
aged-cheesemeans a price rise may take 5 minutes to show up; a 5-second one means 12 misses per minute per key even if the price does not change for a month. - Explicit invalidation: when the data changes, somebody deletes (or updates) the key. It is precise, but it requires every write path to perform it (the producer dashboard, the bulk importer, the fix-up script an administrator runs by hand…) and the delete to get through (if Redis is unreachable at that instant, the old key survives). With several caches (local + Redis + CDN), you have to invalidate in all of them.
The practical combination: explicit invalidation as the main mechanism and TTL as the safety net, with a TTL long enough for misses to be rare and short enough that an invalidation failure does not last for hours. For Kilometre Zero's catalogue: a 10-minute TTL and event-driven invalidation.
A rule that follows from this: invalidating (deleting) is safer than updating the cache on a write. If two concurrent writes update the cache in a different order from the database, the cache is left with the old value until the TTL; if both delete, the next read reloads the correct value. We will look at this in detail in section 6.
- Classic problems: stampede, hot keys, penetration
Cache stampede / thundering herd. The key product:aged-cheese expires at 12:00:00 in the middle of a campaign. In the next 50 ms, 40 requests discover the miss at the same time, all 40 fire the same 8 ms query at PostgreSQL, and all 40 write the same value to Redis. With thousands of keys expiring in the same window (because they were all loaded together on deployment), the database is hit by an avalanche that can bring it down, which makes queries take longer, which piles up more requests on a miss. Solutions, which can be combined:
| Solution | How | Cost |
|---|---|---|
| Per-key lock (mutex) | The first to detect the miss acquires a lock in Redis (SET lock:product:aged-cheese <id> NX EX 5); it recomputes and publishes; the others wait (or serve the expired value if there is one) |
Those waiting add latency; the lock must expire in case its owner dies |
| Request coalescing | Within a process, concurrent requests for the same key are grouped and only one goes to the origin (single-flight) | Only protects within one instance; with 10 instances, 10 queries instead of 400 |
| TTL with jitter | TTL = base ± random (300 s ± 30 s) so that keys do not expire en bloc | None |
| Early recompute (probabilistic) | When reading a key that is close to expiring, it is recomputed before it expires with an increasing probability (XFetch) | The occasional unnecessary early reload |
| Serving the expired value while reloading (stale-while-revalidate) | The value is stored with a logical TTL shorter than the physical one; if the logical one has expired, the old value is returned and a task refreshes it | Controlled staleness |
Hot keys. aged-cheese during Artisan Cheese Week gets 100 times more reads than the average product. In a partitioned distributed cache (section 8), that key lives on one node, which becomes the bottleneck while the others sit idle. Solutions: a local cache in each instance with a short TTL for the hot keys (it absorbs 99% before anything reaches Redis), read replicas of the node that holds it, or sharding the key (product:aged-cheese:0 … :9 with the same content, picked at random on reads, all invalidated on writes) to spread it over 10 nodes.
Cache penetration. A bot (or a broken link) asks for product:aged-chese, which does not exist. Cache-aside looks in Redis (miss), queries PostgreSQL (no row) and writes nothing to the cache because there is no value: every request for a non-existent key reaches the database. Under an attack with a million made-up slugs, the cache is useless. Solutions: cache the absence (SET product:aged-chese "" EX 60, a sentinel value with a short TTL) and, for enormous key sets, a Bloom filter in front: a compact probabilistic structure that answers "definitely does not exist" or "may exist" with no false negatives, so that keys that definitely do not exist are rejected without touching either the cache or the database (Redis offers it through the RedisBloom module; Cassandra uses it internally for SSTables, as we saw in 04-04).
- Consistency between cache and database
The cache is a replica of the database (03-04) with no replication protocol whatsoever: the application maintains it, by hand, and so the anomalies are the code's responsibility. The two questions are what to do on a write (update or invalidate) and in what order.
Consider updating the cache on a write, with two concurrent price writes (A: 15.20; B: 15.90):
A: UPDATE price = 15.20 B: UPDATE price = 15.90 (DB = 15.90, B won) B: SET cache = 15.90 A: SET cache = 15.20 (cache = 15.20: INCONSISTENT until the TTL)
With invalidation (deleting), the same interleaving leaves the key deleted and the next read loads 15.90: correct. That leaves the order between writing to the DB and invalidating:
| Order | Possible anomaly | Likelihood |
|---|---|---|
| Invalidate, then write to the DB | In between, a read misses, loads the old value from the DB and caches it; then the write arrives: stale cache until the TTL | High (the window is the whole write) |
| Write to the DB, then invalidate | A read misses before the write, the write and the invalidation happen, and afterwards the (slow) read writes the old value to the cache | Low (the DB read would have to be slower than a complete write), but possible |
| Write, invalidate, and invalidate again after a delay (double delete) | Covers the previous case by deleting again once the duration of a read has elapsed | Very low; extra complexity |
The usual answer is "write to the DB, then invalidate", with the TTL as a safety net and, if the data is sensitive, a double delete. And there is a structurally better solution, which picks up from 02-05: invalidate from the change events. inventory already publishes stock.updated to orders.events through the outbox table, in the same transaction as the change, and that event is guaranteed to arrive (the relay sees to that) and arrives after the commit (by construction). A catalog consumer deletes the key on receiving it. With that:
- Invalidation does not depend on every write path remembering to delete: any change that goes through the outbox invalidates.
- If Redis is down when the event arrives, the consumer does not commit the offset and retries: the invalidation is durable.
- The order is always "commit, then invalidate".
- The price is the pipeline delay (tens of milliseconds, sometimes seconds), during which the cache serves the old value: eventual consistency with a bounded window, acceptable for the catalogue and for the stock indicator, not for the reservation (which never reads from the cache).
sequenceDiagram
participant Inv as inventory
participant PG as PostgreSQL km0_inventory<br/>(stock + outbox)
participant Rel as outbox relay
participant K as Kafka orders.events
participant Con as catalog (consumer)
participant R as Redis
participant Web as Web
Inv->>PG: BEGIN; UPDATE stock…; INSERT outbox(stock.updated); COMMIT
Rel->>PG: reads outbox
Rel->>K: publishes stock.updated (key = product)
K-->>Con: stock.updated {product: aged-cheese, market: Girona, available: 1}
Con->>R: DEL stock:aged-cheese:Girona
Con->>K: commit offset
Web->>R: GET stock:aged-cheese:Girona
R-->>Web: (nil) → miss → gRPC inventory → SET with TTL 10 s
- Memcached versus Redis
The two most widely used distributed caching systems are alike in the basics (in-memory key-value, networked, sub-millisecond latency) and differ in almost everything else:
| Memcached | Redis | |
|---|---|---|
| Data model | Byte strings | Strings, hashes, lists, sets, sorted sets, streams, HyperLogLog, bitmaps, geospatial, plus JSON and Bloom via modules |
| Threads | Multithreaded: scales vertically with cores | One main thread for commands (multithreaded I/O since version 6); scales horizontally with Cluster |
| Persistence | None: it is a pure cache | Optional: RDB (snapshots) and AOF (command log) |
| Replication | None (the client decides, often with consistent hashing) | Asynchronous primary-replica; Sentinel for failover; Cluster for partitioning |
| Atomic operations | incr, cas |
Every operation is atomic; MULTI/EXEC transactions; Lua scripts and atomic functions |
| Expiry | Per key, with LRU eviction | Per key, with several eviction policies (LRU, LFU, random, by TTL) |
| Pub/sub, queues, locks | No | Yes: pub/sub, streams, SET NX EX for locks |
| Memory | Slab allocator, very efficient for small values | Higher per-key overhead; compact structures for small hashes |
| When to choose it | A pure string cache, maximum simplicity, maximum efficiency per core | Almost whenever you need more than get/set: structures, locks, counters, lightweight queues, optional persistence |
Kilometre Zero chooses Redis: it needs the stampede lock (SET NX EX), hashes for the product pages, sorted sets for the most-viewed products ranking (refresh-ahead), and optionally streams. And it will need Redis Cluster when a single instance is no longer enough.
- Redis Cluster: slots, replicas and failover
A single-instance Redis serves hundreds of thousands of operations per second and tens of GB; to go beyond that, or to tolerate a node going down, there is Redis Cluster, which is the partitioning of 04-01 in its fixed number of partitions variant:
- The key space is divided into 16,384 slots. A key is assigned with
slot = CRC16(key) mod 16384. There is no ring and there are no vnodes: there are 16,384 fixed partitions shared out among the masters (with 3 masters: 0–5460, 5461–10922, 10923–16383). - Each master owns a range of slots and has one or more replicas (asynchronous replication, 03-04). The nodes learn about each other by gossip and share the slot map.
- The client is partition-aware (option (c) from 04-01): it downloads the slot map and sends each command to the right master. If the map is out of date, the node replies
-MOVED 10215 172.22.0.4:6379and the client updates and retries; during a slot migration it replies-ASK.redis-pyhandles this withRedisCluster. - Hash tags: only the part between braces is hashed:
{aged-cheese}:pageand{aged-cheese}:stock:Gironafall in the same slot, which allows multi-key operations (MGET, transactions, Lua scripts) on them. Without a hash tag, anMGETof keys in different slots fails withCROSSSLOT. - Failover: if a master stops responding for
cluster-node-timeout(15 s by default), the majority of masters declare itFAILand one of its replicas is promoted (an election with an epoch, along the lines of Raft in 03-03). Writes not yet replicated are lost: Redis Cluster is AP with windows of loss, suitable for a cache, not for data that cannot be rebuilt. - Rebalancing:
redis-cli --cluster rebalanceorreshardmove whole slots between masters, key by key withMIGRATE, while the cluster keeps serving: the fixed number of partitions of 04-01 in action.
As an alternative to Cluster when there is no need to partition, Redis Sentinel watches over a primary with replicas and performs automatic failover without splitting up the data.
- Hands-on:
services/catalog/cache.py and Redis Cluster
services/catalog/cache.py and Redis ClusterA plain Redis in docker-compose.yml for the first part:
# km0/docker-compose.yml (excerpt)
services:
redis:
image: redis:7.4
command: ["redis-server", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru", "--appendonly", "no"]
ports: ["6379:6379"]allkeys-lru means that, once the 512 MB fill up, Redis evicts the least recently used keys: cache behaviour. appendonly no because a cache does not need persistence.
The cache module (pip install redis). It implements cache-aside with TTL and jitter, a lock against stampedes, caching of absences, and a stock.updated consumer that invalidates:
# km0/services/catalog/cache.py
"""Cache-aside for the catalogue with Redis: TTL with jitter, anti-stampede lock, event-driven invalidation."""
import json
import random
import time
import uuid
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
TTL_PRODUCT = 600 # 10 min: safety net; the real invalidation arrives through events
TTL_STOCK = 10 # the stock indicator tolerates 10 s of lag
TTL_ABSENT = 60 # cache "does not exist" to counter penetration
JITTER = 0.1 # ±10%
SENTINEL = "__NOT_FOUND__"
def _ttl_with_jitter(base: int) -> int:
return int(base * random.uniform(1 - JITTER, 1 + JITTER))
# --- anti-stampede lock ---------------------------------------------------------------------
def _acquire_lock(key: str, ttl_ms: int = 3000) -> str | None:
token = str(uuid.uuid4())
return token if r.set(f"lock:{key}", token, nx=True, px=ttl_ms) else None
_RELEASE = r.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0
""") # only the owner releases (atomic compare-and-delete)
def _release_lock(key: str, token: str) -> None:
_RELEASE(keys=[f"lock:{key}"], args=[token])
# --- generic cache-aside ----------------------------------------------------------------------
def get_or_load(key: str, load, ttl: int, max_wait: float = 2.0):
"""Returns the cached value or loads it with `load()` under the protection of a lock.
`load` returns None if the data does not exist (the absence is cached)."""
value = r.get(key)
if value is not None:
return None if value == SENTINEL else json.loads(value)
start = time.monotonic()
while True:
token = _acquire_lock(key)
if token:
try:
value = r.get(key) # did someone load it while we waited for the lock?
if value is not None:
return None if value == SENTINEL else json.loads(value)
data = load()
if data is None:
r.set(key, SENTINEL, ex=TTL_ABSENT)
else:
r.set(key, json.dumps(data), ex=_ttl_with_jitter(ttl))
return data
finally:
_release_lock(key, token)
# another process is loading: wait a little and retry the read
time.sleep(0.02)
value = r.get(key)
if value is not None:
return None if value == SENTINEL else json.loads(value)
if time.monotonic() - start > max_wait:
return load() # degradation: go to the origin without caching
# --- catalogue functions ----------------------------------------------------------------------
def product(slug: str, load_from_db):
return get_or_load(f"product:{slug}", lambda: load_from_db(slug), TTL_PRODUCT)
def stock(slug: str, market: str, query_inventory):
return get_or_load(f"stock:{slug}:{market}", lambda: query_inventory(slug, market), TTL_STOCK)
def invalidate_product(slug: str) -> None:
r.delete(f"product:{slug}")
def invalidate_stock(slug: str, market: str | None = None) -> None:
if market:
r.delete(f"stock:{slug}:{market}")
else:
keys = list(r.scan_iter(f"stock:{slug}:*"))
if keys:
r.delete(*keys)
# --- stock.updated consumer (event-driven invalidation, 02-05) ---------------------------------
def consume_stock_updated():
from kafka import KafkaConsumer # pip install kafka-python
consumer = KafkaConsumer("orders.events", group_id="catalog-cache",
bootstrap_servers="localhost:9092",
enable_auto_commit=False,
value_deserializer=lambda b: json.loads(b.decode()))
for msg in consumer:
ev = msg.value
if ev["type"] == "stock.updated":
d = ev["data"]
invalidate_stock(d["product"], d.get("market"))
print(f"invalidated stock:{d['product']}:{d.get('market', '*')} because of {ev['event_id']}")
elif ev["type"] == "product.updated":
invalidate_product(ev["data"]["slug"])
consumer.commit() # only after invalidating: if Redis fails, it is retriedThe important points in each part:
get_or_loadis cache-aside with a per-key lock:SET lock:<key> <token> NX PX 3000succeeds only for the first process; the others wait 20 ms and re-read. The lock expires after 3 s in case its owner dies._RELEASEis a Lua script so that "if the token is mine, delete" is atomic: without it, a slow process could delete someone else's lock. (This is a cache lock: if it fails, the cost is a duplicate query; a correctness lock, like the relay's in 03-03, is done with etcd.)- The double check after acquiring the lock stops the second arrival from reloading what the first has just written.
- If the wait exceeds
max_wait, it degrades to querying the origin directly without caching: better one extra query than a hung request. _ttl_with_jitterdesynchronises the expiries;SENTINELcaches absences to counter penetration.- The consumer reuses the
orders.eventstopic and theevent_id/type/version/timestamp_ms/source/dataenvelope from 02-05.enable_auto_commit=Falseand thecommit()after invalidating make the invalidation at-least-once: deleting twice is harmless (idempotent), whereas not deleting is the error we want to avoid. Note that the Kafka partition keys are the order id, so thestock.updatedevents for a given product may arrive through different partitions and in a different order; since we invalidate (rather than update), the order does not matter: this is the underlying reason for the "delete, don't update" rule.
Measuring latency (simulations/cache_latency.py): we simulate the aged-cheese product page with a "database" that takes 8 ms and compare:
# km0/simulations/cache_latency.py
import statistics, time
from services.catalog import cache
def load_from_db(slug):
time.sleep(0.008) # PostgreSQL: product page query, 8 ms
return {"slug": slug, "name": "Aged sheep's cheese", "producer": "Montblanc Dairy",
"price": 14.50, "photos": ["photos/aged-cheese/thumb-800.jpg"]}
def measure(f, n=2000):
times = []
for _ in range(n):
t0 = time.perf_counter(); f(); times.append((time.perf_counter() - t0) * 1000)
times.sort()
return statistics.mean(times), times[len(times) // 2], times[int(n * 0.99)]
cache.invalidate_product("aged-cheese")
print("no cache mean/p50/p99 (ms): %.2f / %.2f / %.2f" % measure(lambda: load_from_db("aged-cheese")))
print("with cache mean/p50/p99 (ms): %.2f / %.2f / %.2f" % measure(lambda: cache.product("aged-cheese", load_from_db)))Forty times less latency and (what matters to PostgreSQL) one query instead of 2,000. Afterwards, run python -c "from services.catalog.cache import *; [invalidate_product('aged-cheese') for _ in range(1)]" from another terminal while the measurement is running with 20 threads, and in redis-cli's MONITOR you will see a single SET product:aged-cheese after each DEL, with the other 19 threads reading the freshly loaded value: the lock at work.
Redis Cluster with three masters and three replicas, for the second part. Six nodes with cluster-enabled yes and a container that joins them together:
# km0/docker-compose.yml (excerpt)
x-redis-cluster: &redis-cluster
image: redis:7.4
command: ["redis-server", "--cluster-enabled", "yes", "--cluster-node-timeout", "5000",
"--appendonly", "no", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
services:
redis-1: { <<: *redis-cluster, hostname: redis-1 }
redis-2: { <<: *redis-cluster, hostname: redis-2 }
redis-3: { <<: *redis-cluster, hostname: redis-3 }
redis-4: { <<: *redis-cluster, hostname: redis-4 }
redis-5: { <<: *redis-cluster, hostname: redis-5 }
redis-6: { <<: *redis-cluster, hostname: redis-6 }
redis-cluster-init:
image: redis:7.4
depends_on: [redis-1, redis-2, redis-3, redis-4, redis-5, redis-6]
command: >
sh -c "sleep 5 && redis-cli --cluster create
redis-1:6379 redis-2:6379 redis-3:6379 redis-4:6379 redis-5:6379 redis-6:6379
--cluster-replicas 1 --cluster-yes"--cluster-replicas 1 assigns one replica to each master (the first three are masters, the last three replicas, paired so as to avoid the same host where possible). Checks:
docker compose exec redis-1 redis-cli cluster info | head -6 # cluster_state:ok, cluster_slots_assigned:16384
docker compose exec redis-1 redis-cli cluster nodes # 3 masters with slot ranges, 3 slaves
docker compose exec redis-1 redis-cli cluster keyslot product:aged-cheese # e.g. 10215
docker compose exec redis-1 redis-cli cluster keyslot "{aged-cheese}:page" # same slot as…
docker compose exec redis-1 redis-cli cluster keyslot "{aged-cheese}:stock:Girona" # …this one
docker compose exec redis-1 redis-cli -c set product:aged-cheese '{"price":14.5}' # -c: follows MOVED
docker compose exec redis-1 redis-cli set product:aged-cheese x # without -c: (error) MOVED 10215 172.22.0.4:6379
docker compose exec redis-1 redis-cli mget product:aged-cheese product:pink-tomato # (error) CROSSSLOTCLUSTER KEYSLOT shows the CRC16 mod 16384 of each key and demonstrates that hash tags in braces place related keys in the same slot. The MOVED reply without -c is the partition map talking to the client. And CROSSSLOT is a reminder that multi-key operations, in a partitioned cache, require deliberate co-location.
Failover: docker compose stop redis-1; after 5 s (cluster-node-timeout), cluster nodes shows redis-1 as master,fail and its replica promoted to master with slots 0–5460; writes to those keys keep working. When redis-1 is started again, it joins as a replica of the new master. From Python:
from redis.cluster import RedisCluster
rc = RedisCluster(host="redis-1", port=6379, decode_responses=True) # run from a container on the Compose network
rc.set("product:aged-cheese", "…") # the client routes by slot
print(rc.cluster_keyslot("product:aged-cheese"))redis-py's RedisCluster downloads the slot map, routes each command and follows MOVED/ASK automatically: the cache.py module works unchanged if you replace redis.Redis with RedisCluster, except for scan_iter (which walks through all the masters) and the multi-key operations, which need hash tags. A natural improvement to the module would be to name the keys {aged-cheese}:product and {aged-cheese}:stock:Girona so that everything belonging to a product can be invalidated with a single multi-key DEL.
Common Mistakes and Tips
- Caching without a TTL "because we already invalidate". Invalidation will fail at some point (a forgotten write path, Redis down at just the wrong instant). The TTL is the safety net; always set one.
- Updating the cache on a write instead of invalidating. Two concurrent writes leave the cache with the wrong value until the TTL. Delete; the next read loads the right value.
- Invalidating before writing to the database. A large window for caching the old value. Write first, invalidate afterwards; better still, invalidate from the outbox events.
- All keys with the same TTL, loaded at once after a deployment. They expire together: a stampede. Jitter and, for what is hot, refresh-ahead.
- No caching of absences. A bot with made-up slugs goes straight through the cache and reaches the database on every request. A sentinel with a short TTL, or a Bloom filter.
- A lock with no expiry, or released without checking the owner. A dead process blocks the key for ever; a slow process releases someone else's lock.
NX PXand release through a Lua script. - Confusing the cache lock with a correctness lock. The Redis one with
SET NXcan fail during a failover (asynchronous writes are lost); if not selling the last piece twice depends on it, use etcd (03-03) or the database. - Caching the response of the stock reservation. The "only a few left" indicator may lag 10 s behind; the actual reservation never reads from the cache. Decide the staleness tolerance per piece of data.
- Multi-key operations in Redis Cluster without hash tags.
CROSSSLOT. Design your keys with{tag}from the start; changing them later means flushing the cache. - Treating Redis Cluster as a database. Failover loses unreplicated writes. It is a cache (or a rebuildable store), and
ordersandinventorylive where 04-04 decided.
Exercises
Exercise 1. Implement in cache.py the stale-while-revalidate variant of get_or_load: store in Redis a hash with the fields value and logical_expiry (a timestamp), with a physical TTL equal to twice the logical one. If on reading the logical value has expired, return the old value immediately and kick off the reload (in a thread, protected by the same lock). Explain what the website gains during Artisan Cheese Week compared with the lock-based version of section 9, and what is lost.
Exercise 2. The stock.updated event is published to orders.events with partition key = order id. Two reservations of aged-cheese in Girona (orders P-2026-000125 and P-2026-000126) generate two events in different partitions, which the catalog-cache consumer may process in reverse order. Explain (a) why invalidation by deletion is correct in any order; (b) what would happen if, instead of deleting, the consumer did SET stock:aged-cheese:Girona <available from the event>; (c) how the situation would change if inventory published the stock events with key = product, and what is lost with that change (hint: 02-04 and the relationship with order.created).
Exercise 3. During the campaign, product:aged-cheese receives 15,000 reads/s and its slot lives on redis-2, which saturates (the rest of the masters are at 10%). Propose two combined solutions, write the code for sharding the key into N copies ({aged-cheese}:product:0..N-1) with random reads and invalidation of all of them, and reason out why the hash tag {aged-cheese} means the sharding does not help in Redis Cluster, and how you would name the copies so that it does.
Solutions
Solution 1:
import threading
def get_swr(key: str, load, logical_ttl: int):
h = r.hgetall(key)
now = time.time()
if h:
value = None if h["value"] == SENTINEL else json.loads(h["value"])
if float(h["logical_expiry"]) > now:
return value # fresh
# logically expired: return the old value and reload in the background
threading.Thread(target=_reload_swr, args=(key, load, logical_ttl), daemon=True).start()
return value
return _reload_swr(key, load, logical_ttl) # no value: synchronous load (with lock)
def _reload_swr(key, load, logical_ttl):
token = _acquire_lock(key)
if not token: # another process is reloading; read whatever is there
h = r.hgetall(key)
return json.loads(h["value"]) if h and h["value"] != SENTINEL else None
try:
data = load()
r.hset(key, mapping={"value": SENTINEL if data is None else json.dumps(data),
"logical_expiry": time.time() + _ttl_with_jitter(logical_ttl)})
r.expire(key, logical_ttl * 2) # physical TTL: safety net
return data
finally:
_release_lock(key, token)What the website gains: during the campaign, no request for a hot key waits for the database (not even the one that triggers the reload), so the p99 stays at Redis's sub-millisecond level instead of jumping to 8 ms every 10 minutes; and a stampede is impossible because only one reload runs at a time while the rest serve the old value. What is lost: additional staleness (the old value is served until the reload finishes and, if the origin fails, until the physical TTL), memory (one hash per key) and complexity (background threads, which on an asynchronous server would be done with a task). For the price on a product page it is a good deal; for the stock indicator with its 10 s TTL, debatable.
Solution 2:
(a) DEL is idempotent and carries no value: it makes no difference which of the two events is processed first, the end result is "the key is not there", and the next read reloads it from inventory, which has the correct value (what is available after both reservations). Invalidation turns an ordering problem into an existence problem, which has no order.
(b) With SET <available from the event>, if the event for P-2026-000126 (available = 1) is processed before the one for P-2026-000125 (available = 2), the cache ends up at 2 while the real stock is 1: wrong until the 10 s TTL. You would need to compare timestamp_ms or a version and discard old events (the last-writer-wins pattern of 03-04, with its clock risks from 01-05), which is more code and more failure modes than a DEL.
(c) With key = product, all the stock.updated events for aged-cheese would go to the same partition and arrive in order (02-04), and then SET would be safe. But co-location with the rest of the order's events is lost: the order.created, stock.reserved and payment.confirmed of P-2026-000125 would no longer be in the same partition as their stock.updated, and the consumers that rebuild an order's history in order (the choreographed saga of 03-05, analytics) would lose that guarantee. A clean alternative: a separate topic inventory.stock with key = product for the stock events, which is what a mature design would do; in the meantime, the DEL works with any key.
Solution 3:
Combined solutions: (1) a local cache in each instance of catalog (cachetools.TTLCache(maxsize=500, ttl=2)) for the most-read keys, which absorbs the vast majority of the 15,000 reads/s before they reach Redis, and which is also invalidated by the event consumer (each instance consumes with its own group_id, or a Redis PUBLISH is sent to all of them); (2) sharding the key into N copies to spread it across masters:
N_COPIES = 8
def copy_key(slug, i): return f"product:{slug}:c{i}" # no hash tag: each copy in its own slot
def sharded_product(slug, load_from_db):
i = random.randrange(N_COPIES)
return get_or_load(copy_key(slug, i), lambda: load_from_db(slug), TTL_PRODUCT)
def invalidate_sharded_product(slug):
for i in range(N_COPIES):
r.delete(copy_key(slug, i)) # in Cluster: N DELs, one per slotWith the hash tag {aged-cheese} all the copies would have the same slot (only what is between the braces is hashed) and therefore the same master: the load would be spread across keys, not across nodes, which is exactly what is of no use. Without a hash tag (product:aged-cheese:c0…c7), the CRC16 of each full name falls in different slots and, with high probability, on different masters; you can check this with CLUSTER KEYSLOT and tweak the suffixes until the 8 copies cover all 3 masters. The cost: 8 misses instead of 1 after each invalidation (irrelevant with 300 changes a day) and 8 DELs per invalidation, which can no longer be multi-key (they go to different slots); Redis Cluster's read replicas (READONLY on the client) are the third option, which spreads reads of the same slot between master and replica without changing the keys, at the price of reading with replication lag.
Conclusion
A distributed cache is a replica of convenience maintained by hand by the application, which is why it concentrates in the code all the replication problems that databases solve by protocol. Caching pays off when the read/write ratio is high and the data tolerates some staleness, like Kilometre Zero's catalogue with its 20,000 reads per write: the measurement brought the aged-cheese product page down from 8 ms to 0.2 ms and took 98% of the Artisan Cheese Week queries off PostgreSQL. We have placed the cache in its layers (client, CDN, local, distributed), gone through the patterns (cache-aside as the choice, refresh-ahead for what is hot, write-through and write-behind for other cases), and dealt with invalidation through the rule "TTL as the net, explicit invalidation as the mechanism, delete rather than update, after writing to the database". Against the stampede: a per-key lock with a token and a Lua script, coalescing, jitter and early reloading; against hot keys: a local cache, replicas and key sharding; against penetration: sentinels and Bloom filters. Invalidation through the stock.updated events in orders.events has turned the outbox of 02-05 into the mechanism that keeps inventory and the catalog cache coherent, durable and correct in any order. Redis has beaten Memcached thanks to its structures and its locks, and Redis Cluster has brought the module full circle: 16,384 fixed slots, MOVED as the partition map in the client's hands, hash tags for co-location, and replicas with asynchronous failover that make it suitable as a cache and not as a database.
This lesson closes Module 4, and every piece of Kilometre Zero's data now has its place:
| Data | Where it lives | Lesson | Why |
|---|---|---|---|
| Orders (history, "my orders", sagas) | Cassandra km0_orders, partitioned by customer_id (+ month), RF=3, LOCAL_QUORUM |
04-01, 04-04 | Write-heavy, stable queries, availability |
| Stock per product and market | PostgreSQL km0_inventory (primary + replica), partitioned by product_slug |
03-04, 04-01, 04-04 | CP counter with constraints and local transactions |
| Payments | PostgreSQL | 04-04 | CP, low volume, auditing |
| Catalogue (product pages, producers) | PostgreSQL with jsonb + Redis (cache-aside, 10 min TTL, event-driven invalidation) |
04-04, 04-05 | 20,000 reads per write; tolerates seconds of lag |
| Stock indicator on the product page | Redis, 10 s TTL, invalidated by stock.updated |
04-05 | Explicit tolerance of 10 s; the reservation never reads from here |
Positions of van-3 |
Cassandra, partition courier + day, ONE |
04-01, 04-04 | AP, 2.4 M writes/day, access by courier |
| Indexes by market and producer | Tables materialised from orders.events |
04-01 | Asynchronous global index, no scatter/gather |
| Product photos | MinIO km0-photos, versioned, presigned URLs, CDN in front |
04-03 | Immutable objects served over HTTP; egress |
| PDF invoices and PostgreSQL backups | MinIO km0-invoices, km0-backups, lifecycle |
04-03 | Immutable, retention, cost per GB |
Events from orders.events and click logs |
HDFS /km0/events/<day>/, /km0/clicks/<day>/, one file per day |
04-02 | Data lake: massive, sequential, write-once |
| Partition map and leadership | etcd | 03-03, 04-01 | Consensus for coordination state |
The data is spread over many nodes and we know why each piece is where it is. The next question is how to process it in bulk now that it no longer fits on one machine: how to compute Grape Harvest Week sales by producer and market over the hundreds of millions of events in the lake, how to train recommendations on a year's worth of clicks, how to feed the delivery dashboard in real time. That is the territory of Module 5, distributed computing, which begins with distributed computing models: what it means to spread a computation, and not just a piece of data, across many nodes.
Distributed Architectures Course
Module 1: Introduction to Distributed Systems
- Basic Concepts of Distributed Systems
- Distributed System Models
- Advantages and Challenges of Distributed Systems
- The Fallacies of Distributed Computing
- Time, Clocks and Event Ordering
- From Monolith to Distributed Platform: the Kilometre Zero Case
Module 2: Communication in Distributed Systems
- Communication Protocols
- RPC and RMI
- gRPC and Data Serialization
- Messaging and Message Queues
- Asynchronous Communication Patterns
Module 3: Consistency and Replication
- Consistency Models
- The CAP Theorem and PACELC
- Consensus Algorithms
- Data Replication
- Distributed Transactions and Sagas
Module 4: Distributed Storage
- Data Partitioning and Consistent Hashing
- Distributed File Systems
- Object Storage
- Distributed Databases
- Distributed Caches
Module 5: Distributed Computing
- Distributed Computing Models
- MapReduce and Hadoop
- Spark and In-Memory Computing
- Stream Processing
- Job Scheduling and Data Pipelines
Module 6: Security in Distributed Systems
- Authentication and Authorization
- Encryption and Data Protection
- Identity Management
- Service-to-Service Security: mTLS and Secrets Management
- API Gateways, Rate Limiting and Auditing
Module 7: Monitoring and Maintenance
- Monitoring Distributed Systems
- Centralized Logs and Distributed Tracing
- Failure Management and Recovery
- Resilience Patterns: Timeouts, Retries and Circuit Breakers
- Automation and Orchestration
- Testing Distributed Systems and Chaos Engineering
