Skip to content

第二十二章:缓存一致性策略 ​

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 + 回填缓存」的耗时。

四、更彻底的方案 ​

  1. 设置过期时间(兜底):给缓存设 TTL,即使出现极端不一致,过期后自动失效重建,保证最终一致。这是最简单也最该做的事。

    python
    redis_client.set(f"cache:{key}", data, ex=300)   # 5 分钟过期兜底
  2. 串行化(同一 key 读写串行):对同一 key 的「更新 DB」和「回填缓存」用锁或队列串行化,消除并发竞态。但这会降低并发,成本高。

  3. Canal + Binlog 异步删除缓存:监听 MySQL 的 Binlog,数据变更后由订阅方(如 Canal)异步删缓存。优点是「删缓存」和「DB 变更」解耦、可靠、最终一致,缺点是引入额外组件。

    text
    DB 变更 ──► Binlog ──► Canal 监听 ──► 异步删除 Redis 缓存
  4. 读写串行 + 版本号 / 时间戳:给数据加版本号,回填缓存时只允许「更新版本」写入,旧版本的回填被丢弃。实现复杂,一般用于强一致场景。

五、不同方案的取舍 ​

方案一致性复杂度场景
先更 DB 再删缓存弱低已不推荐单独用
延迟双删较强中最常用,配合 TTL 达到可用的一致性
设置 TTL 兜底最终一致低必做项,叠加在上面任何方案上
同一 key 串行化强高强一致、低并发场景
Canal + Binlog强高数据一致性要求高、可接受额外组件

六、实用结论 ​

  • 默认用 Cache-Aside + 先更 DB 再删缓存(或延迟双删)+ 缓存过期 TTL;
  • TTL 是必须的,是最终一致性的最后兜底;
  • 强一致场景再考虑 Canal / Binlog 或串行化,不要一上来就上重方案。

Q70:缓存和数据库一致性,有哪些常见错误认知?如何在实际业务中做取舍? ​

答:

缓存一致性是「理论与实践差距」最大的领域之一,很多人在此踩坑,根源在于对「一致性」的期望不切实际。

一、常见错误认知 ​

错误认知正确理解
缓存和 DB 能 100% 强一致绝大多数场景做不到也不需要强一致,追求最终一致即可
延迟双删是银弹,包治百病延迟双删只是降低概率,兜底还得靠 TTL
缓存不过期就永远是一致的缓存过期只是为了兜底,不代表过期前不会是脏数据
所有数据都该缓存写多读少、实时性强的数据缓存意义不大,反而增加一致性负担
一致性 = 数据永远正确比「永远正确」更实际的是「能容忍多久的不一致 + 最终能不能纠正」
一致性越强越好,宁可牺牲性能应该按业务容忍度选方案,强一致代价很高(可用性、性能下降)

二、关键认知:一致性是「概率 + 时间」问题 ​

一致性不是一个「要么有、要么没有」的开关,而是「多大概率、多长时间内会不一致」。衡量时要问三个问题:

  1. 能容忍多久的不一致?(毫秒?秒?分钟?)
  2. 不一致会造成什么后果?(无所谓?轻微?资金损失?)
  3. 最终能不能自动纠正?(靠 TTL 过期重建,还是需要人工介入?)

三、按业务场景做取舍 ​

场景类型一致性要求推荐方案
商品详情、文章、用户资料最终一致,秒级可接受Cache-Aside + 先更 DB 删缓存 + TTL
库存、金额、账户尽量强,不能算错尽量不缓存,或缓存只做展示 + 强一致兜底 + 快速失效
排行榜、点赞数允许短暂不一致直接操作 Redis,异步刷 DB,接受误差
配置、热点数据可较长不一致长 TTL + 主动刷新 + 发布订阅通知失效
秒杀、库存扣减强一致核心扣减走 Lua/DB 串行,缓存只做预热和挡流量

四、实用落地原则 ​

  1. 读多写少才缓存:写多读少的数据缓存得不偿失;
  2. 优先保证「不返回错误数据」而非「永远最新」:金额、库存类要谨慎缓存或快速失效;
  3. TTL 是底线:任何缓存策略都必须配合过期时间,保证最终能自愈;
  4. 能容忍读旧值:大多数读多场景,用户读到一个「慢了半拍」的旧值是可接受的;
  5. 强一致场景少用缓存:资金、库存等关键数据,要么不缓存,要么用更重的一致性方案(串行化、Canal、版本号),不要指望简单的删缓存解决问题;
  6. 可观测:对缓存命中率、DB 压力、不一致告警做监控,问题发生时能发现。

五、小结 ​

  • 一致性的本质是「概率 + 时间」,不是绝对;
  • 通用方案:Cache-Aside + 先更 DB 再删缓存(或延迟双删)+ TTL 兜底;
  • 按业务容忍度选方案:能容忍读旧值就用轻方案,资金/库存类要重方案或干脆不缓存;
  • 永远设置 TTL,永远做监控。