缓存和数据库的一致性怎么保证?

深入高频场景题约 7 分钟读完

一句话回答

最常用的是 Cache Aside 模式:读时先查缓存,未命中再查数据库并写回缓存;写时先更新数据库,再删除缓存。删除而不是更新缓存,是因为并发写时更新缓存的顺序可能和更新数据库的顺序相反,留下脏数据,而删除是幂等的,下次读取会加载最新值。删除失败、主从延迟这些情况,用重试、延迟双删或订阅 binlog 异步删除兜底,并且始终给缓存设置过期时间。这些方案都只能做到最终一致,要强一致就得加锁让读写串行,牺牲缓存的性能优势。

详细解析

Cache Aside 的读写流程

文本
读:查缓存 ──命中──> 返回
       └──未命中──> 查数据库 ──> 写入缓存(带过期时间)──> 返回

写:更新数据库 ──> 删除缓存

为什么删除缓存,而不是更新缓存

并发写会留下脏数据:

文本
线程 A:更新数据库 price = 100
线程 B:更新数据库 price = 200
线程 B:更新缓存 price = 200
线程 A:更新缓存 price = 100     ← A 的网络慢了一点
结果:数据库是 200,缓存是 100,过期之前一直是错的

两个线程更新数据库的先后由行锁决定,但更新缓存的先后没有任何保证。删除是幂等的,谁先删谁后删结果都一样,下次读取时从数据库加载最新值。

更新缓存可能白费功夫:缓存的值往往是多张表关联、计算出来的,每次写都重新算一遍,而这段时间可能根本没人读。删除相当于懒加载,用到时再计算。

为什么先更新数据库,再删缓存

反过来,先删缓存再更新数据库,很容易出问题:

文本
线程 A(写):删除缓存
线程 B(读):缓存未命中,查数据库,读到旧值
线程 B(读):把旧值写入缓存
线程 A(写):更新数据库
结果:缓存里是旧值,直到过期

A 更新数据库要花一段时间,B 读库再写缓存很可能在这段时间里完成,这个时序很容易出现。

先更新数据库再删缓存,理论上也有问题:

文本
(缓存恰好刚过期)
线程 B(读):缓存未命中,查数据库,读到旧值
线程 A(写):更新数据库,删除缓存
线程 B(读):把旧值写入缓存

这要求 B 的"读库 + 写缓存"跨越 A 的整个"更新库 + 删缓存",而写库通常比读库慢,还要恰好赶上缓存失效,发生的概率小得多。再加上过期时间,即使发生,影响也有上限。

另外,删缓存要放在事务提交之后:如果在事务里先删了缓存,事务还没提交,其他请求从数据库读到的仍是旧值,又会把旧值写回缓存。

删除失败和主从延迟

  • 删除失败:网络抖动导致删除缓存失败,缓存就一直是旧值。可以把删除操作发到消息队列,失败后重试
  • 延迟双删:更新数据库前后各删一次缓存,第二次延迟一段时间再删,清理这期间被读请求写回的旧值。主要用在读走从库的场景:主库更新后从库还没同步,读请求从从库读到旧值写回了缓存。缺点是延迟多久不好确定,只能根据主从延迟和读库写缓存的耗时估一个更长的时间
  • 订阅 binlog:用 Canal 这类工具把自己伪装成 MySQL 的从库,解析 ROW 格式的 binlog(见 MySQL 的三种日志),数据变更后发到消息队列,由消费者删除缓存,失败就重试。业务代码不用关心缓存,删除也更可靠,代价是多维护一套组件,删除会有短暂的延迟
文本
业务服务 ──写──> MySQL ──binlog──> Canal ──> 消息队列 ──> 消费者:删除缓存(失败重试)

只能做到最终一致

上面这些方案都是让缓存尽快和数据库一致,中间总有一个短暂的不一致窗口。真要强一致,读写都得加分布式读写锁,写的时候让读等待,缓存的并发优势就没了。实际做法是按数据分类:

  • 能容忍短暂旧数据的(商品详情、文章、配置):Cache Aside + 过期时间
  • 对一致性要求高的(库存扣减、余额):以数据库为准,缓存只用于展示,关键操作直接读写数据库

代码示例

Node.js(ioredis + mysql2),穿透和击穿的处理见 缓存穿透、击穿、雪崩:

JavaScript
async function getProduct(id) {
  const key = `product:${id}`
  const cached = await redis.get(key)
  if (cached) return JSON.parse(cached)

  const [rows] = await pool.query('SELECT * FROM product WHERE id = ?', [id])
  // 过期时间是最后的兜底:就算出现不一致,最多持续一小时
  if (rows[0]) await redis.set(key, JSON.stringify(rows[0]), 'EX', 3600)
  return rows[0] ?? null
}

async function updatePrice(id, price) {
  await pool.query('UPDATE product SET price = ? WHERE id = ?', [price, id])
  try {
    await redis.del(`product:${id}`) // 数据库更新成功之后再删缓存
  } catch (err) {
    // mq 代表项目里的消息队列客户端,由消费者重试删除
    await mq.publish('cache-delete-retry', { key: `product:${id}` })
  }
}

面试官可能追问

为什么不用 Read/Write Through 或 Write Behind?

Read/Write Through 由缓存层负责读写数据库,应用只和缓存打交道,Redis 本身不提供这个能力,要自己封装一层缓存服务。Write Behind 是只写缓存、再异步批量写数据库,写入性能最好,但缓存宕机会丢数据,一般只用在点赞数、浏览量这类允许少量误差的计数上。Cache Aside 实现简单、以数据库为准,所以最常用。

延迟双删的延迟时间怎么定?

没有标准值,要大于"主从同步延迟 + 一次读库并写缓存的耗时",一般根据监控数据估算后再留些余量。第二次删除要异步执行,比如放进延时队列,不能在处理请求的线程里 sleep。它只是降低了不一致的概率,并不能保证一定一致。

热点 key 被删除后,大量请求同时回源怎么办?

热点数据的缓存一删,下一波请求会同时查数据库,造成缓存击穿。可以在重建缓存时加互斥锁,只让一个请求去查库;或者对少数热点数据改成由订阅 binlog 的消费者更新缓存,而不是删除:只要同一个 key 的变更按 binlog 的顺序消费,就不存在并发写缓存的顺序问题。

易错点

  • "更新数据库后更新缓存"看起来更直接,但并发写时会留下脏数据
  • 先删缓存再更新数据库,出问题的窗口比反过来大得多
  • 删缓存要在数据库事务提交之后
  • 不管用哪种方案,都要给缓存设置过期时间作为最后的兜底

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。