2743 字
约 9 分钟
18
S12-X03 · 缓存失效与“改了就更新”:三种更新策略的取舍

缓存最难的部分不是"怎么放进去",而是"数据变了以后怎么让它失效"。绝大多数线上脏数据事故都出自同一个模式:更新数据库成功、删除缓存失败,或者删完缓存又被一个慢查询把旧值写回去了。以下按 Redis 7 与 MySQL 8.0 的常见组合核对。


1. Cache-Aside:两种顺序

顺序 动作 脏数据窗口 适用
先更库,再删缓存 UPDATE db → DEL cache 极小(删除失败或并发送达顺序异常) 默认选择
先删缓存,再更库 DEL cache → UPDATE db 较大(更新前的读会把旧值回填) 只适合写少读多且能容忍
// 先更库再删缓存(Cache-Aside 标准写法)
@Transactional
public void updatePrice(long skuId, BigDecimal price) {
    priceMapper.update(skuId, price);          // 1. 数据库为准
    redis.del("sku:price:" + skuId);           // 2. 删除,不是更新
}

注意 @Transactional 与缓存删除的顺序:删除动作最好放在事务提交之后(TransactionSynchronizationManager.registerSynchronization 或 @TransactionalEventListener(AFTER_COMMIT)),否则事务回滚了,缓存却已经删了,读请求会把旧值重新填回来。


2. 为什么是"删除"而不是"更新"

做法 问题
更新缓存 两个并发写会以不可控顺序写入两个值,可能留下旧值
更新缓存 缓存值常是聚合结果(多表 join、计数),更新代价高于删除
更新缓存 写多读少时,每次写都更新缓存纯属浪费
删除缓存 下次读按需重建,天然幂等,重复删无副作用

一句话:删除是幂等的,更新不是。在并发下,"删除"最多让下一次读多一次回源;"更新"可能永久留下一个错误值。


3. 延迟双删

先删缓存、更库、再延迟删一次,用来覆盖"更新期间有读把旧值回填"的窗口。

public void update(Sku sku) {
    redis.del(key);                     // 第一次删
    db.update(sku);                     // 更库
    executor.schedule(() -> redis.del(key), 500, TimeUnit.MILLISECONDS); // 二次删
}
前提 失效场景
回源读的耗时 < 延迟时间 慢查询超过 500ms,旧值在第二次删之后才回填 → 依旧脏
延迟删除任务可靠执行 进程重启、线程池满、消息丢失 → 第二次删没发生
主从复制延迟被考虑 更库后立刻读从库拿到旧值并回填 → 二次删也救不了

延迟双删是"提高概率"的手段,不是正确性保证。它真正适用的场景是:更新频率低、回源慢、又不想引入 binlog 订阅。


4. Read/Write-Through 与 Write-Behind

模式 写路径 一致性 风险
Cache-Aside 应用自己管库和缓存 最终一致 应用逻辑分散,易漏删
Read-Through 读穿透:缓存未命中时由缓存组件回源 最终一致 回源逻辑集中在 loader
Write-Through 写缓存,缓存组件同步写库 较强 写延迟变高,缓存成为写路径单点
Write-Behind 写缓存即返回,异步批量落库 弱 宕机丢数据,需持久化队列兜底

Write-Behind 的吞吐最高,代价是"缓存里已经是新值、库里还是旧值"的窗口可能长达数秒。金融、订单类业务基本不会选它。


5. 并发时序:删缓存 + 旧值回填

这是 Cache-Aside 唯一真正的漏洞,发生时序如下:

时刻 线程 A(写) 线程 B(读) 缓存 数据库
T1 读缓存 miss 空 旧值 100
T2 查库得到 100 空 旧值 100
T3 更库为 200 空 新值 200
T4 删除缓存 空 新值 200
T5 把 100 写回缓存 100(脏) 新值 200
T6 事务提交 脏值长期存在 新值

触发条件是"B 的读库发生在 T3 之前、写缓存发生在 T4 之后"。窗口长度约等于一次查询耗时,概率不高但必然发生。

缓解手段 效果
更新后延迟再删一次 覆盖大部分窗口,不保证
缓存值带版本号,回填时比对 能挡住旧版本回填
binlog 订阅异步删 窗口缩短到消费延迟,仍非零
读时校验库版本(读多写少才可行) 一致性最好,成本最高
// 版本号防回填:缓存里存 {value, version},回填前比对 DB 版本
if (cached != null && cached.version >= dbVersion) {
    return cached;   // 缓存更新,丢弃这次回填
}

6. 订阅 binlog 异步失效

把"删缓存"从业务代码里摘出来,交给 CDC 组件消费 binlog。业务只管写库,缓存由下游保证最终失效。

# Debezium 的 MySQL connector 关键配置
connector.class: io.debezium.connector.mysql.MySqlConnector
database.hostname: mysql-primary
database.include.list: shop
table.include.list: shop.sku
topic.prefix: cdc
snapshot.mode: schema_only
组件 特点 注意
Canal 阿里系,伪装从库拉 binlog 需要 ROW 格式与 log_slave_updates
Debezium Kafka Connect 生态,事件结构规范 运维重,依赖 Kafka
Maxwell 轻量,输出 JSON 功能相对单一

优点:业务与缓存解耦、漏删概率低、可按表订阅。缺点:引入延迟(毫秒到秒级)、多一套要运维的组件、binlog 保留期必须大于消费滞后。


7. 强一致做不到的根因

环节 为什么破坏强一致
网络分区 更新库成功、删缓存请求丢失,两副本状态不同
主从复制 写主库、读从库,从库滞后期间读到旧值
缓存与库是两个系统 没有跨系统事务,只能"尽力而为 + 补偿"
CAP 分区发生时必须在一致性(C)与可用性(A)二选一

想在分区时保 C,就得让写请求在缓存不可达时失败——这在电商详情页上等于自杀。所以工程上的目标不是强一致,而是把不一致窗口压到可接受,并保证最终收敛。


8. TTL 是最后的兜底

无论删除逻辑多严,TTL 都必须有。它是"所有失效机制都失败时"的唯一保险。

数据类型 建议 TTL 理由
商品详情 5–30 分钟 允许短时陈旧
库存 1–5 秒 + 版本校验 陈旧代价高
用户会话 30 分钟(滑动) 与登录态绑定
配置/字典 10 分钟 + 主动刷新 变更需秒级生效则配广播
计数/排行 1 分钟 可重算,允许抖动

TTL 加随机抖动(ttl = base + random(0, base*0.1))能避免同一批 key 同时过期造成雪崩。


9. 击穿、穿透、雪崩与更新策略的关系

问题 触发 与失效策略的关系 解法
击穿 单个热点 key 过期,瞬间大量回源 TTL 集中 + 删除后立即回源 逻辑过期 / 互斥重建 / 单飞
穿透 查不存在的数据,每次都打到库 删除策略无关 空值缓存 / 布隆过滤器
雪崩 大量 key 同时失效或缓存整体宕机 TTL 未抖动、集群故障 抖动 TTL / 多级缓存 / 限流降级
// 单飞:同一 key 只允许一个线程回源,其余等待
public String load(String key) {
    String v = redis.get(key);
    if (v != null) return v;
    String lockKey = "lock:" + key;
    if (redis.set(lockKey, "1", SetParams.setParams().nx().ex(10))) {
        try {
            v = db.query(key);
            redis.set(key, v, SetParams.setParams().ex(jitter(600)));
            return v;
        } finally {
            redis.del(lockKey);
        }
    }
    sleep(50);
    return redis.get(key);   // 等重建完成后再读
}

注意"逻辑过期"与"物理 TTL"的区别:逻辑过期把过期时间放在 value 里,key 本身不过期,重建时异步进行,读请求永远不阻塞——代价是必然读到一次旧值。


10. 排错:五种脏数据现场

现象 常见原因 处理
改了库,页面还是旧值 删除失败被吞异常 删除失败必须重试或落补偿队列
偶发旧值,重放后又好了 旧值回填(第 5 节时序) 版本号校验 + 延迟双删
主库新、从库旧,缓存跟着旧 回源读从库 强一致读走主库,或缓存写入带版本
大促期间缓存与库差很多 写多读少,删除风暴 改为写时只删一次 + 异步重建
缓存 key 越来越多 无 TTL 或 TTL 过长 全量 key 必须有 TTL 上限

排查顺序永远是:先看缓存里到底是不是旧值(GET 一下)→ 再看删除有没有执行(慢日志/审计)→ 最后看时序(是否被回填)。


11. 边界:什么时候别用缓存

场景 更合适的做法 理由
强一致账务/余额 直读库 + 行锁/乐观锁 缓存一致性成本高于收益
写多读少 只做本地小缓存或不做 每次写都要失效,命中率低
结果集巨大 分页 + 按需查 大 value 拖垮 Redis 与网络
需要事务性读 读库 缓存无法参与事务
数据可重算且便宜 不加缓存 缓存本身也是故障源

一句话总结:先更库再删缓存是默认答案;删除优于更新;延迟双删只是概率优化;TTL 是必须存在的最后一道保险;强一致要么不做缓存,要么在读路径上付代价。


12. 自测

  1. 为什么更新缓存不如删除缓存?举一个并发反例。
  2. 延迟双删在什么情况下会失效?
  3. 第 5 节的时序里,最关键的两次跨线程操作是哪两步?
  4. 用 binlog 订阅做失效,延迟从哪来,怎么控制?
  5. 缓存与数据库为什么做不到强一致?用 CAP 解释。
  6. TTL 抖动解决的是击穿、穿透还是雪崩?
S12-X03 · 缓存失效与“改了就更新”:三种更新策略的取舍
https://www.clxhxhhr.top/posts/5673/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。