缓存与数据库如何保证数据一致性?
简化版
缓存和数据库是两份数据,更新时无法做到强一致,只能追求最终一致。业界主流是 Cache Aside(旁路缓存):读时「查缓存→未命中查库→回填缓存」,写时**「先更新数据库,再删除缓存」(而不是更新缓存)。为什么是删而不是更新、为什么先库后删,都有讲究。极端并发下仍可能短暂不一致,可用延迟双删**(更新库后删一次,延迟一会再删一次)或订阅 binlog 异步更新缓存(如 Canal)进一步收敛。
详细版
Cache Aside 标准写法:
// 读
Object read(String key) {
Object v = cache.get(key);
if (v != null) return v;
v = db.query(key);
cache.set(key, v); // 回填
return v;
}
// 写:先更新 DB,再删除缓存
void write(String key, Object v) {
db.update(key, v); // ① 先更新数据库
cache.del(key); // ② 再删除缓存(不是更新)
}
为什么”删除”缓存而非”更新”缓存?
- 更新缓存要额外算最新值,若写多读少则白算(写进去还没被读就又被改)。
- 多个并发写更新缓存易产生「旧值覆盖新值」的乱序。
- 删除是惰性的:删掉后下次读再回填,简单且不易错。
为什么”先更新库、再删缓存”,而不是先删缓存再更新库?
- 先删缓存再更新库:删缓存后、更新库前,若有读请求进来,会把旧值重新载入缓存,之后库更新完,缓存却留着旧值 → 长期不一致。
- 先更新库再删缓存:不一致窗口更短,更安全。
延迟双删:更新库前删一次缓存、更新库后延迟几百毫秒再删一次,清掉期间可能被回填的旧值。
完整版教学
一、根本矛盾:两份数据无法原子更新
缓存(Redis)和数据库(MySQL)是两个独立系统,更新一个数据要改两处。而「改两处」不是一个原子操作——中间任何时刻都可能有并发读写插进来,或某一步失败。所以缓存与数据库的强一致(任何时刻都完全相同)几乎做不到(要做只能上分布式事务,代价巨大且违背用缓存提性能的初衷)。因此现实目标是最终一致:允许短暂不一致,但最终收敛到一致。
二、四种更新组合,为什么选「先更库、再删缓存」
更新时有两个动作(操作 DB、操作缓存)× 两种缓存操作(更新/删除)× 两种顺序,组合起来分析:
先说「更新缓存」为什么不如「删除缓存」:
- 浪费计算:写多读少时,每次写都去算并更新缓存,但缓存可能还没被读就又被下一次写覆盖,白算。
- 并发写乱序:线程 A、B 都要更新缓存,如果 A 先更库、B 后更库,但缓存更新顺序反过来(B 先更缓存、A 后更缓存),缓存里留下的是 A 的旧值 → 不一致。删除就没这问题(删完下次读回填最新的)。
所以主流是删除缓存(Cache Aside)。
再说删除的顺序:「先删缓存再更库」 vs 「先更库再删缓存」:
- 先删缓存,再更新库(不推荐):删缓存后、库还没更新完的空隙里,如果来了个读请求,未命中缓存 → 查库读到旧值 → 回填缓存。然后库更新完成,但缓存里已经是刚回填的旧值,且不会再被删 → 长期不一致。
- 先更新库,再删缓存(推荐,Cache Aside 标准):先把库改成新值,再删缓存。不一致窗口只存在于「更库完成到删缓存完成」这极短时间,且删完后下次读就回填新值。安全得多。
三、先更库再删缓存仍有的极端问题
「先更库、再删缓存」也不是绝对完美,极端并发下有个小概率问题:
读请求 R 未命中缓存,查库读到旧值(此时写请求还没开始);紧接着写请求 W 更新库为新值、删除缓存;然后 R 才把它读到的旧值回填缓存 → 缓存留下旧值。
这种情况要求「读的查库」发生在写之前、但「读的回填」发生在写删缓存之后——即读操作跨越了整个写操作。因为读通常比写快得多,这个时序概率极低,但理论上存在。
四、进一步收敛:延迟双删
为了对付上面的极端时序,可用延迟双删:
- (可选)更新库前先删一次缓存;
- 更新数据库;
- 延迟一小段时间(如 500ms)后,再删一次缓存。
第二次延迟删除的目的:等那些「可能在更库期间读到旧值并回填」的请求都完成后,再删一次,把它们回填的旧值清掉。延迟时间要略大于「一次读操作的耗时」。缺点是延迟删除要额外的异步机制(延迟队列/定时任务),且延迟期间仍是旧值。
五、终极方案:订阅 binlog 异步更新缓存
更工程化的做法是把「删缓存」这件事和业务代码解耦,改由监听数据库 binlog 来驱动:
- 用 Canal(阿里开源)伪装成 MySQL 的从库,订阅 binlog。
- 数据库任何数据变更都会产生 binlog,Canal 解析后发出变更事件。
- 一个消费者收到事件,去删除/更新对应的缓存。
好处:业务代码只管更新数据库,缓存更新由 binlog 异步保证,不会漏删(只要库变了就一定有 binlog),也天然处理了失败重试(消息可重投)。这是大厂常用的缓存一致性方案。代价是引入 Canal + MQ,架构更重,且是异步的(仍是最终一致,有短暂延迟)。
六、常见误区与追问
| 考点 | 正确口径 |
|---|---|
| Cache Aside | 读缓存,未命中查库回填;写库后删除缓存 |
| 不一致来源 | 并发读写、删除失败、主从延迟、事务时序 |
| 常用保障 | 先写库再删缓存、重试删除、延迟双删、binlog 订阅 |
write path:
begin transaction
update database
commit
delete cache key
if delete fails -> retry / MQ / binlog repair
缓存一致性不是追求强一致,而是在性能和一致性之间定义可接受的不一致窗口。
- 误区:更新数据库后再更新缓存一定最好。 并发写下更新缓存容易被旧值覆盖,工程上更常用写库后删缓存。
- 误区:删除缓存成功就永远一致。 并发读可能在写库前回填旧值,删除失败也会留下脏缓存。
- 误区:缓存和数据库能轻松做到强一致。 引入缓存本质上增加副本,强一致成本高,通常做最终一致。
- 追问:为什么通常先写数据库再删缓存? 数据库是权威数据源,提交成功后删除缓存,让后续读回源拿新值。
- 追问:删除缓存失败怎么办? 用重试队列、消息补偿、binlog 监听或定期校验修复。
- 追问:延迟双删解决什么? 降低并发读在写期间回填旧值导致脏缓存的概率。
七、加强记忆
缓存与 DB 一致性只能做到最终一致(两份数据无法原子更新)。主流 Cache Aside:读「查缓存→未命中查库→回填」,写**「先更新数据库,再删除缓存」。删而非更(省计算、避免并发写乱序覆盖)、先库后删而非先删后库(避免删后被读请求用旧值回填导致长期不一致)。极端并发仍有小概率不一致,用延迟双删**(更库后延迟再删一次清掉回填的旧值)或订阅 binlog(Canal)异步删缓存(业务只管更库、缓存更新解耦且不漏)进一步收敛。核心口诀:先更库、再删缓存;要更稳,延迟双删或 binlog 兜底。