Redis 和 MySQL 缓存一致性,延迟双删为什么不是强一致
Redis 放在 MySQL 前面,读请求是快了,但"数据库更新成功,缓存是不是也一定是新值"并没有答案。
我更愿意把缓存当成数据库的副本,而不是第二个事实源。这样遇到故障时,先问三个问题:旧值最多能存在多久,删除失败怎么补,哪些判断根本不能读缓存。

先看最容易漏掉的竞态
假设缓存里有 V1,现在写请求 B 把数据库改成 V2。
常见的 Cache-Aside 写法是:
text
1. 提交 MySQL 事务
2. 删除 Redis key
3. 下一次读请求从 MySQL 回源,再写入 Redis
问题在第 2 步和第 3 步之间。读请求 A 可能已经拿到了旧数据,或者刚好从一个还没追上的只读副本读到了 V1。A 随后把 V1 写回 Redis,最后就变成:
text
MySQL = V2
Redis = V1
这不是"Redis 删除命令偶尔慢"这么简单,而是读写并发下的顺序没有被约束。

延迟双删到底解决哪一段
有人会这样写:
java
updateDatabase();
deleteCache();
sleep(500);
deleteCache();
第二次删除确实能覆盖一部分场景:读请求先拿到旧值,第一次删除后回填,第二次删除再把它清掉。
但这个方案有几个前提,少一个都不能把它叫成"强一致":
- 延迟必须大于一次慢读、序列化和回填的总耗时。固定写死 500 毫秒只是猜测,线上 P99 变了就会失效。
- 第二次删除本身可能失败。失败只打日志,缓存不会因为你"计划删过"就自动消失。
- 回源如果走延迟副本,副本还没追上时读到的仍然是 V1。这时再删一次,也只是把错误窗口往后推。
- sleep 会占住业务线程,流量一高,缓存问题还没解决,线程池先被拖慢了。
所以延迟双删更像是给历史系统加的一层概率缓解,适用于允许短时间旧读的场景。库存、余额、权限判定这类数据,不能靠它做最终判断。
更稳的落地顺序
我一般按下面的边界拆:
1. 数据库事务提交后再删缓存
不要在事务还没提交时就删缓存。事务回滚了,缓存反而先没了;这虽然最终可能回源修复,但会制造不必要的抖动。
写路径可以先保持简单:
text
提交 MySQL
↓
删除 Redis
↓
删除失败进入持久重试
重试记录要有业务 key、版本或事件 id,不能只靠内存里的一个异步任务。进程重启、队列积压、Redis 短暂不可用时,都要能继续处理。
2. 读请求要认识"副本不新鲜"
如果数据库有只读副本,缓存 miss 后直接读副本,再回填缓存,旧值写回的窗口会更大。
可以选一种明确的策略:
- 写入后的短窗口内,回源读主库。
- 带上最低版本号,副本没追上就改读主库。
- 复制延迟超过阈值时,暂停这类数据的缓存回填。
这里的重点不是把所有请求都打到主库,而是别把"副本可读"误当成"副本最新"。
3. 写入口多时,用事件做收敛
如果更新不只来自一个服务,应用层删缓存很容易漏。MySQL 的 binlog 能按提交顺序记录事务变更,可以用 CDC 或消息链路消费它,再做缓存失效。
这条链路也不是魔法,至少要处理:
- 消息重复:同一个事件重放不能把数据搞坏。
- 消息乱序:旧事件不能覆盖新版本。
- 消费失败:要有重试、死信和对账。
- 延迟监控:看"数据库提交时间到缓存失效时间"的 P95/P99,而不是只看消费成功数。
缓存一致性不是一个"选哪种方案"的单选题。真正要定的是:数据库是事实源,缓存允许多旧,失效事件多久必须收敛,以及哪些请求永远不能用缓存结果做最后决定。
面试时我会这样回答
如果只剩一分钟,我会先说:
Redis 是 MySQL 的副本,不是共同事务的一部分。提交数据库后删除缓存是常见的 Cache-Aside 写法,但删除失败、慢读回填、只读副本延迟都会造成短暂不一致。延迟双删只能缩短部分竞态窗口,不能保证强一致。重要数据要用主库或一致读,删除失败要持久重试,多写入口可以用 binlog CDC 做最终收敛。
面试官继续追问"那到底多久删第二次",我不会报一个看起来很精确的数字,而是先问业务的读 P99、复制延迟和允许的旧读时长。这个数字应该从监控里量出来,不是从博客里抄出来。