← 返回题目列表

缓存与数据库如何保证数据一致性?

高频 困难 第 15 / 25 题 更新于 2026/07/28
缓存数据一致性Cache Aside延迟双删

简化版

缓存和数据库是两份数据,更新时无法做到强一致,只能追求最终一致。业界主流是 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 才把它读到的旧值回填缓存 → 缓存留下旧值。

这种情况要求「读的查库」发生在写之前、但「读的回填」发生在写删缓存之后——即读操作跨越了整个写操作。因为读通常比写快得多,这个时序概率极低,但理论上存在。

四、进一步收敛:延迟双删

为了对付上面的极端时序,可用延迟双删

  1. (可选)更新库前先删一次缓存;
  2. 更新数据库;
  3. 延迟一小段时间(如 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 兜底