Imagine that you have just changed a product’s price. One request is still reading the old price from the database; another has already deleted the old cache entry. Which request writes the cache last? Now imagine thousands of visitors opening the same page just as its cache key expires. How many database queries will you get?

Those are the questions I find more interesting than another SET/GET tutorial. This article starts with a small product catalog and follows three problems: stale cache fills, a database overwhelmed by cache misses, and rate limits that behave differently from what their configuration suggests. It ends with eight failures worth simulating before Redis sits on an important request path.

The examples use Redis 7.x, Python 3.10+, and redis-py. Numbers in the simulations explain the mechanism; they are not measurements from a real service. If Redis itself is new to you, read the fundamentals first. The persistence guide covers RDB and AOF in depth.

1. Start with a Product Page

Suppose a product page reads its name and price from PostgreSQL. Many people view the page, but prices change only occasionally. Asking PostgreSQL the same question for every view wastes work. With cache-aside, the application asks Redis first:

flowchart LR
    U[User] --> API[Application API]
    API -->|GET product:42| R[(Redis)]
    R -->|cache hit| API
    R -.->|cache miss| API
    API -->|SELECT| DB[(PostgreSQL: source of truth)]
    DB --> API
    API -->|SET with TTL| R

On a miss, the application reads PostgreSQL and stores a temporary copy in Redis. When a price changes, it commits the new price to PostgreSQL and then deletes the cache key. Only products that people actually view enter the cache. Redis cache-aside guidance.

Three different reasons a key disappears

Mechanism Who triggers it? What it means for product:42
TTL expiration Time passes Redis treats the cached value as expired after, say, 60 seconds.
Invalidation The application notices a change The price changes, so the application calls DEL rather than waiting for TTL.
Eviction Redis needs memory Redis may remove the key before its TTL ends.

A 60-second TTL sets an upper bound on the life of that cache entry if nobody overwrites or removes it first. It does not promise that the price stays accurate for 60 seconds. Redis keeps expiration against real time, so time continues while the server is down. Eviction can remove a key even sooner when RAM is tight. Key expiration, key eviction.

1
2
3
GET product:42                    # read cache
SET product:42 '{"name":"Blue shirt"}' EX 60
TTL product:42                    # seconds left

In this example, PostgreSQL owns the official price and Redis makes common reads cheaper. If a cache key disappears, the app can fetch the price again. Ask the same question for every other Redis key in your system: where would we rebuild this value from?

Redis also offers different data types for different operations. A small cached JSON document may be a String; fields that change independently may fit a Hash; unique IDs fit a Set; ranks fit a Sorted Set; work requiring consumer acknowledgment may fit a Stream. The type helps with the operation, but it does not erase limits on RAM, durability, or traffic. Redis data types.

2. How an Old Read Can Put an Old Price Back in Cache

The usual read and write flow

On the first read, GET finds no key. The application reads PostgreSQL and uses SET ... EX 60. Later reads use Redis. For an update, commit PostgreSQL first and then DEL the cache. If you delete the cache before the database commit, another request may miss, read the old database value, and fill the cache with it again. Unfortunately, committing first still leaves another race.

There are two smaller details worth fixing before we get to that race:

  • GET returns nil both when a product has not been cached and when that product does not exist. Repeated requests for nonexistent IDs can repeatedly hit the database. A short-lived marker such as __NOT_FOUND__ avoids that; delete it if the product is later created. Do not use an empty string as the marker if empty is a valid value. Redis missing-record guidance.
  • A cache key must include every input that changes the answer. If price varies by tenant or currency, product:42 is too broad. catalog:tenant:7:product:42:currency:VND:v1 is safer, though more variants cost more RAM. The v1 suffix makes it easier to change the cached format later. Redis cache-aside guidance.
1
2
3
GET catalog:product:404              # nil
SELECT ... WHERE id = 404            # no row
SET catalog:product:404 __NOT_FOUND__ EX 10

A useful race described by Meta

In “Cache made consistent”, Meta’s engineers describe a slow cache fill: a read obtains an old value, an update happens, and then that delayed read writes the old value into cache. Their article is about TAO/Memcache, not a Redis incident. The ordering problem also applies to this Redis cache-aside design.

Say the old price is 100 and the new price is 120:

Time Request A: read Request B: update
t1 Redis miss; start database read
t2 Read 100, then pause before SET
t3 Commit 120 to PostgreSQL
t4 DEL product:42
t5 SET product:42 = 100 EX 60

PostgreSQL now says 120, while Redis says 100. The DEL was executed, yet the late reader put the old price back. TTL limits the mistake only if the new cache write actually includes a TTL. If request A forgets EX, the wrong price can remain much longer. A lagging PostgreSQL read replica can introduce a related problem: it may return 100 after invalidation has finished.

Let a version number reject late fills

Add product:{42}:version to Redis. Before reading PostgreSQL, request A remembers the current version. After the read, A fills the cache only if that version has not changed. When B commits the new price, it increments the version and deletes the cache within one Redis Lua script. Other Redis commands cannot run between the two script operations. Redis Lua scripting.

sequenceDiagram
    participant A as Reader A
    participant R as Redis
    participant D as PostgreSQL
    participant B as Writer B
    A->>R: GET cache (miss), GET version (= 0)
    A->>D: SELECT price (= 100)
    B->>D: UPDATE price = 120, COMMIT
    B->>R: script increments version and deletes cache
    A->>R: script: fill only if version is still 0
    R-->>A: Reject: version is now 1
    A->>D: Read the new price (= 120)

This blocks one specific race: A cannot fill Redis with a value it fetched before B changed the version. It does not make PostgreSQL and Redis one transaction. Another request can still see the old cache between the database commit and Redis invalidation. A lagging database replica can also return old data under the new version. At checkout, where the latest price may be mandatory, read the authoritative source or use a stronger application-level consistency design.

Python lab: force the bad ordering

We can make the race visible by pausing A just after it reads 100, letting B change the price, and then resuming A. You need Redis and Python. With Docker, run docker run --rm -p 127.0.0.1:6379:6379 redis:7.4 in one terminal. In another, run python -m pip install redis, save the code below as demo_cache.py, and run python demo_cache.py. SQLite stands in for PostgreSQL. The script deletes two exercise keys under demo:product:{42} and writes demo_cache.sqlite3 in the current directory; use a Redis instance dedicated to the lab.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import json
import sqlite3
import threading

import redis

DB = "demo_cache.sqlite3"
CACHE = "demo:product:{42}:value"
VERSION = "demo:product:{42}:version"
r = redis.Redis(host="localhost", port=6379, decode_responses=True)

FILL_IF_CURRENT = """
local current = redis.call('GET', KEYS[2]) or '0'
if current ~= ARGV[1] then return 0 end
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
return 1
"""

INVALIDATE = """
redis.call('INCR', KEYS[2])
redis.call('DEL', KEYS[1])
return 1
"""

def db_read():
    conn = sqlite3.connect(DB)
    try:
        return conn.execute("SELECT price FROM product WHERE id = 42").fetchone()[0]
    finally:
        conn.close()

def read_product(paused=None, resume=None):
    while True:
        cached = r.get(CACHE)
        if cached is not None:
            return json.loads(cached)

        version = r.get(VERSION) or "0"
        price = db_read()
        if paused is not None:  # force the race ordering in this lab
            paused.set()
            if not resume.wait(timeout=5):
                raise TimeoutError("writer did not finish")
            paused = None

        stored = r.eval(
            FILL_IF_CURRENT, 2, CACHE, VERSION,
            version, json.dumps({"price": price}), 60
        )
        if stored == 1:
            return {"price": price}
        print("Version changed while reading; retrying")

def update_price(price):
    conn = sqlite3.connect(DB)
    try:
        conn.execute("UPDATE product SET price = ? WHERE id = 42", (price,))
        conn.commit()
    finally:
        conn.close()
    r.eval(INVALIDATE, 2, CACHE, VERSION)

if __name__ == "__main__":
    conn = sqlite3.connect(DB)
    try:
        conn.execute("CREATE TABLE IF NOT EXISTS product (id INTEGER PRIMARY KEY, price INTEGER)")
        conn.execute("INSERT OR REPLACE INTO product (id, price) VALUES (42, 100)")
        conn.commit()
    finally:
        conn.close()
    r.delete(CACHE, VERSION)

    paused, resume = threading.Event(), threading.Event()
    reader = threading.Thread(
        target=lambda: print("Reader received:", read_product(paused, resume))
    )
    reader.start()
    if not paused.wait(timeout=5):
        raise TimeoutError("reader did not reach the pause point")
    update_price(120)
    resume.set()
    reader.join()
    print("Final cache:", r.get(CACHE))

You should see Version changed while reading; retrying, followed by {'price': 120}. Both Redis keys contain {42} so they share a hash slot if you run the example on Redis Cluster; a multi-key script requires its keys to be in the same slot. Redis Cluster hash tags.

Read the code in three steps:

  1. read_product() obtains the version before SQLite returns a price. If the version changes while it reads, FILL_IF_CURRENT returns 0, and the reader starts again.
  2. update_price() commits SQLite and calls INVALIDATE. INCR version and DEL cache run together inside Redis, with no other Redis command between them.
  3. Two threading.Event objects pause A after it reads 100. B changes the price before A resumes. Without those deliberate pauses, the race depends on timing and can be hard to observe.

For a useful experiment, replace FILL_IF_CURRENT with redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3]); return 1, then rerun the script. The reader can return 100 even though SQLite holds 120. Restore the version check and compare the results.

Where the version approach ends

There is still a gap between committing SQLite and invalidating Redis. If the application stops in that gap, stale cache may survive until its TTL ends. A Redis outage can also make invalidation fail. For data that needs stricter freshness, an outbox or CDC pipeline can record the database change and retry invalidation. Critical paths may read directly from the authoritative database.

The version key itself matters. If Redis evicts it while an old reader is working, the version may appear to return to 0, and that reader may incorrectly fill the cache. An allkeys-lru cache instance is not automatically a safe place for a long-lived correctness token. A stricter design can obtain the version from the database or a durable change log and use Redis only as a fast copy. That adds I/O and complexity, and it should be tested with the actual Cluster and failover setup.

The product question is simpler to state: may the catalog show an old price for one second, one minute, or not at all at checkout? The same product can use cache on a listing page and an authoritative read at order confirmation.

3. The Cache Is Correct, but the Database Still Falls Over

A high hit rate can hide a dangerous peak

Imagine a popular product’s cache key expiring just as 2,000 requests arrive. All 2,000 miss Redis and run the same SELECT on PostgreSQL. One database lookup has become 2,000 lookups. Several keys with similar TTLs can expire together and produce the same effect.

1
2
Before expiration: 2,000 requests -> cache hit  -> almost no database reads
After expiration:  2,000 requests -> cache miss -> up to ~2,000 database reads

The daily cache hit rate could still be 99%. The remaining 1% might cluster into a few seconds after a deployment, a failover, or the expiration of a very popular key. The database has to survive the short peak, not the daily average. Slow queries make requests wait longer; more requests accumulate, time out, and may retry, extending the peak.

Four ways to reduce that peak

Choose based on how stale the data may be:

  1. Add jitter to TTLs. Let keys live 60–75 seconds rather than exactly 60. This spreads expirations across many keys. It does not solve 2,000 requests racing on one key.
  2. Coalesce identical requests. Within one application process, let one request load a missing key while others wait for its result. This is often called single-flight. With 20 application instances, you may still get 20 database queries. A short Redis lock such as SET lock:key token NX PX 2000 can coordinate instances; callers that miss the lock can wait briefly and retry the cache, or receive stale data if the product permits. Redis distributed locks.
  3. Serve stale data while refreshing. One worker refreshes the value in the background while readers temporarily receive the old version. This is stale-while-revalidate. It helps with load but may be wrong for account balances or final checkout prices.
  4. Protect the database directly. Cap concurrent cache-miss queries, set timeouts, and choose a controlled degraded response. A lock can expire before a slow database query finishes, so it is not proof that only one worker queried the database.
Situation First idea to try What to measure
Many keys expire together TTL jitter Expirations and misses per second
One very popular key Single-flight, then perhaps a lock across instances Database queries when that key expires
Data may be a few seconds old Serve stale while refreshing Age of data actually returned to users
Database is near its limit Concurrency cap and timeouts Active connections and database p99

A lock must belong to its owner

Worker A acquires a lock, but its database read takes longer than the lock’s TTL. The lock expires, and B acquires a new one. If A then calls DEL lock:key, it deletes B’s lock. Give each owner a random token and remove the lock only when the token still matches. The compare-and-delete step must run as one Lua script:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import secrets

token = secrets.token_hex(16)
acquired = r.set("demo:lock:product:42", token, nx=True, px=2000)

RELEASE_IF_OWNER = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0
"""

if acquired:
    try:
        # Only this worker loads the cache; see the version check in section 2.
        pass
    finally:
        r.eval(RELEASE_IF_OWNER, 1, "demo:lock:product:42", token)

This snippet shows safe release, not a complete cache implementation. A worker that fails to acquire the lock still needs a policy: wait briefly and retry GET, return an allowed stale value, or return an error the caller can retry. Do not wait forever. Choose a lock TTL based on the slower end of database read times, with margin. A process pause or network delay can still outlast that TTL. The lock reduces duplicate cache fills; by itself it does not guarantee an external task runs exactly once.

Four cases worth testing

  • A popular key is manually deleted. This can produce the same database surge as expiration. Test it under simulated traffic, not only while the service is idle.
  • Clients repeatedly request nonexistent IDs. Without a short “not found” cache entry, every request hits the database.
  • One value grows huge. A large JSON reply costs network time even when every lookup is a hit. Measure MEMORY USAGE key and response size. Redis MEMORY USAGE.
  • Errors cause synchronized retries. If Redis slows down and every application retries immediately while PostgreSQL is also slow, the system amplifies its own load. Cap retries and add backoff.

Measure misses for individual popular keys and the resulting database queries. A single daily hit-rate number will not reveal the peak.

4. Rate Limits: How Redis Can Protect an API

When one customer calls your API too often, a per-second limit sounds like the obvious fix. But what if each call runs for 30 seconds? Even a modest arrival rate can occupy every worker.

In “Scaling your API with rate limiters”, Stripe describes four layers: requests over time, requests currently in flight, load across the fleet, and load on a worker group. They used Redis for a token-bucket rate limiter. Before enforcing it, they measured which requests would have been denied in a monitoring-only mode. They also planned a quick way to disable it and decided what to do when Redis failed.

Layer Stripe describes What it controls Example
Request rate Too many calls in a period A broken client loops on the API.
Concurrent requests Too much work in flight for one customer Twenty reports each take 30 seconds.
Fleet load Resource pressure across the service Defer reports to preserve payments.
Worker load A worker group is already busy Reject less important work before the queue grows.

Requests arriving per second and requests still running are different numbers. One token bucket cannot solve every overload problem.

Choose the shape of the limit

Algorithm How to picture it Useful property Trap
Fixed window Count calls in each fixed interval Simple and compact Calls near the end of one interval and start of the next can form a burst.
Sliding window Count calls in a window that moves with time Smoother limit More timestamps or an approximation are needed.
Token bucket Tokens refill a bucket with fixed capacity Allows a short burst while controlling long-term rate Updates must be atomic; losing the key refills the bucket.

A fixed-window limit of ten requests a minute may allow ten at 12:00:59 and another ten at 12:01:00: twenty requests in two seconds. A bucket that holds ten tokens and refills five per second permits an initial burst of ten, then makes later requests wait for new tokens. Redis rate-limiting guidance.

Build one token bucket

In our lab, every user has a bucket of at most ten tokens. Each request consumes one; Redis adds five tokens per second, up to the maximum. Ten immediate calls can pass when the bucket is full. Over a longer period, the rate approaches five calls per second. These are example numbers, not Stripe’s configuration.

flowchart LR
    Q[Request from user 42] --> S[Lua script in Redis]
    S --> T{At least one token?}
    T -->|Yes: subtract one| API[Allow request]
    T -->|No| E[HTTP 429 and Retry-After]

Why use Lua? If the application runs HGET, calculates, and then runs HSET, two simultaneous requests can both see the last token and both pass. A script runs the read, decision, and write as one Redis operation. A pipeline reduces network trips but cannot make a decision that depends on a preceding reply atomic. Redis pipelining and scripting.

The script updates the bucket with:

1
new_tokens = min(capacity, old_tokens + elapsed_time * refill_rate)

If new_tokens >= 1, it subtracts one and allows the request. Otherwise, it reports how many milliseconds the caller should wait. capacity and refill_rate must be positive server-side configuration values; do not accept a caller-supplied limit.

Python lab: let Redis make the decision

Save the code below as demo_limit.py. It uses the same local Redis and redis Python package as the cache lab:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

TOKEN_BUCKET = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2]) -- tokens per second
local clock = redis.call('TIME')
local now = tonumber(clock[1]) + tonumber(clock[2]) / 1000000
local previous = redis.call('HMGET', key, 'tokens', 'updated_at')
local tokens = tonumber(previous[1]) or capacity
local last = tonumber(previous[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - last) * refill)

local allowed = 0
if tokens >= 1 then
    allowed = 1
    tokens = tokens - 1
end

redis.call('HSET', key, 'tokens', tokens, 'updated_at', now)
redis.call('PEXPIRE', key, math.ceil(capacity / refill * 1000))
local retry_ms = 0
if allowed == 0 then
    retry_ms = math.ceil((1 - tokens) / refill * 1000)
end
return {allowed, math.floor(tokens), retry_ms}
"""

bucket = r.register_script(TOKEN_BUCKET)
key = "demo:limit:user:42"
r.delete(key)
for n in range(12):
    allowed, remaining, retry_ms = bucket(keys=[key], args=[10, 5])
    print(n + 1, "ALLOW" if allowed else "429", remaining, retry_ms)

Run python demo_limit.py. The first calls pass; after tokens run out, the output shows 429. Time still passes during the loop, so the exact number allowed can vary slightly by machine. The script uses Redis’s clock so application servers do not depend on their own possibly skewed clocks. PEXPIRE removes an inactive user’s bucket after enough time has passed for it to refill; the next call starts with a full bucket. An API can turn retry_ms into a Retry-After header, rounding a denied request up to at least one second.

Three code details matter:

  1. TIME runs in Redis. Two application servers with clocks a few seconds apart still calculate refills from the same clock.
  2. HMGET fetches the remaining tokens and the last update time. A missing key means a full bucket. That is a product decision: after failover loses a bucket, the user gets another initial burst.
  3. PEXPIRE cleans up inactive users’ state. For an active user, each call refreshes the TTL because the bucket is still needed.

In a real API, 429 is an HTTP status; the lab only prints it. Middleware also needs an error body, Retry-After, metrics by user or endpoint, and care not to log sensitive identities. Start in monitoring-only mode before enforcing the limit, as Stripe describes.

When the limiter itself fails

Test these cases before relying on the limiter:

  • Identity is ambiguous. Limiting by IP can group many people behind one NAT. Limiting by account requires identifying the account first. A large customer may need a different quota.
  • The bucket disappears. Failover or data loss can create a fresh, full bucket. Do not make a Redis bucket the only protection for a financial or security-critical constraint.
  • Redis is unavailable. Fail open allows traffic so the API remains available, but risks overload. Fail closed protects a resource, but can reject legitimate users. Stripe chose fail open for their limiter; your choice may differ by endpoint.
  • Requests run a long time. A token bucket limits arrivals, not the number of calls currently running. Add a concurrency limit where necessary.
  • The script gets expensive. Lua executes atomically from other clients’ perspective, so a long script delays other Redis commands. Keep the script short and avoid loops over large data. Redis Lua scripting.

Your policy can vary by API. A public read endpoint may temporarily allow traffic with a local per-instance cap when Redis fails. Login or transaction endpoints may need another protection layer in the gateway or application. If a quota is contractual or tied to money, include failover and multi-region behavior in the design.

5. The Operational Questions That Follow

Memory: a good hit rate can be expensive

Redis keeps active data in RAM. maxmemory sets a limit, and an eviction policy determines what happens as Redis reaches it. For a rebuildable cache, test allkeys-lru or allkeys-lfu. With noeviction, some writes fail when memory is exhausted. Test with realistic numbers of keys and realistic value sizes. A million tiny keys still have metadata overhead; one enormous value is expensive to send over the network. Redis eviction, MEMORY USAGE.

An evicted product-cache key can be reloaded from PostgreSQL. An evicted version key from section 2 can undermine the stale-fill protection. Do not mix disposable cache entries and important coordination state under a broad eviction policy without a plan. Separate Redis instances or keep important state in a durable source where necessary.

Durability: restart changes different keys differently

Redis can persist data with RDB, AOF, or both. RDB captures a point-in-time snapshot, so changes after the last snapshot may be lost. AOF records changes; appendfsync everysec usually trades some performance for a potential loss window of roughly a second in certain failures. Define RPO (how much data may be lost) and RTO (how long recovery may take) for each workload. A persistence setting does not replace backups and restore practice. The RDB/AOF guide walks through the mechanisms. Redis persistence.

If the product cache disappears, PostgreSQL can refill it, but the database may face a surge. If a token bucket disappears, a user may receive an extra burst. If Redis holds the only copy of business data, the consequences are much more serious. Evaluate keys by their role rather than treating everything in Redis as equally disposable or equally durable.

Latency: is Redis slow, or is the path to Redis slow?

Even if Redis processes a command quickly, the application still pays for network travel, connection-pool waits, JSON encoding, and the reply size. MGET or a pipeline can reduce network waits for independent commands. When the next action depends on a read result, consider a Lua script or transaction instead. Keep pipeline batches reasonable; Redis buffers replies until clients read them. On large systems, use SCAN rather than KEYS *, and remember that a scan is not a fixed snapshot while keys change. Redis pipelining, latency guidance, SCAN.

Compare p99 latency at the application with command time in Redis. If Redis is fast but the app is slow, investigate network trips, connection waits, and JSON decoding. If Redis is slow too, investigate commands that traverse large collections, long scripts, big values, persistence work, and memory pressure.

Cluster: keys used together need the same slot

The cache lab uses demo:product:{42}:value and demo:product:{42}:version. They share {42}, so Redis Cluster places them in the same hash slot. Without the braces, the keys could land in different slots and the multi-key script could fail. Put the hash tag around the unit of data you must update together. If every product uses {product}, all products collapse into one slot and defeat sharding. Redis Cluster specification.

Observe what users and operators actually need to know

Question Useful signals Sign worth investigating
Is cache reducing database work? Hits and misses by API/key; database reads per second Misses jump after a deployment or key expiration.
Is Redis running out of RAM? used_memory, maxmemory, evicted_keys, sample key sizes Important keys disappear or writes fail.
Are users seeing latency? Application p50/p95/p99, Redis latency, SLOWLOG p99 grows while the average looks fine.
Is the limiter making good decisions? Allows and denies by API/customer; monitoring-only results One group is blocked unexpectedly or overload is not limited.
Can the database survive Redis errors? Redis timeouts, database connections, database p99 A Redis problem cascades into database overload.

INFO stats exposes keyspace_hits, keyspace_misses, and evicted_keys, but they describe the entire Redis instance. They cannot tell you which misses came from product cache versus the rate limiter. Add application metrics by feature. Redis INFO.

6. Eight Failures You Are Unlikely to See in a Tiny Demo

These failures become more likely with several workers, a failover, or a key that quietly grows for months. For each one, ask: what happened, why was it easy to miss, and what should we test?

6.1. SET silently removes the old TTL

One service creates product:42 with SET product:42 ... EX 60. Another overwrites the same key with SET product:42 ... and assumes the old 60-second TTL remains. It does not: a plain SET removes the previous expiration unless the new command sets a TTL or uses KEEPTTL. If a later invalidation is missed, an outdated cache entry can live indefinitely. Redis SET.

1
2
3
4
SET demo:ttl old EX 60
TTL demo:ttl               # positive
SET demo:ttl new
TTL demo:ttl               # -1: the key exists without an expiration

Check: make every cache-writing path set a TTL in the write itself, or intentionally use KEEPTTL when retaining the old deadline is correct. Sample cache keys and look for TTL = -1. This is also why the section 2 lab puts SET ... EX in the script that checks the version.

6.2. An expired event is not a business clock

Suppose an order reserves stock for 15 minutes, and your app waits for Redis to publish an expired key event before releasing that stock. Redis emits the event when it actually deletes the key, which need not happen at the exact instant its TTL reaches zero. If the subscriber is disconnected, Pub/Sub will not replay the missed notification. In a Cluster, keyspace notifications are local to each node. Redis keyspace notifications.

Better approach: TTL can clean up temporary Redis state. If releasing stock must happen, store the deadline in a durable source and have a worker periodically find overdue reservations. Make the worker safe to run twice for the same order. The Redis notification can wake it early, but periodic reconciliation catches missed events.

6.3. Two workers can both believe they hold a lock

Worker A acquires SET lock:order:42 tokenA NX PX 5000. The Redis primary dies before that key reaches a replica. The replica is promoted, and worker B acquires the same lock while A is still working. The same overlap can happen without failover: A pauses longer than the lock TTL, B acquires the expired lock, and A resumes. A single-primary lock backed by asynchronous replication does not provide absolute mutual exclusion through these events. Redis distributed locks, Martin Kleppmann on distributed locking.

1
2
3
A gets lock -> A pauses / primary dies -> lock expires or is absent on replica
                                      -> B gets lock and writes
A resumes  -> A also writes after losing ownership

The random token and safe-release script from section 3 prevent A from deleting B’s lock. They do not prevent A from writing to an external database after its lease expires. If the lock only reduces duplicate cache loads, an occasional extra database read may be acceptable. If it protects a correctness-critical write, the receiver needs to reject old workers. One approach is a monotonically increasing fencing token issued by a sufficiently reliable source; the receiver accepts only newer tokens. A random ownership token has no ordering information.

6.4. WAIT does not turn Redis into a strongly consistent database

WAIT 1 1000 waits up to 1,000 ms for at least one replica to acknowledge earlier writes from the same connection. Your app must inspect the returned replica count. WAIT can reduce the chance of losing a recent write on failover, but it does not guarantee that the replica later promoted is the one that received that write. SET lock followed by WAIT therefore does not fully fix section 6.3. Redis WAIT.

Test: in an isolated environment, keep writing IDs, stop the primary unexpectedly, then compare acknowledged IDs with data on the new primary. One clean run does not prove all future failovers are safe. It shows the behavior of your setup under that particular failure.

6.5. A Stream can redeliver work that already succeeded

Worker A reads an entry with XREADGROUP and successfully charges an external payment service. A dies before XACK. Redis still lists the entry as pending in the Pending Entries List (PEL). Worker B uses XAUTOCLAIM, receives the entry, and may charge the customer again. Acknowledging before charging is also dangerous: if A dies after XACK, nobody may perform the charge. The external effect and the Redis acknowledgment are not one transaction. Redis Streams, XPENDING.

1
2
XREADGROUP -> external payment succeeds -> worker dies -> no XACK
XAUTOCLAIM -> another worker receives it -> possible second payment

Handle duplicates deliberately. Give the task a stable event_id or order_id; the payment service should reject an ID it has already processed. Acknowledge only after confirming the business result. Track XPENDING, entry age, and redelivery count. Repeatedly failing work needs a separate handling path.

Take care when trimming the Stream. In Redis 7.x, XTRIM MAXLEN can remove an entry’s payload while the PEL still refers to it. Redis 8.2 added KEEPREF, DELREF, and ACKED options for more control. Choose retention to accommodate slow workers and verify behavior on the Redis version you actually run. Redis XREADGROUP, XTRIM.

6.6. MULTI/EXEC does not roll back every execution error

If you are used to SQL transactions, you may expect the first command to be undone whenever the second fails. Redis does not do that for an error that happens during execution. An earlier command can succeed, the next can fail, and later queued commands can still run. Redis does discard a transaction for certain errors detected while commands are being queued before EXEC. Redis transactions.

1
2
3
4
MULTI
SET demo:tx:key abc
INCR demo:tx:key       # valid syntax, but "abc" is not an integer
EXEC                   # SET succeeded; INCR errors; the key is still abc

Check: read the result of each command returned by EXEC and test type errors. If another system is involved, design how you will correct a partial business operation. Lua scripts also prevent other Redis commands from interleaving during execution, but that does not make them SQL transactions with automatic rollback for every error.

6.7. One hot key can overload one Cluster node

Redis Cluster maps keys to hash slots and distributes slots among nodes. One key belongs to one slot. If most traffic reads or updates flash-sale:stock:42, its node can saturate while the others are idle. Adding nodes does not split one popular key into pieces. A broad hash tag such as {catalog} can accidentally push a whole group of keys into the same slot. Redis Cluster scaling, Cluster specification.

Measure traffic by key and by node. For mostly read-only data that tolerates a little lag, local application cache, a CDN, or replica reads may help. Splitting an atomic counter into several keys changes its meaning: you need a new aggregation and correctness design, not only new key names.

6.8. Deleting a huge key can make Redis pause

DEL on a structure with a very large number of elements can spend time freeing memory as part of the command path, delaying other work. UNLINK removes the key from the keyspace first and frees the heavier memory work on another thread. The cleanup still happens; it is moved away from the immediate reply path. Redis UNLINK.

Check: use MEMORY USAGE, inspect collection sizes, and compare SLOWLOG with p99 latency near cleanup time. If the same key repeatedly becomes huge, investigate splitting the data or setting retention earlier rather than only changing DEL to UNLINK.

7. Exercises and a Pre-Launch Checklist

Try these exercises on a dedicated test instance:

  1. Reproduce stale cache. Run demo_cache.py, remove the version check as described in section 2, and compare Redis with SQLite. Restore the check and see which step changes the outcome.
  2. Observe the limiter. Run demo_limit.py with [10, 5], then with [3, 1]. Add different pauses between calls. Why does a call pass after waiting?
  3. Start with an empty cache. Under simulated traffic, delete a popular key or restart with no cache. Measure database queries per second and p99 latency. Add request coalescing or stale-while-revalidate and measure again.

Before a real rollout, write down answers to these questions:

Question Decision to make
What if Redis is unavailable? Read the database, return permitted stale data, or fail the request? Set a timeout and limit database fallback load.
How long may old data appear? Choose TTL from product requirements. Important writes may need reliable invalidation or an authoritative read.
What if RAM fills? Set maxmemory and eviction by data type; keep correctness-critical keys away from broad cache eviction. Redis eviction.
Must data survive restart? Choose RDB/AOF from allowed data loss, keep backups, and practice restore. Redis persistence.
Which commands might cause latency? Avoid KEYS * on large systems; use SCAN, check command complexity, value size, and SLOWLOG. Redis latency guidance.
Too many network trips? Use MGET/MSET or reasonable pipeline batches for independent commands. Redis pipelining.
What should we monitor? Cache misses by feature/key, p95/p99, evictions, memory, connections, database load, limiter denials, and Redis errors.

Two concrete answers matter more than a generic checklist: “If Redis has not replied in 500 ms, what does this request do?” and “If the cache shows an old price, what happens to the customer?” Without agreed answers, timeout, fallback, and TTL settings are guesses.

8. What I Would Take Away

GET and SET are the easy part. The hard parts are the ordering between Redis and the database, the peak when many readers miss together, and the effects of Redis losing state or changing primaries. Meta’s cache example makes a stale fill easy to see. Stripe’s rate-limiter story shows why limits need monitoring and a failure policy before enforcement. The eight cases above show how a command that seems fine in isolation can fail once time, retries, or failover are involved.

Run the Python labs, change one step at a time, and compare the observed behavior with the guarantees your product actually needs.

Further reading