面试题:先更新数据库还是先删缓存?Cache-Aside 到底哪里会出错、怎么保证最终一致?
这是「每日一题」专栏的第十一篇。每天一道面试题,后端 + AI 混合路线,从原理到代码一次讲清。上一篇讲了 模型量化,今天按轮换回到后端/分布式方向,聊上一篇预告的题:Cache-Aside 缓存一致性。
题目
缓存面试里出场率最高、也最容易答成八股的一道题:
先更新数据库还是先删缓存?Cache-Aside 模式下,缓存和数据库到底会在哪些时序下不一致?怎么保证最终一致?
这道题考察三件事:懂不懂读写路径的完整时序、能不能说出每种写法的失败窗口、知不知道工程上的兜底手段。很多人能背出「先更新 DB 再删缓存」,但被追问「这个写法自己有没有漏洞」就卡壳。
先给结论,再拆开讲:
- 写路径:先更新数据库,再删缓存(Cache-Aside 标准写法)。删缓存是幂等的,天然规避并发覆盖问题。
- 兜底:延迟双删 → binlog 订阅异步删 + 重试 → 版本号比对,从便宜到贵。
- 底线:缓存必须设置过期时间。缓存不是真相源,只是加速层,允许短暂不一致,但必须保证最终收敛。
一、Cache-Aside 的正常流程
先明确基线。Cache-Aside(旁路缓存)的读写路径:
def read(key):
v = redis.get(key)
if v is not None:
return v # 1. 缓存命中,直接返回
v = db.get(key) # 2. 未命中,查数据库
redis.set(key, v, ex=300) # 3. 回填缓存,带过期时间兜底
return v
def write(key, val):
db.update(key, val) # 1. 先写数据库(真相源)
redis.delete(key) # 2. 再删缓存,下次读时懒加载重建
两个容易忽略的设计点:
- 为什么是「删」而不是「更新」缓存? 删是幂等的——无论并发来几个写请求、谁先谁后,最终缓存都是「空」状态,由下一次读重建;而「更新缓存」会遇到两个写请求互相覆盖、以及「读回填旧值覆盖新值」的错序问题。删缓存把并发时序问题消掉了大半。
- 为什么缓存要带过期时间? 这是最后一道保险。删缓存可能失败、回填可能延迟,但 TTL 一到,脏数据强制失效,系统必然收敛。
二、两种错误姿势的失败窗口
姿势 A:先删缓存,再更新数据库(错误,脏数据长期驻留)
时序:
1. 写请求 W 删除缓存 key
2. 读请求 R 未命中缓存,查库拿到旧值 V1
3. 读请求 R 把 V1 回填进缓存
4. 写请求 W 才更新数据库为 V2
结果:缓存里是 V1(旧值),数据库是 V2。这个脏状态会一直持续到缓存过期——因为回填发生在 DB 更新之前,之后没有任何机制让缓存再次失效。窗口大小是「W 从删缓存到写完 DB 的耗时」,对慢事务可能长达几十毫秒甚至更久,期间所有读都命中脏数据。这是致命错误,面试时一定要先把这个说透。
姿势 B:先更新数据库,再删缓存(正确姿势,但仍有窄窗口)
时序:
1. 读请求 R 未命中缓存,从 DB 读出 V1(此时 DB 还是旧值)
2. 写请求 W 更新 DB 为 V2,随后删缓存
3. 读请求 R 把 V1 回填进缓存
结果:缓存 V1,DB V2,又脏了。窗口是「R 从 DB 读到回填缓存之间的耗时」,只有几毫秒,且必须同时满足「R 恰好缓存未命中」和「R 恰好在 W 删缓存之前完成回填」两个条件——概率低、窗口短,比姿势 A 轻得多。但面试官追到这里,就是要看你能不能说出这个残余窗口,以及怎么消灭它。
并发双写:为什么「删」比「更新」安全
两个写请求并发时,如果采用「更新缓存」:
W1 更新 DB → V1,更新缓存 → V1
W2 更新 DB → V2,更新缓存 → V2 (W2 后完成,缓存 = V2,碰巧对了)
但网络时序可能反过来让 W1 的缓存更新发生在 W2 之后,缓存最终 = V1,而 DB = V2。更新操作有「最后写入者胜」的竞态;删除操作没有——删了就是删了,无论交错多少次,最终状态都是空。这就是标准答案坚持「删缓存」的根本原因。
三、兜底方案一:延迟双删
针对姿势 B 的残余窗口,最朴素的解法:
def write(key, val):
db.update(key, val)
redis.delete(key) # 第一次删
time.sleep(1) # 等读回填的典型耗时过去
redis.delete(key) # 第二次删,清掉回填的旧值
把「读 miss → 查库 → 回填」这个周期(毫秒级)睡过去,再删一次,脏数据就被清掉了。生产上一般不真 sleep,而是丢到 MQ 延迟队列异步执行,避免阻塞写路径。
缺点也明显:sleep 时长是拍脑袋的,慢查询/慢回填可能超过预估;多一次删除的 RT 和失败概率。所以延迟双删是「够用但粗糙」的过渡方案。
四、兜底方案二:binlog 订阅,把删除从业务代码里摘出去
延迟双删再怎么说还是「业务代码主动删」,删缓存失败(网络抖动、Redis 超时)时依然会漏。工程上更可靠的做法是监听数据库 binlog 异步删缓存:
业务服务 --写DB--> MySQL --binlog--> Canal --推送--> 缓存删除服务 --删--> Redis
要点:
- Canal 伪装成 MySQL 从库订阅 binlog,拿到的是 DB 真实的提交顺序,天然规避了业务代码里的时序错乱。
- 删除失败可重试:binlog 消费有 offset,删除服务把失败的 key 丢重试表/MQ,重启后从断点续传,保证最终删掉。
- 业务代码零侵入:写路径只要更新 DB,一致性由旁路保障,主流程不再依赖 Redis 可用性。
面试加分点:删除服务必须幂等(删不存在的 key 无副作用),且重试不能只靠「try-catch 再删一次」,要落到带持久化的重试队列,否则进程一挂重试就丢了。
五、兜底方案三:版本号比对(最强,但读放大)
要彻底消灭「旧值回填覆盖新值」,可以让回填动作「认版本」:
1. 写请求:DB 更新数据,同时 version 自增
2. 读请求:缓存里存 (value, version)
3. 回填前:拿缓存里的 version 和 DB 当前 version 比对
4. 缓存 version 落后 → 丢弃旧值,重新查库回填
代价是每次读都要多一次 DB 版本查询,读路径被放大,Redis 省下的查询又还回去了。所以生产上更常见的折中是:数据自带 version 字段一起进缓存,回填时用 version 做乐观校验,配合短 TTL 兜底——一致性窗口从「毫秒级残余」进一步压到「几乎为零」,同时读路径只多一次内存级比对。
六、面试回答模板
按这个顺序答,逻辑闭环:
- 先表态:写路径用「先更新 DB、再删缓存」,且必须删而不是更新——删是幂等的,并发下没有最后写入者竞态。
- 再讲坑:先删缓存再更新 DB 会让旧值长期驻留(姿势 A,致命);先更新 DB 再删缓存仍有「读 miss 后回填旧值」的毫秒级窄窗口(姿势 B,残余)。
- 给兜底:延迟双删(便宜,sleep 靠拍脑袋)→ binlog 订阅异步删 + 持久化重试(主流,Canal 方案)→ 版本号比对(最强,读放大)。
- 收尾金句:缓存一致性问题的本质不是「先谁后谁」,而是「缓存删除可能失败、读回填可能延迟」这两个不可控因素;工程上靠幂等删除 + 异步重试 + 过期兜底,把不一致的窗口和概率压到业务可接受的范围——缓存不是真相源,允许短暂陈旧,但必须最终收敛。
明日预告:系统设计方向 ——「设计一个 Feed 流系统(微博/推特时间线):关注关系怎么存、推拉模式怎么选、百万粉丝的大 V 内容怎么下发?」