延迟双删之外:缓存一致性的几种取舍
"先更新数据库还是先更新缓存"能吵一个下午,实际上没有零成本的一致性方案,只有按业务容忍度选的折中。把我们在不同业务上用过的四种方案和它们的坑摆出来。
方案一:Cache Aside,读 miss 回源、写时删缓存。最普及的姿势,但"写 DB 后删缓存"的两步不是原子的:删缓存失败的瞬间,旧数据会在缓存里躺到下次过期。我们的兜底是删除失败进重试队列(发 MQ 延迟重投),顺手把缓存 TTL 控制在分钟级,最坏不一致窗口有上界。
方案二:延迟双删,针对"读写并发时旧值回填"的经典场景:A 改 DB 删缓存,B 在删除前读到 miss、用旧数据回填。解法是写完 DB 后延迟几百毫秒再删一次。实现要讲究:
public void updateProduct(Product p) {
productMapper.update(p); // 1. 更新 DB
redis.delete(key("product", p.getId())); // 2. 第一删
// 3. 延迟二删:清掉并发读回填的旧值
delayQueue.offer(new DelTask(key, 500)); // 500ms 后执行
// 也可以用 MQ 延迟消息,重启不丢任务
}
延迟的时长是玄学部分,要覆盖"一次读请求从 miss 到回填"的完整耗时(DB 慢查询时长加网络往返),我们取读 P99 加 200 毫秒。它的本质是用第二次删除换一致性,代价是延迟期间的脏读窗口依然存在。
方案三:订阅 binlog 异步删(Canal 加 MQ),把"删缓存"从业务代码里剥离,DB 变更后由下游统一处理。好处是业务零侵入、删除失败可重放,是我们主推的方案;代价是链路变长,秒级延迟。
方案四:强一致场景直接放弃缓存自愈,用分布式锁串行化读写,或者把"读缓存"降级为"读 DB 加短 TTL"。库存这类数字敏感的业务,我们就是这么干的——缓存的性能收益让位给正确性。
后来这套组合又进化了一次:binlog 订阅做主通道后,延迟双删降级为兜底(仅订阅链路延迟超阈值时触发),业务代码里的删除逻辑收敛到统一 SDK。一致性方案的演进方向是把复杂性从业务代码搬到基础设施,应用侧只保留幂等和兜底两件事。
选型树总结:允许秒级不一致,binlog 异步删加 TTL 兜底;并发回填频发,叠加延迟双删;要求最终一致且可审计,重试队列必须上;要求强一致,别难为缓存了,它不是干这个的。工程上没有免费的午餐,只有明码标价的取舍。
- 点赞
- 收藏
- 关注作者
评论(0)