Redis 与数据库缓存一致性:从最终一致到强一致的完整解法
Redis 与数据库缓存一致性:从最终一致到强一致的完整解法
缓存一致性是那种”人人以为懂、一写就翻车”的题目。本文先讲清为什么会不一致,再对比三种缓存模式的优劣,最后按业务对一致性的要求强弱,从最终一致到强一致给出一整套方案,并坦白每种方案的代价。所有方案以语言无关的伪代码呈现,重在思路而非语法。
目录
一、为什么会不一致
一切问题的根源只有一句话:缓存和数据库是两个独立的系统,对它们的两次写入无法保证原子性。
只要你”写了数据库,又动了缓存”,这两步之间就存在一个时间窗口。在这个窗口里,任何并发请求看到的都可能是错的。不一致主要来自两类场景。
1.1 双写问题:第二步失败
1 | 应用 数据库 缓存 |
数据库改成功了,但删缓存这一步失败了。于是缓存里残留着旧值,且没有任何机制纠正它——这是最危险的一类不一致,因为它不会自愈。
1.2 并发读写交错:脏数据被”写回”缓存
这一类更隐蔽。即便每一步都成功了,只要时序不巧,缓存照样会被污染。经典场景是**「读请求回填」与「写请求删除」交错**:
1 | 时间 ──────────────────────────────────────────────────▶ |
线程 A 在第 2 步读到了旧值,但它”慢了一拍”,等到第 5 步才回填;而线程 B 在这中间已经更新 DB 并删了缓存。A 的回填把已经过期的旧值重新塞回了缓存。
核心思想:不一致的根因是「双写非原子」+「读写时序交错」。所有解决方案,本质上都是在和这两件事做斗争——要么缩小窗口、要么补偿纠错、要么干脆上锁串行化。
二、三种缓存模式及其优劣
在谈怎么解决之前,得先分清谁来负责缓存和数据库的同步。不同的缓存模式,把这个责任放在了不同的地方。
2.1 Cache-Aside(旁路缓存)
最常用的模式。缓存只是”旁路”,应用代码直接同时操作缓存和数据库,一致性完全由应用自己负责。
1 | 读: |
为什么写的时候是删除缓存而不是更新缓存?两个原因:
- 避免无效计算:缓存值往往是多张表聚合、计算后的结果。每次写都重新算一遍再塞进缓存,但这个 key 可能很久都没人读,白算。删除是惰性的,下次真有人读才回填。
- 减少并发写覆盖:两个写请求若都去”更新缓存”,缓存里最终留下的可能是先到 DB、后到 Cache 的那个旧值。删除则没有这个问题。
2.2 Read/Write-Through(读写穿透)
应用不直接碰数据库,只和缓存层打交道。缓存层自己负责”穿透”到数据库。
1 | 读(Read-Through): |
关键在于 Write-Through 里写 DB 和写缓存被封装在缓存组件内部,对应用是一次原子调用。一致性责任从应用上移到了缓存层。后面 3.2 讲强一致时,会基于这个模式做文章。
2.3 Write-Behind(写回 / Write-Back)
写操作只更新缓存就立即返回,数据库由缓存层异步批量落库。
1 | 写: |
性能最高(写操作几乎不碰磁盘),但最危险:缓存节点在数据落库前宕机,这批数据就永久丢失了。适合能容忍丢数据的场景(如计数器、点赞数),绝不能用于订单、余额这类数据。
2.4 三种模式对比
| 维度 | Cache-Aside(旁路) | Write-Through(写穿透) | Write-Behind(写回) |
|---|---|---|---|
| 谁负责一致性 | 应用自己 | 缓存组件 | 缓存组件 |
| 写性能 | 中(同步写 DB + 删缓存) | 中(同步写 DB + 写缓存) | 高(只写缓存即返回) |
| 数据安全 | 高(DB 是源头) | 高(DB 同步落盘) | 低(宕机丢数据) |
| 实现复杂度 | 低(应用层几行代码) | 高(需缓存组件支持) | 高(需异步刷盘 + 容错) |
| 一致性上限 | 最终一致(可加强到强一致) | 可做强一致 | 最终一致(且可能丢) |
| 典型场景 | 绝大多数业务 | 对一致性要求高、读写都频繁 | 可容忍丢失的高频写 |
核心思想:模式之争的本质是「一致性责任放在哪一层」。Cache-Aside 把责任甩给应用,灵活但容易写错;Write-Through 把责任收进缓存层,是后续做强一致的基础;Write-Behind 用数据安全换性能,场景受限。绝大多数业务用的都是 Cache-Aside,所以第三节的解决方案也主要围绕它展开。
三、按一致性强度排兵布阵
没有”最好”的方案,只有”最匹配业务要求”的方案。关键问法是:这份数据短暂不一致,能接受吗?
- 能接受几秒不一致(商品详情、文章内容、用户资料)→ 追求最终一致,方案轻、性能好。
- 不能接受任何不一致(账户余额、库存扣减、支付状态)→ 追求强一致,代价是性能和复杂度。
3.1 最终一致:TTL 兜底、延迟双删、binlog 订阅
最终一致的目标:允许短暂不一致,但保证在有限时间内自动收敛到正确。 我们有三件武器,逐个上。
① TTL 兜底:最朴素,但不可或缺
给每个缓存 key 设一个过期时间。哪怕所有同步逻辑都失败了,key 过期后下一次读会自动从 DB 回填正确值。
1 | cache.set(key, value, ttl=300s) // 5 分钟后无论如何都会被淘汰重建 |
- 优点:实现零成本,是所有方案的最后一道防线。再精巧的方案也应该叠加 TTL,防止”删缓存失败”导致的永久脏数据(回顾 1.1)。
- 缺点:TTL 期间数据可以一直是错的。它只保证”最终”会对,但这个”最终”可能长达整个 TTL。不能单独作为一致性方案,只能兜底。
② 延迟双删:一个经验型方案,时间窗口很难拿捏
延迟双删针对的是 1.2 那种「读回填覆盖」的并发问题。思路是:写完之后删两次缓存,第二次延迟一会儿,目的是把那个”慢半拍的旧值回填”也一并清掉。
1 | 延迟双删: |
为什么第二次删能解决问题?回顾 1.2 的时序:线程 A 读到旧值后”慢半拍”才回填。只要第二次删除发生在 A 回填之后,就能把这个旧值清掉,缓存随后由新的读请求回填为正确值。
但这里有个绕不开的硬伤——那个 sleep(N) 到底设多久?
1 | A 回填旧值 |
N 必须大于”一次缓存未命中读请求的完整耗时”(读 DB + 回填的时间),可现实中这个耗时是抖动的——平时 5ms,GC 或慢查询时可能 500ms。你没法设一个对所有情况都正确的值:
- 设 50ms:大多数情况够,但慢查询那次的旧值回填发生在 80ms,删除(50ms)已经过去了,照样脏。
- 设 1s:覆盖面广了,但这 1 秒内缓存一直是空的(被删了),读请求全部击穿到 DB,且若主从同步有延迟,第二次删时从库可能还没同步,删了个寂寞。
客观评价:延迟双删是一个经验型、概率型方案。它确实能在很大程度上降低并发回填脏数据的概率,工程上也确实有人用、有效果。但它不能给出确定性保证——
N的取值本质是赌”读请求会在 N 内完成回填”,这个赌注没有理论上限。它降低了不一致的概率,却没有消除不一致。所以它适合做”锦上添花”,不该作为核心依赖。真正想把同步这件事做扎实,看下面的 binlog 订阅。
③ binlog 订阅:最终一致的主力方案
延迟双删的脆弱,根子在于”删缓存”这个动作是应用主动发起的——应用得记得删、删的时机得对、删失败了还得重试。能不能把”删缓存”这件事,从应用代码里彻底解耦出去?
能。数据库每一次成功的写入,都会落到 binlog(MySQL)/ WAL(PostgreSQL)这种变更日志里。我们让一个独立的服务去订阅这份日志,DB 一旦有数据变更,就由这个服务去删对应的缓存。
1 | ┌──────────────┐ |
(MySQL 生态常用 Canal,跨数据库通用方案常用 Debezium,原理一致:伪装成从库,拉取 binlog 流。)
这套方案的好处,恰好补上了前面所有方案的短板:
- 应用代码极简且不会漏:应用只负责写数据库,根本不用碰缓存。”忘记删缓存”或”删缓存失败”这类 bug 从代码里消失了——只要 DB 写成功,binlog 一定有记录,缓存就一定会被删。
- 天然可靠重试:binlog 是持久化、有序的日志流。订阅服务消费失败可以重试,重启后从上次的位点(offset)继续消费,不丢变更。这正是延迟双删做不到的”确定性”。
- 彻底解耦:写库的业务方和缓存维护方互不感知。新增一个缓存,只要加一个订阅消费者即可,业务代码一行不改。
代价也要讲清楚:
- 引入了新组件(Canal/Debezium + 消息队列),架构变重,要承担它的部署、监控、高可用成本。
- 存在延迟:从 DB 写入到 binlog 被消费、缓存被删,有一个毫秒到秒级的延迟。所以它依然是最终一致,不是强一致。这段延迟内读到旧缓存是可能的。
- 顺序与幂等:要保证同一个 key 的变更被顺序消费(否则旧的删除覆盖新的),消费逻辑要幂等。
核心思想:最终一致的正确姿势是「binlog 订阅做主力(确定性地、解耦地删缓存)+ TTL 做兜底(防止任何意外导致的永久脏数据)」。延迟双删可作为过渡或补充,但别把它当成顶梁柱——它给不了确定性。
3.2 强一致:乐观锁、2PC,尽量不加锁
如果业务连”几百毫秒的不一致”都不能忍(余额、库存、支付状态),就得上强一致。强一致的核心思想变了:不再容忍窗口、事后补偿,而是要么让缓存和 DB 的写入构成一个事务、要么用并发控制把交错的写串行化。
这里我们基于 Write-Through 模式(2.2)来谈——因为写 DB 和写缓存需要被当作一个事务单元来对待,而 Write-Through 正好把这两步收在了一起。
思路一:乐观锁(优先,因为不加锁)
强一致不等于”必须加锁”。能不加锁就不加。乐观锁的思想是:不提前上锁,而是在更新时校验数据有没有被别人改过,靠版本号(或 CAS)保证。
1 | 带版本号的更新: |
缓存侧也带上版本号,回填时只接受版本号更高的值,就能从根上杜绝 1.2 那种”旧值回填覆盖新值”——因为旧值的 version 更小,会被拒绝。
- 优点:无锁,读写不互相阻塞,高并发下吞吐远好于悲观锁。版本号机制顺带解决了并发回填覆盖问题。
- 代价:冲突时要重试。在写竞争激烈的热点 key 上(比如一个爆款库存),版本号会频繁失效,重试次数飙升,反而把性能拖垮,严重时活锁。所以乐观锁适合冲突不频繁的场景。
思路二:分布式事务(冲突激烈时的兜底)
当写竞争激烈、乐观锁重试到怀疑人生时,才考虑用分布式事务把”写 DB + 写缓存”绑成一个原子操作。
最经典的模型是两阶段提交(2PC):引入一个协调者,先让所有参与者(DB、Cache)进入 Prepare 阶段锁定资源、各自回复能否提交,全部 Yes 后协调者再统一下发 Commit,任一参与者拒绝或超时则全体 Rollback。它能拿到跨系统强一致,但代价很重:Prepare 到 Commit 之间所有参与者同步阻塞、锁着资源;协调者一旦在两阶段之间宕机,参与者会一直锁着资源傻等;而且 Redis 原生并不支持 2PC,落地往往要引入额外框架。所以 2PC 在缓存一致性这种高频写场景里几乎不直接用,实际工程更常用下面两种柔性事务。
TCC:把事务拆成 Try-Confirm-Cancel 三段
TCC(Try-Confirm-Cancel)不锁资源,而是要求每个参与者自己实现三个接口,由业务代码补偿来保证最终原子。
1 | Try ── 预留资源(不真正生效)。如:DB 把余额冻结,Cache 写入"待生效"标记 |
- 优点:不像 2PC 那样长时间锁定资源,Try 之后资源就释放了,并发度高;一致性强(靠 Confirm/Cancel 补偿达成最终原子)。
- 代价:侵入性极强。每个操作都要写 Try / Confirm / Cancel 三套逻辑,开发量翻三倍;还要自己保证三个接口的幂等(Confirm/Cancel 可能因重试被调多次)和空回滚、悬挂等边界问题。复杂度很高。
SAGA:一串本地事务 + 反向补偿
SAGA 把一个大事务拆成一连串有序的本地事务,每一步都立即提交(不预留、不锁定)。一旦某步失败,就按相反顺序依次调用前面每一步的补偿操作回滚。
1 | 正向: T1 ──▶ T2 ──▶ T3 ──▶ T4 |
- 优点:每步都是独立的本地事务、立即提交不锁资源,适合长流程、跨多服务的业务(如下单 → 扣库存 → 扣余额 → 更新缓存)。
- 代价:没有隔离性——T1 提交后、整个 SAGA 完成前,中间状态对外是可见的,可能读到”半成品”数据(需业务容忍或加业务锁)。补偿逻辑同样要保证幂等,且并非所有操作都能完美补偿(比如已经发出去的短信收不回来)。
一句话区分:TCC 靠”预留 + 确认/取消”,隔离性好但侵入强、适合短事务;SAGA 靠”直接提交 + 逆向补偿”,无隔离但轻量,适合长流程。工程上常借助 Seata 这类框架(同时支持 AT/TCC/SAGA 模式)来落地,避免手写协调逻辑。
核心思想:强一致的优先级是「先乐观锁、再分布式事务」。乐观锁靠版本号校验实现无锁强一致,是冲突不激烈时的首选;只有当写冲突极其频繁、乐观锁重试代价过高时,才退到 TCC / SAGA 这类柔性事务(2PC 因强阻塞基本不直接用)。记住一个原则:强一致是用性能和复杂度换来的,能用最终一致解决的,就别上强一致。
四、如何选:一致性决策树
1 | 这份数据,短暂不一致能接受吗? |
一个经验法则:默认走最终一致(binlog + TTL),只有当业务方明确说”这个数据错一秒都不行”时,才往强一致挪,且优先乐观锁。
这其实和秒杀系统那篇的取舍一脉相承——秒杀场景下,我们用 “Redis + Lua 预扣 + MQ 异步落库” 主动选择了 AP(最终一致),容忍 Redis 与 MySQL 的短暂不一致,靠异步同步兜底。本质上,秒杀就是”最终一致”在高并发场景下的一个具体实战案例。
五、总结与权衡
绕了一圈,把这篇的骨架收一下:
- 不一致的根因只有一个:缓存和 DB 是两个系统,双写非原子;叠加读写时序交错,产生不会自愈的脏数据。
- 三种缓存模式的区别在于”一致性责任放在哪”:Cache-Aside 甩给应用(最常用),Write-Through 收进缓存层(做强一致的基础),Write-Behind 拿数据安全换性能(场景受限)。
- 最终一致:
binlog 订阅是主力(确定性、解耦、可重试),TTL必加做兜底,延迟双删是经验型补充——能降低概率但给不了确定性保证,那个sleep(N)的值本质是在赌。 - 强一致:优先
乐观锁(版本号 CAS,无锁,冲突时重试),冲突激烈才退到2PC等重量级分布式事务,代价是阻塞、单点和复杂度。
| 方案 | 一致性级别 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| TTL 兜底 | 最终一致(弱) | 高 | 极低 | 任何方案的底线,配合使用 |
| 延迟双删 | 最终一致(概率) | 高 | 低 | 补充手段,非核心依赖 |
| binlog 订阅 | 最终一致(强) | 高 | 中 | 最终一致主力,读多写少 |
| 乐观锁 CAS | 强一致 | 较高 | 中 | 强一致首选,写冲突不频繁 |
| 2PC / 分布式事务 | 强一致 | 低 | 高 | 写冲突激烈的极端场景,慎用 |
最后一句话:一致性不是越强越好,而是越匹配越好。 强一致是用性能和复杂度真金白银换来的,先问清楚业务到底能不能容忍那几百毫秒,再决定往哪个方向走。绝大多数时候,binlog 订阅 + TTL 兜底,就够了。
如果这篇对你有帮助,欢迎留言交流你在生产环境里踩过的缓存一致性的坑。
- 标题: Redis 与数据库缓存一致性:从最终一致到强一致的完整解法
- 作者: Jie
- 创建于 : 2026-06-26 00:00:00
- 更新于 : 2026-06-26 17:24:13
- 链接: https://your-domain.com/cache-consistency/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。