Redis 与数据库缓存一致性:从最终一致到强一致的完整解法

Jie

Redis 与数据库缓存一致性:从最终一致到强一致的完整解法

缓存一致性是那种”人人以为懂、一写就翻车”的题目。本文先讲清为什么会不一致,再对比三种缓存模式的优劣,最后按业务对一致性的要求强弱,从最终一致强一致给出一整套方案,并坦白每种方案的代价。所有方案以语言无关的伪代码呈现,重在思路而非语法。


目录


一、为什么会不一致

一切问题的根源只有一句话:缓存和数据库是两个独立的系统,对它们的两次写入无法保证原子性。

只要你”写了数据库,又动了缓存”,这两步之间就存在一个时间窗口。在这个窗口里,任何并发请求看到的都可能是错的。不一致主要来自两类场景。

1.1 双写问题:第二步失败

1
2
3
4
5
6
7
8
应用                  数据库              缓存
│ │ │
├─ 1. 更新 DB ───────▶│ ✅ 成功 │
│ │ │
├─ 2. 删除 Cache ─────┼─────────────────▶│ ❌ 失败(网络抖动 / Redis 宕机)
│ │ │
▼ ▼ ▼
结果:DB 是新值,Cache 是旧值,且会一直错下去(直到 key 过期)

数据库改成功了,但删缓存这一步失败了。于是缓存里残留着旧值,且没有任何机制纠正它——这是最危险的一类不一致,因为它不会自愈

1.2 并发读写交错:脏数据被”写回”缓存

这一类更隐蔽。即便每一步都成功了,只要时序不巧,缓存照样会被污染。经典场景是**「读请求回填」与「写请求删除」交错**:

1
2
3
4
5
6
7
8
9
10
11
12
13
时间 ──────────────────────────────────────────────────▶

线程 A(读,缓存未命中) 线程 B(写)
│ │
├─ 1. 读 Cache,miss │
├─ 2. 读 DB,拿到旧值 V0 │
│ (此时还没回填) │
│ ├─ 3. 更新 DB 为新值 V1
│ ├─ 4. 删除 Cache
│ ▼
├─ 5. 把旧值 V0 回填进 Cache ←── 灾难发生在这里

结果:DB = V1(新),Cache = V0(旧),不一致,且不会自愈

线程 A 在第 2 步读到了旧值,但它”慢了一拍”,等到第 5 步才回填;而线程 B 在这中间已经更新 DB 并删了缓存。A 的回填把已经过期的旧值重新塞回了缓存。

核心思想:不一致的根因是「双写非原子」+「读写时序交错」。所有解决方案,本质上都是在和这两件事做斗争——要么缩小窗口、要么补偿纠错、要么干脆上锁串行化。


二、三种缓存模式及其优劣

在谈怎么解决之前,得先分清谁来负责缓存和数据库的同步。不同的缓存模式,把这个责任放在了不同的地方。

2.1 Cache-Aside(旁路缓存)

最常用的模式。缓存只是”旁路”,应用代码直接同时操作缓存和数据库,一致性完全由应用自己负责。

1
2
3
4
5
6
7
8
9
10
读:
value = cache.get(key)
if value == null: // 未命中
value = db.query(key)
cache.set(key, value) // 回填
return value

写:
db.update(key, value)
cache.delete(key) // 删除而非更新,下次读时回填

为什么写的时候是删除缓存而不是更新缓存?两个原因:

  • 避免无效计算:缓存值往往是多张表聚合、计算后的结果。每次写都重新算一遍再塞进缓存,但这个 key 可能很久都没人读,白算。删除是惰性的,下次真有人读才回填。
  • 减少并发写覆盖:两个写请求若都去”更新缓存”,缓存里最终留下的可能是先到 DB、后到 Cache 的那个旧值。删除则没有这个问题。

2.2 Read/Write-Through(读写穿透)

应用不直接碰数据库,只和缓存层打交道。缓存层自己负责”穿透”到数据库。

1
2
3
4
5
6
7
读(Read-Through):
应用 → cache.get(key)
└─ 缓存内部:miss 时自己去 db 查,回填后返回,应用无感

写(Write-Through):
应用 → cache.put(key, value)
└─ 缓存内部:同步写 DB,DB 成功后才更新缓存,再返回

关键在于 Write-Through 里写 DB 和写缓存被封装在缓存组件内部,对应用是一次原子调用。一致性责任从应用上移到了缓存层。后面 3.2 讲强一致时,会基于这个模式做文章。

2.3 Write-Behind(写回 / Write-Back)

写操作只更新缓存就立即返回,数据库由缓存层异步批量落库。

1
2
3
4
5
写:
应用 → cache.put(key, value) // 立即返回,不等 DB
└─ 缓存内部:先记到内存/队列,攒一批后异步刷入 DB

缓存 ──(每隔 N 毫秒 / 攒够 M 条)──▶ 批量写 DB

性能最高(写操作几乎不碰磁盘),但最危险:缓存节点在数据落库前宕机,这批数据就永久丢失了。适合能容忍丢数据的场景(如计数器、点赞数),绝不能用于订单、余额这类数据。

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
2
3
4
5
延迟双删:
cache.delete(key) // 第一次删:清掉当前缓存
db.update(key, value) // 更新数据库
sleep(N 毫秒) // ← 关键、也是最难的一步
cache.delete(key) // 第二次删:清掉「这段时间里被旧值回填」的脏数据

为什么第二次删能解决问题?回顾 1.2 的时序:线程 A 读到旧值后”慢半拍”才回填。只要第二次删除发生在 A 回填之后,就能把这个旧值清掉,缓存随后由新的读请求回填为正确值。

但这里有个绕不开的硬伤——那个 sleep(N) 到底设多久?

1
2
3
4
5
6
7
         A 回填旧值

写 DB ───────┼──────────────▶ 第二次删(必须晚于 A 回填才有效)
│ ▲
│ │
这段间隔 = ? 设短了删早了,旧值回填在删之后,白删
设长了,这段时间一直脏,且占用资源

N 必须大于”一次缓存未命中读请求的完整耗时”(读 DB + 回填的时间),可现实中这个耗时是抖动的——平时 5ms,GC 或慢查询时可能 500ms。你没法设一个对所有情况都正确的值:

  • 设 50ms:大多数情况够,但慢查询那次的旧值回填发生在 80ms,删除(50ms)已经过去了,照样脏。
  • 设 1s:覆盖面广了,但这 1 秒内缓存一直是空的(被删了),读请求全部击穿到 DB,且若主从同步有延迟,第二次删时从库可能还没同步,删了个寂寞。

客观评价:延迟双删是一个经验型、概率型方案。它确实能在很大程度上降低并发回填脏数据的概率,工程上也确实有人用、有效果。但它不能给出确定性保证——N 的取值本质是赌”读请求会在 N 内完成回填”,这个赌注没有理论上限。它降低了不一致的概率,却没有消除不一致。所以它适合做”锦上添花”,不该作为核心依赖。真正想把同步这件事做扎实,看下面的 binlog 订阅。

③ binlog 订阅:最终一致的主力方案

延迟双删的脆弱,根子在于”删缓存”这个动作是应用主动发起的——应用得记得删、删的时机得对、删失败了还得重试。能不能把”删缓存”这件事,从应用代码里彻底解耦出去?

能。数据库每一次成功的写入,都会落到 binlog(MySQL)/ WAL(PostgreSQL)这种变更日志里。我们让一个独立的服务去订阅这份日志,DB 一旦有数据变更,就由这个服务去删对应的缓存。

1
2
3
4
5
6
                                       ┌──────────────┐
应用 ── 只管写 DB ──▶ MySQL ── binlog ──▶│ Canal / │── 删缓存 ──▶ Redis
│ │ Debezium 等 │
▼ └──────────────┘
binlog 顺序记录 订阅者按 binlog
每一条数据变更 顺序消费、删缓存

(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
2
3
4
5
6
7
8
9
10
带版本号的更新:
1. 读出数据连同版本号: (value, version) = db.query(key)
2. 计算新值
3. 条件更新(CAS):
UPDATE t SET value = newValue, version = version + 1
WHERE key = ? AND version = oldVersion
4. if 影响行数 == 1: // 没人在我之前改过,更新成功
cache.set(key, newValue, version) // 缓存也带上版本号
else: // 版本变了,说明有并发修改
重试整个流程(回到第 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
2
3
Try     ── 预留资源(不真正生效)。如:DB 把余额冻结,Cache 写入"待生效"标记
Confirm ── 全部 Try 成功 → 确认提交,把预留的资源真正生效
Cancel ── 任一 Try 失败 → 释放 Try 阶段预留的资源(业务层"反向操作"补偿)
  • 优点:不像 2PC 那样长时间锁定资源,Try 之后资源就释放了,并发度高;一致性强(靠 Confirm/Cancel 补偿达成最终原子)。
  • 代价侵入性极强。每个操作都要写 Try / Confirm / Cancel 三套逻辑,开发量翻三倍;还要自己保证三个接口的幂等(Confirm/Cancel 可能因重试被调多次)和空回滚、悬挂等边界问题。复杂度很高。
SAGA:一串本地事务 + 反向补偿

SAGA 把一个大事务拆成一连串有序的本地事务,每一步都立即提交(不预留、不锁定)。一旦某步失败,就按相反顺序依次调用前面每一步的补偿操作回滚。

1
2
3
4
正向:  T1 ──▶ T2 ──▶ T3 ──▶ T4
│ │ │ ✗ 失败
▼ ▼ ▼
补偿: C1 ◀── C2 ◀── C3 (逆序补偿已提交的步骤)
  • 优点:每步都是独立的本地事务、立即提交不锁资源,适合长流程、跨多服务的业务(如下单 → 扣库存 → 扣余额 → 更新缓存)。
  • 代价没有隔离性——T1 提交后、整个 SAGA 完成前,中间状态对外是可见的,可能读到”半成品”数据(需业务容忍或加业务锁)。补偿逻辑同样要保证幂等,且并非所有操作都能完美补偿(比如已经发出去的短信收不回来)。

一句话区分:TCC 靠”预留 + 确认/取消”,隔离性好但侵入强、适合短事务;SAGA 靠”直接提交 + 逆向补偿”,无隔离但轻量,适合长流程。工程上常借助 Seata 这类框架(同时支持 AT/TCC/SAGA 模式)来落地,避免手写协调逻辑。

核心思想:强一致的优先级是「先乐观锁、再分布式事务」。乐观锁靠版本号校验实现无锁强一致,是冲突不激烈时的首选;只有当写冲突极其频繁、乐观锁重试代价过高时,才退到 TCC / SAGA 这类柔性事务(2PC 因强阻塞基本不直接用)。记住一个原则:强一致是用性能和复杂度换来的,能用最终一致解决的,就别上强一致。


四、如何选:一致性决策树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
               这份数据,短暂不一致能接受吗?

┌─────────────────┴──────────────────┐
能接受 不能接受
(最终一致) (强一致)
│ │
▼ ▼
binlog 订阅删缓存(主力) 写竞争激烈吗?
+ TTL 兜底(必加) │
+ 延迟双删(可选补充) ┌──────────┴──────────┐
│ 不激烈 激烈
▼ (乐观锁优先) (才考虑重锁)
👉 商品详情、文章、 │ │
用户资料、配置 版本号 CAS 2PC / TCC /
无锁,冲突重试 分布式事务框架
│ │
▼ ▼
👉 余额更新、状态机 👉 极端热点资源
(冲突不频繁) (慎用,代价大)

一个经验法则:默认走最终一致(binlog + TTL),只有当业务方明确说”这个数据错一秒都不行”时,才往强一致挪,且优先乐观锁。

这其实和秒杀系统那篇的取舍一脉相承——秒杀场景下,我们用 “Redis + Lua 预扣 + MQ 异步落库” 主动选择了 AP(最终一致),容忍 Redis 与 MySQL 的短暂不一致,靠异步同步兜底。本质上,秒杀就是”最终一致”在高并发场景下的一个具体实战案例。


五、总结与权衡

绕了一圈,把这篇的骨架收一下:

  1. 不一致的根因只有一个:缓存和 DB 是两个系统,双写非原子;叠加读写时序交错,产生不会自愈的脏数据。
  2. 三种缓存模式的区别在于”一致性责任放在哪”:Cache-Aside 甩给应用(最常用),Write-Through 收进缓存层(做强一致的基础),Write-Behind 拿数据安全换性能(场景受限)。
  3. 最终一致binlog 订阅是主力(确定性、解耦、可重试),TTL必加做兜底,延迟双删是经验型补充——能降低概率但给不了确定性保证,那个 sleep(N) 的值本质是在赌。
  4. 强一致:优先乐观锁(版本号 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 进行许可。
评论