Appearance
Redis 与 MySQL 数据一致性:原理、方案与工程落地
更新: 7/5/2026 字数: 0 字 时长: 0 分钟
只要你把数据同时放在缓存和数据库里,就一定要面对"这两份数据什么时候、以什么程度对得上"的问题。这份文档从一致性问题的根源讲起,逐层拆解业界主流方案的取舍,最后落到"什么场景选什么"和"哪些坑必须提前知道"。

一、一致性问题的本质来源
在谈方案之前,必须先想清楚:不一致不是某个 bug,而是"两份数据 + 非原子操作 + 并发"这三者叠加后的必然结果。根源可以拆成四层。
1. 双写不是原子操作。 更新数据库和更新(或删除)缓存是两次独立的网络请求,中间没有事务保护。只要第一步成功、第二步失败(进程崩溃、网络抖动、Redis 超时),两份数据立刻分叉。这是最根本的来源——跨存储系统没有统一事务。
2. 并发下的操作交错。 即使每一步都成功,多个请求的读写顺序一旦交错,也会把旧值写回缓存。经典场景:
- 请求 A 读数据库拿到旧值(缓存刚好失效)
- 请求 B 更新数据库为新值、并删了缓存
- 请求 A 才慢悠悠把刚读到的旧值写进缓存
结果缓存里存着旧值,且直到过期都不会自愈。这类问题的本质是读操作的"读库→写缓存"之间存在时间窗口。
3. 主从复制延迟。 MySQL 通常读写分离,主库写、从库读。如果更新完主库就去读从库回填缓存,而 binlog 还没同步过去,读到的就是旧数据。这是数据库内部的一致性延迟被带进了缓存。
4. 缓存与数据库的过期/淘汰不同步。 Redis 有 TTL 和内存淘汰(LRU/LFU),可能在你不知情时把某个 key 踢掉;也可能因为设置 TTL 失败导致脏数据长期驻留。
关键认知:跨系统的强一致(任意时刻两份数据完全相同)在工程上代价极高、几乎不做。真正的目标是"最终一致" + "把不一致窗口压到足够短、影响足够小"。 后面所有方案,本质都是在"一致性强度""性能""复杂度"三者之间做权衡。
二、缓存更新的三种基础模式

在讨论"先删还是后删"这些细节之前,先明确三种顶层架构模式,它们决定了缓存由谁来维护。
1. Cache Aside(旁路缓存)—— 最主流
应用代码自己负责读写缓存,缓存和数据库互不感知。
- 读:先查缓存,命中直接返回;未命中则查数据库,回填缓存后返回。
- 写:先更新数据库,再删除缓存(注意是"删"不是"改")。
| 维度 | 说明 |
|---|---|
| 适用场景 | 绝大多数读多写少的业务,通用性最强 |
| 优点 | 实现简单、缓存故障不影响主流程、控制权在应用层 |
| 缺点 | 存在并发窗口导致的短暂不一致;缓存穿透/击穿需额外处理 |
为什么写时"删缓存"而不是"更新缓存"?因为并发下两个写请求更新缓存的顺序可能和更新数据库的顺序相反,导致缓存存旧值;而"删除"是幂等的,下次读自然回填最新值,风险更低。
2. Read/Write Through(读写穿透)
应用只跟缓存层打交道,由缓存组件(或封装的中间层)同步去读写数据库。
| 维度 | 说明 |
|---|---|
| 适用场景 | 有成熟缓存中间件、希望业务代码不关心数据库的场景 |
| 优点 | 业务代码干净、缓存与库的同步逻辑收敛在一处 |
| 缺点 | 需要中间件支持、写延迟较高(写缓存要同步等写库完成)、生态在 Redis 原生场景较少 |
3. Write Behind / Write Back(异步回写)
写操作只更新缓存,立刻返回;由后台异步批量刷回数据库。
| 维度 | 说明 |
|---|---|
| 适用场景 | 写极其密集、允许丢失少量数据、对最终落库延迟不敏感(如计数器、点赞数、日志类指标) |
| 优点 | 写性能极高、可合并写、削峰 |
| 缺点 | 缓存宕机会丢数据、一致性最弱、实现复杂 |
小结:Cache Aside 是默认选择;Read/Write Through 适合有中间件托管的体系;Write Behind 是高写入吞吐下的特化方案,用一致性换性能。
三、Cache Aside 下的写顺序之争:核心细节

真正的工程细节在于:更新数据库和删除缓存,谁先谁后?四种组合逐一分析。
方案 A:先删缓存,再更新数据库 —— ❌ 风险高
问题:删完缓存、还没更新库时,一个读请求进来发现缓存空,就去读了旧的数据库值并回填缓存。随后写请求才更新数据库。结果缓存长期是旧值。不推荐。
方案 B:先更新数据库,再删除缓存(Cache Aside 标准做法)—— ✅ 推荐
绝大多数情况正确。唯一的理论漏洞:读请求在"缓存恰好失效"的瞬间读到旧库值,还没回填,此时写请求更新库并删缓存,之后读请求才把旧值写入。但这个窗口要求"读库比写库+删缓存还慢",概率极低,工程上通常可接受。
方案 C:延迟双删 —— ✅ 增强方案
在方案 B 基础上,写完数据库、删一次缓存后,延迟一小段时间(如 500ms~1s)再删一次。目的是把并发读请求回填的旧值也清掉。
text
1. 删除缓存
2. 更新数据库
3. sleep(延迟,覆盖一次读操作的耗时)
4. 再次删除缓存- 优点:覆盖住并发读回填旧值的窗口,一致性更强。
- 缺点:延迟时间要凭经验估(一般略大于一次读操作耗时),且第二次删除若失败仍会不一致,需配合重试。
方案 D:更新数据库 + 删缓存失败重试 —— ✅ 生产必备兜底
删缓存这一步可能失败。为保证最终删掉,把"删除缓存"这个动作做成可重试:失败时投递到消息队列,消费端重试删除,直到成功。这是把方案 B/C 真正落地到生产的关键补丁。
| 方案 | 一致性强度 | 复杂度 | 何时用 |
|---|---|---|---|
| A 先删缓存再写库 | 弱 | 低 | 基本不用 |
| B 先写库再删缓存 | 较强 | 低 | 通用默认 |
| C 延迟双删 | 强 | 中 | 并发读写较多、对一致性较敏感 |
| D 删除重试兜底 | 强 | 中 | 生产环境标配,与 B/C 组合使用 |
四、基于 binlog 订阅的异步同步方案

前面的方案都要求业务代码在写库的同时操心缓存。更彻底的思路是:让业务只管写数据库,缓存的更新交给"监听数据库变更"的独立链路。
做法:用 Canal(或 Debezium、Maxwell)伪装成 MySQL 的从库,订阅 binlog;数据库任何一次写入都会产生 binlog 事件,被采集后投递到消息队列(Kafka/RocketMQ),由消费端据此删除或更新 Redis。
text
业务 → 写 MySQL → 产生 binlog → Canal 捕获 → MQ → 消费端更新/删除 Redis| 维度 | 说明 |
|---|---|
| 适用场景 | 系统内多个服务都写同一批数据、想彻底解耦缓存维护、已有 binlog 采集基建 |
| 优点 | 业务代码零侵入、天然覆盖所有写入来源、借助 MQ 可重试保证最终一致、可顺带做数据异构/搜索索引同步 |
| 缺点 | 引入 Canal + MQ 的运维成本;存在同步延迟(毫秒到秒级);binlog 消费需保证顺序,否则新旧事件乱序又会不一致 |
这套方案是大型系统里保证最终一致的常见"正规军"打法:一致性由 MQ 的可靠投递 + 消费重试来兜底,代价是架构变重、有秒级延迟。
五、按场景选型建议

没有"最好"的方案,只有"最匹配当前一致性要求和成本预算"的方案。给几条实用的选型线索:
1. 读多写少、一致性要求一般(商品详情、文章、配置、用户资料) → Cache Aside(先写库再删缓存)+ 合理 TTL。TTL 本身就是最省事的兜底:即使某次删缓存失败,最多脏到过期为止。这是覆盖面最广的默认选择。
2. 读写都较多、对短暂不一致较敏感(库存展示、余额、订单状态) → Cache Aside + 延迟双删 + 删除失败重试。用双删压窗口,用重试保证最终删掉。
3. 要求强一致 / 不允许读到旧值(支付、扣款、下单扣库存) → 别单纯依赖缓存做一致性。核心校验以数据库为准(数据库行锁、乐观锁、SELECT ... FOR UPDATE、唯一约束),缓存只承担读加速。真正的扣减用数据库事务或分布式锁保证,缓存只是"展示层"。把一致性责任压回数据库,是强一致场景的正解。
4. 系统内多写入源、希望缓存维护解耦(中台、多服务共享数据) → binlog 订阅(Canal + MQ)异步更新,业务零侵入。
5. 写极密集、允许丢少量数据(计数、点赞、埋点、排行榜实时分数) → Write Behind 异步回写,或直接以 Redis 为主存、定期落库。
6. 读写分离架构下 → 回填缓存时强制读主库,或写完后延迟一段时间再允许从库回填,避开主从延迟导致的旧值回写。
一句话概括:一致性要求越高,越应该把责任交给数据库本身,缓存越靠后、越简单;一致性要求越低、性能要求越高,越可以让缓存承担更多、异步化更多。
六、常见坑点与注意事项

| 坑点 | 现象 | 应对 |
|---|---|---|
| 删缓存失败没兜底 | 库更新了、缓存没删掉,长期脏数据 | 删除操作做重试(MQ/异步补偿);配 TTL 做最终兜底 |
| 主从延迟回写旧值 | 写完主库读从库回填,缓存进了旧数据 | 回填读主库,或延迟回填、双删 |
| 延迟双删的延迟拍脑袋 | 延迟太短没盖住并发读,太长影响体验 | 按实测的读操作 P99 耗时来定,配合监控调优 |
| binlog 消费乱序 | 同一 key 的新旧事件顺序颠倒,缓存存旧值 | 按主键 hash 到同一分区/队列保证单 key 有序消费 |
| 缓存雪崩 | 大量 key 同一时刻过期,请求全压到 MySQL | TTL 加随机抖动、热点 key 永不过期+后台更新 |
| 缓存击穿 | 热点 key 失效瞬间大量并发打穿到库 | 互斥锁/单飞(singleflight)只放一个请求回源 |
| 缓存穿透 | 查不存在的数据,每次都穿到库 | 空值也缓存(短 TTL)、布隆过滤器拦截 |
| 更新缓存而非删除 | 并发写导致缓存被旧值覆盖 | 写操作统一用"删除",读时再回填 |
| 用 TTL 当唯一一致性手段 | 过期前一直脏 | TTL 只做兜底,不能替代主动删除/更新逻辑 |
| 缓存与库字段结构耦合 | 库改结构,缓存里的旧序列化数据解析出错 | 缓存值加版本号,升级时按版本失效 |
| 分布式锁保护不当 | 用锁保一致性却锁粒度过大或锁失效 | 锁粒度到 key 级、设置合理超时、避免锁续期漏洞 |
几条经验性原则收尾:
- 接受最终一致,不迷信强一致。 90% 的业务用"Cache Aside + 先写库删缓存 + TTL + 删除重试"就够了,不要一上来就上 binlog 全家桶。
- 一致性的最后一道防线永远是数据库。 缓存能容忍偶尔的脏和丢,钱和库存这类不能,就必须在数据库层用锁和事务保证,缓存只做加速。
- 给不一致装上"可观测"。 埋点监控缓存命中率、删除失败率、同步延迟,比事后靠用户投诉发现脏数据强得多。
- 窗口再小也存在,先评估影响再决定投入。 先问"这份数据脏 1 秒会怎样",再决定用多重的方案,避免过度设计。