主题
第二十二章:缓存一致性策略
Q68:什么是缓存一致性?为什么「先删缓存再更新 DB」和「先更新 DB 再删缓存」都有坑?
答:
缓存一致性,指的是当数据库(DB)里的数据发生变化后,缓存里的数据能尽快跟上,避免用户读到旧数据。这个看似简单的问题,是「缓存 + 数据库」双写场景里最容易出错的地方。
一、四个基本更新策略
| 策略 | 描述 | 一致性 | 风险 |
|---|---|---|---|
| Cache-Aside(旁路缓存) | 应用自己负责读写缓存和 DB | 较好,最常用 | 双写操作顺序处理不当会产生不一致 |
| Read-Through | 读时缓存未命中,由缓存层自己加载 DB | 好 | 需要缓存层支持加载逻辑 |
| Write-Through | 写时同时写缓存和 DB | 好,但写放大 | 每次都写缓存,写多读少有额外开销 |
| Write-Behind | 写时只先写缓存,异步再刷 DB | 差(可能丢数据) | 缓存刷DB前崩溃会丢数据 |
其中 Cache-Aside(旁路缓存)是最常见、也最需要理解一致性的模式。它的读流程是:
text
读:查缓存 → 命中则返回;未命中则查 DB → 回填缓存
写:更新 DB → 删除缓存(或更新缓存)问题就出在「写」这一步:更新 DB 和删除缓存是两个独立操作,中间任何故障或并发,都可能导致缓存和 DB 不一致。
二、「先删缓存,再更新 DB」的坑
text
时序:
1. 请求 A 先删除缓存
2. 请求 B 读:缓存未命中 → 查 DB(读到旧数据)
3. 请求 A 更新 DB(写入新数据)
4. 请求 B 把「旧数据」回填到缓存
结果:缓存里是旧数据,DB 里是新数据 → 不一致,且长期存在坑:删缓存后、更新 DB 前的窗口期,如果有读请求进来,会把 DB 的旧值读出来回填缓存,导致缓存放了脏数据,而且没人再删它。
三、「先更新 DB,再删缓存」的坑
text
时序:
1. 请求 A 读:缓存未命中 → 查 DB(读到旧数据)
2. 请求 B 更新 DB(写入新数据)
3. 请求 B 删除缓存
4. 请求 A 把「旧数据」回填缓存
结果:缓存里是旧数据,DB 里是新数据 → 不一致坑:读请求在「查 DB」之后、「回填缓存」之前,如果写请求更新了 DB 并删除了缓存,读请求仍会把读到的旧值回填,覆盖掉新数据。
共性:两种顺序的坑,本质都是「读请求回填旧值」这个动作,与「写请求删除/更新」之间发生了竞态。核心难点是:回填缓存这个动作,无法保证回填的一定是最新值。
Q69:为什么业界推荐「Cache-Aside + 延迟双删」?它的原理是什么?还有更彻底的方案吗?
答:
既然「先更新 DB,再删缓存」的问题是「读请求可能把旧值回填」,那么一个简单有效的解法是:删两次——更新 DB 前后各删一次,第二次延迟一点删,覆盖掉并发读请求回填的窗口。
一、延迟双删的原理
text
1. 先删除缓存
2. 更新 DB
3. 延迟一小段时间(如 500ms)后,再删除一次缓存text
时序:
1. 请求 A 删缓存
2. 请求 B 读:未命中 → 查 DB(旧值)
3. 请求 A 更新 DB(新值)
4. 请求 B 回填缓存(旧值) ← 此时缓存又脏了
5. 请求 A 延迟后,再次删缓存 ← 把第 4 步的旧值删掉,保证最终一致第二次「延迟删除」就是专门针对「并发读请求回填旧值」这个窗口的:等读请求把旧值回填后,再删一次,把脏数据清掉。
二、延迟双删为什么能解决大部分问题
- 第一次删:尽快让缓存失效,减少读到旧值的概率;
- 延迟第二次删:覆盖「更新 DB 期间」并发读请求回填旧值的窗口;
- 结合「更新 DB 后删缓存」:即使延迟删期间又有读请求,最终也以 DB 为准。
虽然延迟双删不能 100% 保证强一致(延迟时间难精确、极端并发仍可能漏网),但对绝大多数业务,它能把不一致的概率降到可接受范围,配合缓存过期可以保证「最终一致」。
三、延迟双删的实现
python
def update_with_double_delete(key: str, new_value):
redis_client.delete(f"cache:{key}") # 第一次删缓存
update_db(key, new_value) # 更新 DB
# 延迟后第二次删缓存
threading.Timer(0.5, lambda: redis_client.delete(f"cache:{key}")).start()注意:延迟删要放在异步线程 / 消息队列里执行,不能阻塞主流程;延迟时间要略大于「读请求查 DB + 回填缓存」的耗时。
四、更彻底的方案
设置过期时间(兜底):给缓存设 TTL,即使出现极端不一致,过期后自动失效重建,保证最终一致。这是最简单也最该做的事。
pythonredis_client.set(f"cache:{key}", data, ex=300) # 5 分钟过期兜底串行化(同一 key 读写串行):对同一 key 的「更新 DB」和「回填缓存」用锁或队列串行化,消除并发竞态。但这会降低并发,成本高。
Canal + Binlog 异步删除缓存:监听 MySQL 的 Binlog,数据变更后由订阅方(如 Canal)异步删缓存。优点是「删缓存」和「DB 变更」解耦、可靠、最终一致,缺点是引入额外组件。
textDB 变更 ──► Binlog ──► Canal 监听 ──► 异步删除 Redis 缓存读写串行 + 版本号 / 时间戳:给数据加版本号,回填缓存时只允许「更新版本」写入,旧版本的回填被丢弃。实现复杂,一般用于强一致场景。
五、不同方案的取舍
| 方案 | 一致性 | 复杂度 | 场景 |
|---|---|---|---|
| 先更 DB 再删缓存 | 弱 | 低 | 已不推荐单独用 |
| 延迟双删 | 较强 | 中 | 最常用,配合 TTL 达到可用的一致性 |
| 设置 TTL 兜底 | 最终一致 | 低 | 必做项,叠加在上面任何方案上 |
| 同一 key 串行化 | 强 | 高 | 强一致、低并发场景 |
| Canal + Binlog | 强 | 高 | 数据一致性要求高、可接受额外组件 |
六、实用结论
- 默认用 Cache-Aside + 先更 DB 再删缓存(或延迟双删)+ 缓存过期 TTL;
- TTL 是必须的,是最终一致性的最后兜底;
- 强一致场景再考虑 Canal / Binlog 或串行化,不要一上来就上重方案。
Q70:缓存和数据库一致性,有哪些常见错误认知?如何在实际业务中做取舍?
答:
缓存一致性是「理论与实践差距」最大的领域之一,很多人在此踩坑,根源在于对「一致性」的期望不切实际。
一、常见错误认知
| 错误认知 | 正确理解 |
|---|---|
| 缓存和 DB 能 100% 强一致 | 绝大多数场景做不到也不需要强一致,追求最终一致即可 |
| 延迟双删是银弹,包治百病 | 延迟双删只是降低概率,兜底还得靠 TTL |
| 缓存不过期就永远是一致的 | 缓存过期只是为了兜底,不代表过期前不会是脏数据 |
| 所有数据都该缓存 | 写多读少、实时性强的数据缓存意义不大,反而增加一致性负担 |
| 一致性 = 数据永远正确 | 比「永远正确」更实际的是「能容忍多久的不一致 + 最终能不能纠正」 |
| 一致性越强越好,宁可牺牲性能 | 应该按业务容忍度选方案,强一致代价很高(可用性、性能下降) |
二、关键认知:一致性是「概率 + 时间」问题
一致性不是一个「要么有、要么没有」的开关,而是「多大概率、多长时间内会不一致」。衡量时要问三个问题:
- 能容忍多久的不一致?(毫秒?秒?分钟?)
- 不一致会造成什么后果?(无所谓?轻微?资金损失?)
- 最终能不能自动纠正?(靠 TTL 过期重建,还是需要人工介入?)
三、按业务场景做取舍
| 场景类型 | 一致性要求 | 推荐方案 |
|---|---|---|
| 商品详情、文章、用户资料 | 最终一致,秒级可接受 | Cache-Aside + 先更 DB 删缓存 + TTL |
| 库存、金额、账户 | 尽量强,不能算错 | 尽量不缓存,或缓存只做展示 + 强一致兜底 + 快速失效 |
| 排行榜、点赞数 | 允许短暂不一致 | 直接操作 Redis,异步刷 DB,接受误差 |
| 配置、热点数据 | 可较长不一致 | 长 TTL + 主动刷新 + 发布订阅通知失效 |
| 秒杀、库存扣减 | 强一致 | 核心扣减走 Lua/DB 串行,缓存只做预热和挡流量 |
四、实用落地原则
- 读多写少才缓存:写多读少的数据缓存得不偿失;
- 优先保证「不返回错误数据」而非「永远最新」:金额、库存类要谨慎缓存或快速失效;
- TTL 是底线:任何缓存策略都必须配合过期时间,保证最终能自愈;
- 能容忍读旧值:大多数读多场景,用户读到一个「慢了半拍」的旧值是可接受的;
- 强一致场景少用缓存:资金、库存等关键数据,要么不缓存,要么用更重的一致性方案(串行化、Canal、版本号),不要指望简单的删缓存解决问题;
- 可观测:对缓存命中率、DB 压力、不一致告警做监控,问题发生时能发现。
五、小结
- 一致性的本质是「概率 + 时间」,不是绝对;
- 通用方案:Cache-Aside + 先更 DB 再删缓存(或延迟双删)+ TTL 兜底;
- 按业务容忍度选方案:能容忍读旧值就用轻方案,资金/库存类要重方案或干脆不缓存;
- 永远设置 TTL,永远做监控。