缓存和数据库的一致性怎么保证?
一句话回答
最常用的是 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),穿透和击穿的处理见 缓存穿透、击穿、雪崩:
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 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。