Redis 和 MySQL 如何保证数据一致性?先更新数据库还是先删缓存,延迟双删、MQ、Canal 一次讲透
Redis 和 MySQL 如何保证数据一致性?先更新数据库还是先删缓存,延迟双删、MQ、Canal 一次讲透

**先给结论:**在经典 Cache-Aside 模式里,通用基线是“先提交 MySQL 事务,再删除 Redis 缓存,并给缓存设置 TTL”。但这只能把不一致控制在有限窗口内,不能凭两行代码获得跨两个独立系统的强一致。删除失败要可靠重试;跨服务重要数据可用事务 Outbox + MQ;写入口很多时可用 binlog CDC/Canal 集中失效;余额、库存扣减、权限判定等关键逻辑不能把 Redis 缓存当唯一真相。
缓存一致性之所以经常讲错,是因为很多文章只比较“先删还是后删”,却没有区分三件事:
- 并发竞态:一个慢读请求可能把旧值重新写回缓存;
- 部分失败:数据库提交成功,删除缓存却超时、进程崩溃或消息丢失;
- 业务一致性目标:普通商品详情允许几秒旧读,余额和权限却可能完全不允许。
本文不背口号,而是从正确性模型、并发时序、故障窗口、Spring Boot 实现、延迟双删、重试、Outbox、MQ、Canal、对账和监控逐层推导。配套代码可以复现两类脏缓存竞态。
一、先定义目标:你要的是“永远一致”,还是“旧值最多存在多久”?
MySQL 是持久化事实源,Redis 通常只是其派生副本。只要一次业务写入需要依次操作 MySQL 和 Redis,就存在双写问题:第一步成功、第二步失败;或者两个并发请求以意料之外的顺序完成。
因此设计前先回答下面四个问题:
- 事实源是谁? 删除 Redis 后,系统能否从 MySQL 重建?
- 允许的旧读窗口是多少? 100 毫秒、5 秒、10 分钟,还是完全不允许?
- 是否要求 Read-Your-Writes? 用户刚修改头像后,自己的下一次读取是否必须立即看到新值?
- 失败后如何收敛? TTL、重试、消息队列、CDC、对账分别承担什么职责?
如果这些问题没有答案,“保证一致性”只是无法验收的口号。更可操作的目标应写成:
商品详情缓存允许最多 30 秒旧读;写成功后 99.99% 的缓存失效事件在 2 秒内完成;任何失败最终在 TTL 10 分钟或对账任务 5 分钟内收敛;余额扣减始终以数据库事务结果为准。
这类目标才能转化为监控和故障演练。
二、Cache-Aside 的标准读写闭环

图 1:读请求命中则直接返回;未命中才查 MySQL 并带 TTL 回填。写请求先提交数据库事务,再删除缓存,等待下一次读取重建。原创教学图(Image2 生成)。
读路径:
- 根据业务实体生成稳定 Key,例如
cache:catalog:product:10086; - 读取 Redis,命中则反序列化返回;
- 未命中时查询 MySQL;
- 查询成功后写入 Redis,并设置 TTL;
- 数据库也不存在时,可缓存短 TTL 的空值哨兵,避免重复穿透。
写路径:
- 在 MySQL 事务内更新业务数据;
- 事务提交成功后删除对应 Redis Key;
- 下一次读请求未命中,从数据库读取新值并重建缓存。
为什么通常是“删除”而不是“同步更新缓存”?因为删除操作不需要重新实现一遍业务聚合逻辑,而且对重复执行更友好。更新缓存会遇到两个写请求乱序:请求 A 先更新数据库为 V2,但回写缓存较慢;请求 B 后更新数据库为 V3,却先写入缓存;最后 A 再把 V2 覆盖回缓存。删除虽然也有竞态,但状态空间更小,下一次读取仍以事实源重建。

图 2:Redis 官方当前文档将 Cache-Aside 描述为带 TTL 的有界旧读,并给出写入后 DEL 的显式失效路径。来源:Redis Docs,原始页面。
Redis 官方文档也强调 TTL 是旧值存活时间的上界之一。**显式删除负责尽快失效,TTL 负责在删除链路彻底失效时兜底收敛。**二者不能互相替代:只设 TTL 会让每次写入都可能旧读到过期;只删除不设 TTL,则一次永久失败可能让脏值长期存在。
三、四种更新顺序,为什么只有一种适合作为通用基线?
1. 先更新缓存,再更新数据库:不推荐
如果缓存写成功、数据库事务失败,Redis 已经暴露一个从未持久化的值。即使数据库最终成功,两个并发写请求也可能因缓存写入完成顺序不同而覆盖成旧版本。更严重的是,其他服务如果绕过缓存直接读数据库,会同时观察到两个世界。
2. 先更新数据库,再更新缓存:一般不作为 Cache-Aside 基线
它解决了“先缓存后数据库”的虚假值问题,但没有解决并发写回乱序,而且每次写都必须重新计算缓存对象。商品详情缓存可能包含商品表、促销表、库存摘要和权限裁剪,写服务很难保证与读服务使用完全相同的拼装规则。
只有在写穿透(Write-Through)架构中,缓存组件与事实源的写入被统一封装、业务可以接受其一致性与延迟模型时,才应单独评估“同步更新缓存”。不要把它与普通 Cache-Aside 混为一谈。
3. 先删除缓存,再更新数据库:高风险竞态
看似合理:先让旧缓存消失,再改数据库。但删除与事务提交之间通常比“数据库提交与删除缓存”之间更长,读请求很容易钻进窗口:缓存未命中 → 读取数据库旧值 V1 → 写回 Redis → 写请求才提交 V2。最终 Redis=V1、MySQL=V2,旧值可以一直存在到 TTL。
4. 先更新数据库,再删除缓存:推荐基线,但不是魔法
常见流程是:数据库提交 V2 → 删除缓存 → 下次读重建 V2。它把大多数竞态窗口压缩到一个较苛刻的顺序:缓存恰好缺失;慢读先从数据库拿到 V1;写请求提交 V2 并删除缓存;慢读最后才把 V1 写回 Redis。
这个顺序仍然可能发生,只是通常比“先删再改库”概率低。更现实的风险反而是数据库提交成功后,Redis 删除失败或状态不确定。因此基线必须配合 TTL、重试和观测,而不是把 redis.delete(key) 当成完整方案。

图 3:左侧的“先删后改库”暴露较宽窗口;右侧即使“先改库后删缓存”,极端情况下慢读仍可能回填 V1。原创教学图(Image2 生成,已核对请求角色、V1/V2 与箭头顺序)。
可以运行配套模拟:
python code/cache_race_simulation.py
输出的两种最终状态都是 cache=V1, db=V2。这个脚本没有模拟随机线程,而是刻意按最危险的事件顺序执行,因此每次都能复现,不会出现“跑一万次偶尔才看到一次”的教学障碍。
四、真正棘手的不是顺序,而是数据库提交后的失败窗口
假设代码如下:
@Transactional
public void updatePrice(long id, Money price) {
repository.updatePrice(id, price);
redis.delete("cache:product:" + id);
}
这段代码至少有三个问题。
第一,Redis 调用发生在数据库事务提交之前。如果 Redis 删除成功,随后数据库事务回滚,缓存虽然只是暂时缺失,但并发读会回源旧数据并重建;更糟的是,开发者容易误以为 @Transactional 能同时回滚 Redis。它只能管理配置到该事务管理器的数据库资源,不会让独立 Redis 命令自动加入 MySQL 本地事务。
第二,Redis 返回超时时,客户端不知道服务端究竟有没有执行 DEL。这是模糊结果:不能简单把超时等价为失败,也不能当作成功。好消息是删除同一 Key 可以重复执行;Redis DEL 会忽略不存在的 Key并返回实际删除数量,因此重试删除通常天然幂等。
第三,数据库提交后进程可能在调用 Redis 前崩溃。没有持久化事件或可恢复日志时,这次失效意图随着进程一起消失,只能等待 TTL。
还要考虑:Redis 主从切换、网络分区、线程池耗尽、消息队列积压、消费成功但 ACK 丢失、多个写入口绕过同一服务、读写分离的副库延迟,以及热 Key 删除后并发回源造成缓存击穿。一个生产方案必须明确每一种失败由谁恢复。
五、Spring Boot 基础实现:在事务提交后删除,并把失败交给可靠重试
配套 code/ProductCacheService.java 展示了核心结构。读路径使用短空值 TTL、正常值 TTL 抖动;写路径在事务提交后执行失效:
@Transactional
public void updatePrice(long id, Money newPrice) {
repository.updatePriceAndIncrementVersion(id, newPrice);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
String key = "cache:product:" + id;
try {
redis.delete(key);
} catch (RuntimeException ex) {
retryQueue.enqueue(key);
}
}
}
);
}
这里有几个故意保留的工程边界:
afterCommit确保只有数据库提交成功后才删缓存;它不会让删除变成数据库事务的一部分。retryQueue必须是持久、可观测的队列。把任务放进当前 JVM 的普通内存队列,进程重启仍会丢失。- 删除事件只需要携带实体类型和 ID,最好附带数据库版本与 eventId,便于去重、追踪和对账。
- 重试采用指数退避并加入抖动,避免 Redis 故障恢复时所有任务同时重放。
- 达到最大重试次数不能静默丢弃,要进入死信队列并触发告警。
仅仅在 catch 中打印日志,不算重试机制;仅仅调用一次 MQ,也没有解决“数据库提交成功、发送消息前崩溃”的双写窗口。这正是 Outbox 要处理的问题。
六、延迟双删到底解决什么?延迟时间应该怎么定?
经典延迟双删通常有两种写法:
方案 A:删除缓存 → 更新数据库 → 等待 Δt → 再删缓存
方案 B:更新数据库 → 删除缓存 → 等待 Δt → 再删缓存
第二次删除的目的,是清理在并发窗口中被慢读请求重新写回的旧值。它是概率性补偿,不是原子协议。
延迟时间不能拍脑袋固定为“500ms”。至少要覆盖:数据库读请求的 P99 耗时、可能的只读副本复制延迟、应用序列化与写 Redis 的时间、队列调度抖动。如果 Δt 太短,慢读在第二次删除后仍可能写回旧值;如果太长,旧值暴露窗口被主动拉长,任务堆积也会增加。
更关键的是,延迟任务本身也可能丢失。如果使用 Thread.sleep 占用 Web 线程,既不可恢复,也会降低吞吐;如果使用内存定时器,进程重启后任务消失。生产实现至少要用持久延迟队列、定时任务表或消息系统,并监控积压。
延迟双删适合:历史系统改造成本有限、允许短暂旧读、读写竞态确实存在且 TTL 过长。它不适合:余额、库存扣减结果、权限撤销等要求严的决策,也不适合作为“删除失败可靠送达”的唯一手段。第二次 DEL 若也失败,问题仍然存在。
七、可靠性升级:从一次 DEL 到重试、Outbox 与 CDC

图 4:可靠性提升意味着额外存储、投递、幂等、对账和运维成本,不存在零成本的“绝对一致”。原创教学图(Image2 生成)。
第 1 级:提交数据库后 DEL + TTL
适合普通商品、文章详情、用户公开资料。实现最简单,失败由 TTL 最终兜底。必须监控删除错误率和缓存年龄;TTL 应与业务旧读容忍度一致,并加入随机抖动避免集中到期。
第 2 级:删除失败进入异步重试
适合订单详情等重要读多写少场景。删除 Key 是幂等的,可使用指数退避重复执行。重试记录至少包含 eventId、entityType、entityId、attempt、nextRetryAt、lastError。消费者成功后 ACK,失败进入下一次重试;超过阈值进入死信并告警。
注意:如果“把重试任务写入队列”与“更新数据库”仍是两个独立动作,进程崩溃窗口依然存在。它提升可用性,但没有彻底解决业务事务和事件发布的原子性。
第 3 级:事务 Outbox + MQ
在同一个 MySQL 本地事务中,同时更新业务行并插入一条 outbox 事件。事务要么一起提交,要么一起回滚。独立 Relay 轮询 outbox,或由 CDC 捕获 outbox 表变化并发布到 MQ;消费者接收后幂等删除缓存。
START TRANSACTION;
UPDATE product
SET price = 199.00,
version = version + 1
WHERE id = 10086;
INSERT INTO cache_outbox(
event_id, aggregate_type, aggregate_id,
event_type, aggregate_version, payload
) VALUES (
UUID(), 'product', '10086',
'CACHE_INVALIDATE', 42,
JSON_OBJECT('cacheKey', 'cache:product:10086')
);
COMMIT;
Outbox 通常提供“至少一次”效果,因此重复事件是正常情况。消费者不能假设 exactly-once:DEL 可以重复;eventId 可用于观测和业务去重;如果消息存在版本,消费者还要防止旧事件晚到后误操作新版本状态。
第 4 级:binlog CDC / Canal 集中失效
当数据可能被多个服务、后台脚本、管理平台直接修改时,应用内删除很容易漏掉写入口。CDC 订阅 MySQL binlog 中的数据变更,将变更事件发送到 MQ,再由统一消费者计算并删除相关缓存。
优点是对写应用侵入小,覆盖面广;缺点是链路更长,存在 binlog 解析、复制延迟、Schema 变更、事件乱序、重复投递、积压和映射维护成本。数据库一行变化可能对应多个缓存 Key,例如商品、店铺首页、搜索条件和榜单,失效映射必须显式治理。

图 5:Debezium 官方说明 Outbox Pattern 用于避免服务内部数据库状态与对外事件状态不一致,并通过 Connector 捕获 outbox 表变化。来源:Debezium Documentation,原始页面。
Canal 与 Debezium 都属于 CDC 工具,但部署模型、生态和消息格式不同。本文把 Canal 作为常见 MySQL binlog 订阅方案举例,不意味着“接上 Canal 就自动一致”:消费幂等、断点恢复、延迟监控、Schema 演进和对账仍然要由系统承担。
八、为什么不能用 Redis Pub/Sub 代替可靠失效队列?
Redis Pub/Sub 很轻量,但官方文档明确其交付语义是 at-most-once:订阅者断线或处理失败后,已经发送的消息不会自动重投。如果失效事件永久丢失,而缓存 TTL 又很长,就会留下长期脏值。
因此:实时通知、允许丢失的界面刷新可以用 Pub/Sub;可靠缓存失效应选择具备持久化、消费确认、重试和积压观测的机制,例如业务 MQ、Redis Streams,或 Outbox/CDC 管道。选择任何队列都不能省略幂等消费者,因为 ACK 丢失会导致重复投递。
九、主从读延迟会怎样制造“删完又旧”?
假设写请求更新 MySQL 主库并删除 Redis,下一次缓存未命中却从只读副库查询。若副库还没追上主库,应用会读到 V1 并重新写入 Redis。此时缓存旧值并不是删除顺序错误,而是回源数据源本身落后。
常见处理方式:
- 写后的一小段时间把同一用户或同一实体的读固定到主库;
- 使用版本/时间戳令牌,客户端携带“至少需要看到 version=42”,副库未追上则回主库;
- 对关键读取直接使用主库或具备一致读保证的存储;
- 延迟双删的 Δt 覆盖副库 P99 延迟,但这只是概率缓解;
- 监控复制延迟,超过阈值时暂停回填或降级读路径。
不要用“Redis 很快”掩盖数据库复制语义。缓存未命中后从哪里读,决定了重建内容是否新鲜。
十、热 Key 失效:一致性修好了,数据库却可能被打穿
写入后删除热点商品 Key,几千个并发请求同时未命中并回源数据库,会形成缓存击穿。解决一致性不能制造可用性事故。
可使用单飞(single-flight)或互斥重建:只有一个请求获得短锁并查询数据库,其他请求短暂等待、返回允许的旧副本或降级结果。锁要使用唯一 token;释放时用 Lua 比较 token 后删除,避免锁过期后误删别人的新锁。锁 TTL 要覆盖数据库 P99 读取时间,并且查询失败不能把错误永久写入缓存。
另一种方案是逻辑过期:缓存值携带 logicalExpireAt,过期后先返回旧值,同时由单个后台任务异步刷新。这提高可用性,却主动接受一段旧读,适合首页、榜单等场景,不适合余额与权限。
TTL 抖动也很重要:正常 TTL 600 秒,可随机增加 0–120 秒,避免批量数据在同一秒过期。但抖动只能缓解雪崩,不能修复写入后的主动失效失败。
十一、版本号能解决什么,不能解决什么?
给数据库行增加单调递增 version 有三个价值:
- 日志和消息能说明自己对应哪次业务状态;
- 消费者可拒绝比已处理版本更旧的事件;
- 客户端可以表达 Read-Your-Writes 的最低版本要求。
但只把 version 放进缓存 JSON 并不能自动阻止“缓存已删、慢读回填 V1”:缓存为空时,简单的“新版本大于当前版本才写”仍可能接受 V1。要真正建立版本栅栏,需要一个不会随数据缓存一起删除的最新版本标记、数据库条件校验,或让回填过程在锁/事务边界内再次确认版本。这样会增加额外读取和复杂度。
因此版本号是检测、排序和拒绝旧事件的工具,不是跨 MySQL 与 Redis 的免费原子协议。
十二、强一致业务应该怎么做?
余额、库存扣减、优惠券核销、权限撤销等关键决策,应在数据库事务、带条件 UPDATE、唯一约束、行锁/乐观锁,或专门的一致性系统中完成。Redis 可以缓存展示结果、做限流预检查或降低读压力,但最终提交必须回到事实源验证。
例如库存扣减应使用数据库条件更新:
UPDATE sku_stock
SET available = available - 1,
version = version + 1
WHERE sku_id = 10086
AND available > 0;
以影响行数判断是否成功,而不是先读 Redis 库存再无条件更新 MySQL。缓存显示稍旧可能影响体验;缓存判定错误若直接产生超卖,则是正确性事故。
要求用户写后立即看到新值时,可以在写接口响应中直接返回提交后的实体;或在短时间内将该用户的读路由到主库。不要为了页面立刻刷新,宣称整个系统已经获得全局强一致。
十三、方案决策:复杂度必须与业务风险匹配

图 6:普通详情读、重要 API、跨服务事件和多写入口需要不同方案;异步方案都不是强一致。原创教学图(Image2 生成)。
| 场景 | 推荐起点 | 旧读窗口 | 主要成本 |
|---|---|---|---|
| 商品/文章详情 | DB 提交后 DEL + TTL 抖动 | 毫秒至 TTL | 简单,但删除失败靠 TTL |
| 订单详情、用户资料 | 基线 + 持久重试 + 死信 | 通常秒级 | 队列与运维 |
| 跨服务组合缓存 | 事务 Outbox + MQ | 消息延迟 | 表、Relay、幂等与积压 |
| 多入口写数据库 | binlog CDC/Canal + MQ + 对账 | CDC 延迟 | 基础设施与映射治理 |
| 余额/扣减/权限 | 事实源强校验,缓存仅加速 | 不允许用于最终判定 | 更高数据库与工程成本 |
十四、监控什么,才能知道一致性真的在工作?
至少建立以下指标:
- 缓存删除调用量、成功率、超时率与 P99;
- 从数据库提交到缓存删除成功的延迟分布;
- 重试队列长度、最老任务年龄、重试次数与死信数量;
- Outbox 未发布行数、最老 created_at、Relay 发布延迟;
- MQ 消费积压、重复事件比率、消费者失败率;
- CDC 位点延迟、binlog 保留安全余量、Schema 解析错误;
- 抽样对账的不一致率,以及按业务类型/Key 前缀分布;
- 缓存命中率、回源 QPS、热 Key 重建等待时间;
- 写后读到旧版本的业务埋点。
对账不能只比较“Redis 有没有 Key”,还要比较实体版本或内容摘要。发现不一致时,修复动作通常是删除缓存让其重建,而不是把缓存反向覆盖数据库。
告警阈值应关联业务 SLO。例如 Outbox 积压 1000 条不一定严重,但最老事件已等待 10 分钟就可能超出旧读窗口。只看队列长度会被流量规模误导。
十五、怎样测试竞态和故障,而不是只跑正常流程?
单元测试应使用 Barrier/Latch 控制事件顺序:让读请求在查询到 V1 后暂停;写请求提交 V2 并删除缓存;再放行读请求写回 V1。这样可以稳定验证补偿机制,而不是靠随机并发碰运气。
集成测试至少注入以下故障:
- MySQL 提交成功后,Redis DEL 超时;
- Redis 已执行 DEL,但客户端连接在响应前断开;
- 进程在事务提交和发送事件之间退出;
- MQ 重复投递、乱序投递、ACK 丢失;
- 消费者长时间不可用后恢复,观察重放风暴;
- 只读副库延迟 3 秒,缓存未命中从副库回填;
- 热 Key 删除后 500 个并发请求同时读取;
- Schema 增加字段,CDC 消费者能否兼容;
- binlog/队列积压超过缓存 TTL 后,对账是否仍能收敛。
验收不应只看“最终相同”。还要记录最大旧读持续时间、旧读请求比例、数据库峰值 QPS、重试恢复耗时和死信数量。
十六、十个常见误区
- 把
@Transactional当成 MySQL + Redis 分布式事务。 它通常只控制数据库本地事务。 - 认为先更新数据库再删缓存绝对不会脏。 极端慢读回填仍可能发生。
- 用固定 500ms 延迟双删解决所有场景。 延迟应由真实 P99 和复制延迟决定。
- 删除失败只打印日志。 没有持久重试、死信和告警就无法恢复。
- 用 Redis Pub/Sub 发送关键失效事件。 at-most-once 语义允许消息永久丢失。
- 相信 MQ exactly-once 后消费者无需幂等。 网络和 ACK 故障仍会产生重复处理边界。
- CDC 能自动知道删哪些聚合 Key。 一行变更到缓存 Key 的映射仍需治理。
- TTL 越短一致性越好。 过短会降低命中率并制造回源压力,且关键业务仍不能靠 TTL 正确判定。
- 缓存有 version 就不会被旧值覆盖。 缓存为空时仍需要版本栅栏或二次校验。
- 所有数据都上 Outbox/Canal。 复杂度、积压和运维成本必须匹配业务风险。
十七、上线检查清单
- 明确 MySQL 是事实源,缓存可删除并重建;
- 为每类数据定义最大可接受旧读窗口;
- 写事务提交后再触发失效;
- 所有缓存都有 TTL,并按业务容忍度设置;
- 删除失败进入持久重试,具备指数退避、死信和告警;
- 消费者按 eventId/实体版本幂等;
- 回源读取考虑主从复制延迟;
- 热 Key 具备单飞、限流或逻辑过期方案;
- Outbox/CDC 链路监控最老事件年龄而非只看数量;
- 定期对账并以删除缓存作为修复动作;
- 对余额、库存、权限等关键判定回到事实源;
- 用可控并发顺序和故障注入验证恢复能力。
总结
Redis 与 MySQL 一致性没有一句万能口诀,只有与业务目标匹配的收敛机制。
普通 Cache-Aside 从“数据库事务提交后删除缓存 + TTL”开始;删除失败用持久重试;需要把业务提交与事件可靠绑定时使用事务 Outbox;写入口很多时通过 binlog CDC/Canal 集中捕获;所有异步链路都要幂等、监控、死信和对账。延迟双删只是在特定竞态下缩短脏值寿命,不是强一致协议。
最终要守住的边界是:缓存负责加速,事实源负责正确;缓存一致性方案负责把旧值窗口控制在业务允许范围内,而不是掩盖跨系统双写的客观失败可能。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)