Đang tải nội dung…
Đang tải nội dung…
A stale product price, two unsafe orderings, and how a WAL consumer repairs the cache
Redis Internals & Cache Architecture · Part 6Fictional setting, verified lab evidence. Fortex in this narrative is fictional; Tom and Ben are characters illustrating engineering reasoning. All metrics, WAL decodings, and assertions come from the linked reproducible Docker lab, not Fortex production systems.
A Journey at Fortex · Chapter 14
At 10:15 AM sharp, Ben’s internal phone rang incessantly. On the other end was an irate VIP merchant:
“Why is our flagship headset still selling for $100 on the live storefront when our operations team raised the price to $110 over half an hour ago? The admin portal says update successful, yet customers are snapping up inventory at the old discounted rate! Who is going to compensate us for this revenue loss?”
Ben broke into a cold sweat. He hurried to his terminal to investigate:
SELECT price FROM products WHERE id = 1; → Returned 110.GET product:1 → Stubbornly showed 100!Ben immediately executed DEL product:1 in Redis. Instantly, the public product page refreshed and displayed $110.
Breathing a sigh of relief, Ben opened the backend source code. He quickly pinpointed the price update handler: It executed a single UPDATE products SET price = 110 WHERE id = 1 against PostgreSQL, with absolutely zero lines touching Redis!
“Aha!” Ben smiled. He promptly created an emergency pull request: Adding a cache deletion right after the database transaction committed:
await db.query('UPDATE products SET price = $1 WHERE id = $2', [newPrice, productId]);
await db.commit();
await redis.del(`product:${productId}`); // Ben added this line
Ben rolled his chair over to show Tom: “Look Tom, I found the culprit! Whoever built the price update API forgot to invalidate the cache. I just added redis.del right after db.commit(). Now the database commits first, and then the cache is cleared. No more stale prices!”
Tom looked at Ben’s new snippet without immediate praise or criticism. He pointed his finger directly at the gap between the commit() line and the redis.del() line, asking:
“What happens if the Node.js process gets OOM-killed by Kubernetes, the container host loses power, or network connectivity to Redis drops at the exact instant between these two lines?”
Ben’s smile vanished.
He realized the brutal reality: The database has durably committed the price of $110, but the process dies before issuing redis.del! The stale $100 remains in Redis indefinitely, and customers continue buying at the outdated rate until the TTL expires (or forever if keys lack a TTL)!
“Then...” Ben scrambled for an alternative, “Then I'll just flip the order! I'll delete the cache first, and only then update the database!”
await redis.del(`product:${productId}`); // Delete cache first
await db.query('UPDATE products SET price = $1 WHERE id = $2', [newPrice, productId]);
await db.commit();
Ben rationalized: “Deleting the cache first is safer! If deleting succeeds but the DB update fails, the worst that happens is a cache miss where the next reader pulls the old value from the DB. No corrupted state!”
Tom shook his head and pulled out a whiteboard marker: “You're only visualizing a solitary Writer executing in isolation. Have you accounted for hundreds of concurrent Readers viewing the product at that exact moment?”
Tom drew the timeline of two concurrent actors:
Writer (Updating Price: 100 -> 110) Reader (Customer Viewing Product)
---------------------------------- ----------------------------------
1. redis.del('product:1') (Cache empty)
2. Read Redis: Cache Miss!
3. Read DB: Sees old price 100!
(Writer has not committed yet)
4. Writes 100 back into Redis!
5. db.query('UPDATE ... price = 110')
6. db.commit() (DB is now 110)
Ben went pale. He muttered to himself:
“That's terrifying... A reader encounters a cache miss, queries the database before the writer commits, reads the stale $100, and caches it back into Redis! Then the writer commits $110. Final state: Database holds 110, but Redis is repopulated with 100!”
WhyBit’s reproducible lab faithfully executed both concurrent interleavings:
| Execution Schedule | Final DB State | Final Cache State | Root Cause |
|---|---|---|---|
| DB First, Missing DEL | 110 | 100 | Committing DB and Invalidating Cache are two distributed side-effects; atomicity is impossible |
| DEL First, Reader Race | 110 | 100 | Reader reads uncommitted old DB value and recaches stale data |
“I remember reading online about a popular trick called Delayed Double Deletion,” Ben scratched his head. “You delete cache, update DB, sleep(500ms), and delete cache a second time to purge whatever stale data a reader might have repopulated. Isn't that a solid pattern?”
Tom chuckled: “That is the quintessential ‘hope-driven engineering’ answer often given in interviews by engineers who have never operated high-throughput systems.”
Tom dissected the critical flaws of the technique:
sleep inside a synchronous request thread holds onto connection pool slots and worker threads, choking overall backend throughput.“A TTL (Time-To-Live) merely bounds the lifespan of stale data; it does not close the consistency window,” Tom concluded. “To solve this fundamentally, we need a mechanism where the invalidation intent originates directly from the exact place where data commit is guaranteed!”
“What is the single source of truth in our architecture?” Tom asked.
“The database transaction log!” Ben answered promptly. “In PostgreSQL, that’s the Write-Ahead Log (WAL). Once a transaction commits, it is durably etched into the WAL on disk!”
Precisely. Instead of having application servers perform flawed dual-writes, the industry-standard architecture for large-scale systems is Change Data Capture (CDC):
COMMIT.The official Debezium PostgreSQL connector documentation highlights that Debezium tracks its processed WAL position (LSN - Log Sequence Number). If Debezium crashes or restarts, it resumes reading from the exact LSN where it left off, providing guaranteed At-Least-Once delivery.
“Wait Tom, At-Least-Once means events can be delivered multiple times!” Ben noted. “What if a delete cache event is received twice?”
Tom smiled: “Try running DEL product:1 twice in a row in Redis and see what happens.”
Ben ran the commands:
(integer) 1.(integer) 0.“It is completely Idempotent!” Ben cheered. “Even if re-delivered ten times, the final state in Redis remains identical: the key does not exist. The next read is guaranteed to fetch the freshest value from the database!”
In WhyBit’s reproducible lab, CDC was configured on PostgreSQL 17.6 using the logical replication slot whybit_cache and the standard test_decoding plugin:
100.110 in PostgreSQL and committed.100 (observable staleness window).BEGIN 746
table public.products: UPDATE: id[integer]:1 price[integer]:110
COMMIT 746
id: 1 and executed DEL product:1 in Redis.110 from PostgreSQL, and repopulated Redis. The cache converged to 110!“That is the true value of CDC,” Tom explained. “Invalidation intent is no longer a fragile promise in the volatile memory of an app server. It is anchored to the durability of the database transaction log. Even if the consumer is down for minutes, the change sits safely in the WAL waiting to be processed!”
Ben was eager: “So CDC is the silver bullet for caching?”
“There are no silver bullets in distributed systems,” Tom cautioned. “CDC buys you durability and recoverability, but imposes substantial operational responsibilities.”
Ben jotted down the operational hazards:
max_slot_wal_keep_size and setting up alerts for retained WAL bytes (PostgreSQL streaming replication slots).All experimental assertions were automatically verified inside isolated Docker containers:
This report image is generated from the report.json for run 771d0fda-ed2c-4d43-8c88-78df7c125ba0: DB = 110, stale cache = 100, WAL retains transaction 746, and duplicate DEL calls return 1 then 0. It is not a production dashboard.
| Lab Scenario | Test Conditions | Observed Results |
|---|---|---|
| DB First, Missing DEL | Crash after commit, before DEL | DB = 110, Cache = 100 (Indefinitely inconsistent) |
| DEL First, Reader Race | Reader reads old DB state before writer commits | DB = 110, Cache = 100 (Recaches stale value) |
| WAL CDC Recovery | Consumer offline during commit, then restarted | WAL retains change; consumer triggers DEL; cache converges to 110 |
| Idempotent DEL | Duplicate execution of delete command | First call returns 1, second returns 0, state safe |
Report JSON · Raw log · Lab Source Code · Run 771d0fda-ed2c-4d43-8c88-78df7c125ba0
Scope & Limitations: The lab runs on a single host with PostgreSQL 17.6 and Redis 7.4.2 to isolate logical mechanics. It demonstrates WAL retention and idempotent deletion; it does not simulate a full Debezium Kafka Connect cluster or multi-datacenter network partitions.
Ben summarized the revised Fortex architecture:
ProductChanged events.DEL against Redis.cdc_consumer_lag_seconds and postgres_wal_retained_bytes.version headers so frontends can detect stale reads.Tom smiled: “Did we achieve absolute real-time strong consistency?”
“Not strong consistency,” Ben grinned. “Cache in distributed systems is always eventually consistent. But now, stale values are never orphaned forever by an unhandled server crash. And most importantly: we can measure exactly how many milliseconds behind we are!”
Fortex and dialogue are fictional narrative devices. All measurements and technical logs are from the linked reproducible lab run.
Previous: Chapter 13 — Invisible Deadlocks · Next: Chapter 15 — Kafka Hot Keys
PostgreSQL 17.6 logical decoding + Redis 7.4.2 · digest pinned · isolated Docker
Download Docker Lab source (.zip)COMMIT a035d919d67e3863bdaab6961089bec3d917905e
Read the README before running. Standard WhyBit lab commands:
make setupmake seedmake reproducemake verifymake cleanupIsolated data, resource limits and automatic cleanup. Article measurements refer to the published run; new executions keep their own history.