MySQL and Redis Data Consistency: Why You Delete, Not Update, the Cache
First, the conclusion: for most Cache-Aside business scenarios with more reads than writes, the preferred approach is "commit the database transaction → delete the cache → rebuild from the source on the next read," and a TTL is mandatory. If the deletion fails, you can't just log it; important data should enter a persistent retry. For cross-service scenarios, you can upgrade to Outbox + MQ. When there are many write entry points, you can use binlog CDC/Canal. These provide measurable eventual consistency, not native strong consistency across MySQL and Redis.
The most misleading part of this problem is treating "correct order under normal conditions" as "correct under failures and concurrency." What you really need to analyze is: Can a slow read write an old value back? What happens if the database has committed but the cache deletion fails? Does a read request backfill from a lagging replica? Can the system converge when messages are duplicated or out of order?
Before discussing solutions, define how long "inconsistency" can last
A cache is just a copy of database data. When two independent systems have no shared local transaction, dual-writes always have a partial failure window. The more meaningful engineering goal is not "always consistent" but: the database is the single source of truth; the maximum staleness of the cache in seconds; how quickly a deletion event must complete; whether failures are converged by TTL, retries, or reconciliation; and which business operations absolutely cannot use the cache for final decisions.
A product detail being 3 seconds old might be acceptable. A user who just changed their nickname expects to see it immediately. Permission revocation and balance deductions should not rely on stale cache values. Using the same "delayed double-delete with 500ms" for all three is clearly unreasonable.
Why does Cache-Aside usually delete, not update, the cache?
On reads, check Redis first, and only query MySQL and backfill with a TTL on a miss. On writes, commit to MySQL first, then invalidate the cache. The benefit of deletion is not that it's absolutely consistent, but that it keeps the database as the source of truth, allowing the next read to rebuild via the unified read logic.
Synchronously updating the cache often requires duplicating aggregation logic: a product cache might come from multiple tables, and the write service and read service can easily produce two sets of serialization rules. Two concurrent write requests might also commit in the order V2, V3 but write back to the cache in the order V3, V2. Deleting the same key is idempotent, making the state space simpler.
The official Redis documentation describes Cache-Aside as TTL-bounded staleness and performs an explicit DEL after a write. Source: Redis cache-aside.
Eliminating the four ordering strategies one by one
Update cache first, then database: Exposes cache values that were never persisted if the database fails; concurrent writes can also overwrite out of order.
Update database first, then cache: Does not solve write-back ordering, and the writer must accurately reconstruct the read model. Unless the system explicitly uses Write-Through and encapsulates the source-of-truth and cache writes, it shouldn't masquerade as ordinary Cache-Aside.
Delete cache first, then update database: The window is very wide. After deletion, a concurrent read misses the cache, reads V1 from the database, and backfills; the write request then commits V2, leaving the cache holding V1 long-term.
Update database first, then delete cache: More stable in the vast majority of cases, but an extreme sequence can still cause a dirty read: the cache happens to be missing; a slow read gets V1 from the database; a write request commits V2 and deletes the cache; the slow read finally backfills V1. A more common failure is a deletion timeout or a process crash right after commit.
The left side is a high-probability window; the right side is a demanding but still possible slow-read backfill. Original teaching diagram.
So "update database first, then delete cache" is the baseline, not proof that "it will never go wrong."
Deletion failure is the real hole production systems must patch
After a database commit, the following can happen: Redis timeout; DEL executed but response lost; application process crashes before the call; thread pool rejects the task; master-slave failover; network partition. A timeout is an ambiguous result—the client doesn't know if the server executed it—but you can safely delete the same key again.
A basic implementation should place the deletion after the transaction commit:
@Transactional
public void updatePrice(long id, Money price) {
repository.updatePriceAndIncrementVersion(id, price);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
invalidateOrEnqueueRetry("cache:product:" + id);
}
}
);
}
afterCommit only guarantees execution after the database commit; it does not add Redis to the MySQL transaction. If the retry queue is just a JVM in-memory structure, a process restart will still lose it. A production queue needs persistence, exponential backoff, a dead-letter queue, and monitoring of the oldest task age.
Is delayed double-delete the standard answer?
The second delayed deletion is used to clean up old values re-written by a concurrent slow read. It can reduce the lifespan of a dirty value, but it is not an atomic protocol.
The delay Δt must at least cover the database read P99, read-only replica replication lag, serialization, and cache write time. A fixed 500ms is just a guess: if too short, the old value will be written after the second deletion; if too long, the stale window expands. Using Thread.sleep occupies a business thread and is unrecoverable; a persistent delayed task should be used instead.
Delayed double-delete is suitable for legacy systems that tolerate short stale reads. It is not suitable for balances, inventory deductions, or permission checks, nor can it replace deletion failure retries.
Upgrading from a single DEL to a recoverable pipeline
Level 1: DB-then-DEL + TTL. Suitable for ordinary details like products and articles. The TTL is the upper-bound fallback when the deletion pipeline fails completely.
Level 2: Asynchronous retry. After a deletion failure, persistently record eventId, Key, attempt, nextRetryAt; use exponential backoff, eventually entering a dead-letter queue and alerting.
Level 3: Transactional Outbox + MQ. Updating the business row and inserting the outbox event happen in the same MySQL local transaction. A Relay/CDC publishes the message, and a consumer idempotently deletes the cache. This solves the window where "the process crashes after the database commit but before the event is sent."
sequenceDiagram
participant API as Write Request
participant DB as MySQL Transaction
participant O as outbox table
participant R as Relay / CDC
participant MQ as Message Queue
participant C as Invalidation Consumer
participant Redis as Redis
API->>DB: Update business row and increment version
DB->>O: Insert CACHE_INVALIDATE event in same transaction
DB-->>API: COMMIT success
R->>O: Poll or subscribe to changes
R->>MQ: Publish entityId + version + eventId
MQ->>C: At-least-once delivery
C->>Redis: Idempotent DEL cache:product:id
C-->>MQ: ACK
Note over O,C: Duplicate delivery is allowed; consumer must be idempotent, failures go to retry/dead-letter
Messages are typically delivered at-least-once, so duplicates are normal. Don't remove consumer idempotency just because the middleware advertises exactly-once.
Level 4: binlog CDC / Canal. When background scripts or multiple services can write to the database, CDC captures changes from the binlog and uniformly produces invalidation events. The advantage is covering all entry points; the costs are latency, backpressure, schema evolution, out-of-order events, and the governance of mapping one row to multiple cache keys.
Debezium explains that the Outbox uses the same database transaction to avoid inconsistency between internal state and external events. Source: Outbox Event Router.
Why can't Pub/Sub be used directly as a reliable invalidation bus?
The official Redis documentation states that Pub/Sub is at-most-once: if a subscriber disconnects or processing fails, already-sent messages are not automatically redelivered. Refresh notifications that can tolerate loss can use it, but critical cache invalidation should choose a queue or Streams with persistence, ACK, retries, and backlog monitoring.
A frequently overlooked problem: replica lag
After the primary commits V2 and deletes the cache, a cache-miss request reading from a replica that is 2 seconds behind will write V1 back into Redis. This is not a DEL ordering problem, but a stale read on backfill.
You can route entity reads to the primary for a short time after a write; carry a minimum version token and fall back to the primary if the replica hasn't caught up; use consistent reads for critical reads; or pause backfill when replication lag exceeds a threshold. Delayed double-delete can probabilistically mitigate this, but it cannot replace understanding replication state.
After deleting a hot key, how to avoid thundering herd on the database?
A single correct invalidation can cause thousands of requests to miss simultaneously. Use single-flight/mutex rebuild, allowing only one request to query the source; other requests wait briefly or use a permissible stale copy. The lock must have a unique token, and the token must be compared on release to avoid accidentally deleting a lock renewed by someone else.
Logical expiration can return the old value first and then refresh asynchronously. This suits homepages and leaderboards, but not balances and permissions. TTL jitter is used to scatter batch expirations and also cannot fix an active deletion failure.
A version number is not a free atomic protocol
Adding a version to a database row helps with event deduplication, out-of-order detection, and Read-Your-Writes. But when the cache is empty, a slow read of V1 can still be written; having the version inside the JSON alone cannot reject it. You need an independent version fence, a pre-backfill re-check, or a lock, all of which increase complexity and extra reads.
Version numbers are good for detection and ordering, but they do not equal an atomic commit across MySQL and Redis.
Which data should never rely on the cache for decisions?
Balances, inventory deductions, coupon redemptions, and permission revocations should be decided by database transactions, unique constraints, conditional UPDATEs, or consensus systems. Redis can accelerate display or do pre-checks, but the final commit must return to the source of truth.
UPDATE sku_stock
SET available = available - 1,
version = version + 1
WHERE sku_id = 10086 AND available > 0;
Judging the success of a deduction by the number of affected rows is more reliable than reading the cache inventory first and then writing to the database unconditionally.
Decision matrix for choosing a strategy
All asynchronous solutions have message or replication latency; complexity should match business risk. Original teaching diagram.
flowchart TD
A["A business write occurs"] --> B{"Does this data tolerate brief stale reads?"}
B -- "No" --> C["Critical decisions read database or consistent storage directly"]
C --> D["Redis only accelerates, not the single source of truth"]
B -- "Yes" --> E["Database transaction commits"]
E --> F["Delete cache key"]
F --> G{"Is the deletion confirmed successful?"}
G -- "Yes" --> H["Next read request rebuilds from database + TTL"]
G -- "No or timeout" --> I{"How high is the required reliability level?"}
I -- "Ordinary business" --> J["Async retry + exponential backoff + dead-letter alert"]
I -- "Important cross-service data" --> K["Transactional Outbox + MQ + idempotent consumption"]
I -- "Many write entry points" --> L["binlog CDC / Canal + centralized invalidation"]
J --> M["Periodic reconciliation and compensation"]
K --> M
L --> M
H --> N["Monitor stale read window, retry backlog, and cache hit rate"]
M --> N
What must be observed after going live?
- The P95/P99 latency from database commit to successful cache deletion;
- The rate of DEL failures, timeouts, and ambiguous results;
- The oldest event age in retries/Outbox/MQ/CDC;
- Dead-letter count, duplicate event ratio, and consumption failure rate;
- The inconsistency rate and number of repairs from sampled reconciliation;
- The backfill peak and lock wait after a hot key invalidation;
- Business metrics for users reading an old version after their own write.
Testing must also control race condition ordering: pause a read request after it gets V1, let a write request commit V2 and delete, then release the old read to backfill. You should also fault-inject Redis timeouts, ACK loss, consumer crashes, replica lag, and duplicate messages. Running only the happy path cannot prove recoverability.
What fields should an invalidation message contain?
If you only send delete product:10086, it's hard to troubleshoot when duplicates, out-of-order events, or reconciliation occur. A more practical event includes at least: a global eventId, entity type, entity ID, database version, event type, transaction commit time, source service, and traceId.
After receiving an event, the consumer should first validate the type and key namespace, then perform an idempotent deletion; upon success, record the processed version and latency. Don't stuff complete user privacy data or large objects into the invalidation event; invalidation only needs enough to locate the cache. If the same entity corresponds to multiple keys for lists, details, and aggregate pages, a clear mapping component should generate them, not the consumer concatenating strings everywhere.
The event version can also help identify out-of-order events. For example, if version=43 has already been processed and then version=42 arrives, the consumer can record it as a late event. For a pure DEL, duplicate deletion is usually harmless, but if the consumption logic also refreshes the cache or triggers other side effects, version checking becomes very important.
How to design a reconciliation task so it doesn't cause an incident?
Reconciliation should not do a full table scan of the database and then read Redis one by one; this can hammer both systems simultaneously. You can shard by update time, sample by business risk, or compare the database version with cache metadata. Upon finding an inconsistency, prioritize deleting the cache and letting the normal read path rebuild; don't let the reconciliation program overwrite the database with a cached value.
The reconciliation task must also be rate-limited, pausable, resumable, and record a scan cursor. After a CDC interruption recovery, a mass consumer failure, or a schema change, you can temporarily increase the scan ratio. Normally, keep a low-cost sample to form a long-term baseline for the "inconsistency rate," and only alert when it deviates from the baseline.
How to achieve immediate visibility after a user's own write?
An eventually consistent global cache does not mean a user must read a stale value immediately after a successful modification. The write API can directly return the new object after the transaction commits; the frontend uses this response to update the current page. Subsequent reads within a short time can carry a minimum version or session token; if the service discovers the cache version is insufficient, it falls back to the primary database instead of blindly returning a stale cache.
This approach provides session-level Read-Your-Writes while preserving the cache benefit for other read requests. It is more economical than forcing all nodes across the entire system to synchronously refresh, but you still need to set a marker expiration time to prevent all users from bypassing the cache long-term.
The final judgment criteria
For ordinary business, start with "DB-then-DEL + TTL"; add persistent retries for important deletions; use Outbox + MQ for cross-service scenarios; use CDC/Canal when there are many write entry points; all asynchronous solutions require idempotency, monitoring, dead-letter queues, and reconciliation.
Don't pursue the expensive and vague slogan of "making Redis and MySQL identical at every instant." Pursue this: the source of truth is clear, the stale read window is measurable, failures are recoverable, and critical decisions do not depend on a potentially expired cache.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Retry chains are very practical.