外观
缓存一致性方案对比实测:Cache Aside、延迟双删与订阅Binlog怎么选
「缓存和数据库不一致」大概是后端面试与生产事故之间距离最短的话题。多数文章只教「先更新数据库再删除缓存」,却不解释为什么是这个顺序、什么场景下它仍然会不一致、以及不一致窗口有多大。我们在线上真正需要回答的问题往往是:这个业务能容忍多长时间的不一致?用多少工程复杂度去把窗口压到多小?这篇文章把三大方案放在同一把尺子下量——不一致窗口的成因、量级与成本,帮你在「背课文」和「做决策」之间建立桥梁。
从时序缺陷说起:为什么方案没有完美的
先接受一个事实:在「缓存 + 数据库」两个独立存储之间,不存在零窗口的强一致方案(除非引入分布式锁或事务把读写串行化,代价是吞吐崩塌)。工程问题因此转化为:把不一致窗口压到多小,以及兜底机制是否可靠。造成不一致的根源是读写并发交错,两个经典坏时序:
text
坏时序一:先删缓存,后更数据库(写入方视角)
T1 删除缓存 → T2 读请求未命中回填旧值 → T3 数据库更新完成
结果:缓存里是旧值,数据库是新值,不一致持续到过期时间
坏时序二:先更数据库,后删缓存(概率极小但存在)
T1 读请求未命中 → T2 写请求更新库并删缓存 → T3 读请求回填旧值
前提:读请求在写更新前查库,却在删除后才回填,且回填的库读发生在写提交前时序一必然会坏,所以「先删后更」直接排除;时序二要求读请求的查询先于写提交、回填晚于缓存删除,概率极低但理论存在。三种主流方案的本质区别,就是对这个极小概率窗口的不同处理策略。
方案一:Cache Aside——先更库,后删缓存
这是 90% 场景的正确答案:
java
// 更新:先持久化,再失效缓存(是删除,不是更新)
@Transactional
public void updatePrice(String skuId, BigDecimal price) {
priceMapper.update(skuId, price);
// 删除而不是更新:避免并发写导致的缓存脏数据互相覆盖,也让冷数据不常驻
redis.del("cache:price:" + skuId);
}
// 读取:未命中查库回填,必须设置过期时间作为最后防线
public BigDecimal getPrice(String skuId) {
String cached = redis.get("cache:price:" + skuId);
if (cached != null) {
return parse(cached);
}
BigDecimal price = priceMapper.select(skuId);
redis.setex("cache:price:" + skuId, 600, toJson(price)); // 过期时间是兜底
return price;
}三个反直觉的要点:第一,失效而非更新缓存——并发写时「更新缓存」会产生后写覆盖先写的脏数据,删除则把回填交给下一次读,天然幂等;第二,过期时间不可省,它是所有方案失效时的最后防线,窗口上界就是过期时长;第三,删除失败必须处理,否则退化成时序一,处理方式见方案三。Cache Aside 残留的不一致窗口是「删除成功前的极短间隙 + 极小概率的坏时序二」,对绝大多数业务可接受。
读写穿透场景还有一个加剧因素:缓存过期瞬间的「击穿回填」会放大不一致的概率——同一热点 key 过期时成百个请求同时查库回填,若恰逢一次写入,回填旧值的概率窗口比单请求场景大得多。标准做法是给回填动作加互斥(singleflight 或分布式锁,只放一个请求查库、其余等待结果),既保护数据库,又把回填时序收敛为单点,间接缩小坏时序二的发生面。更极端的热点 key 可以用逻辑过期:物理上不设过期,值里带逻辑过期时间,发现过期后由异步任务重建,读请求永远命中缓存,代价是短暂接受旧值,窗口由重建速度决定。
方案二:延迟双删——给「先删后更」找补的特例
延迟双删针对的是读多写少且读时回填快的场景:写操作先删一次缓存,更新数据库后睡一小段时间再删第二次,把坏时序一回填的旧值清掉:
java
public void updateStockDelayedDoubleDelete(String skuId, int delta) {
redis.del("cache:stock:" + skuId); // 第一次删除
stockMapper.applyDelta(skuId, delta); // 更新数据库
// 第二次删除交给延迟任务:睡眠时长要覆盖「一次读回填的耗时」
delayedTaskScheduler.schedule(() -> redis.del("cache:stock:" + skuId),
Duration.ofMillis(500));
}它的两个硬伤决定了它只适合特定场景:其一,延迟时长是玄学——短了覆盖不住回填,长了放大不一致窗口,且只能按经验估;其二,第一次删除与第二次删除之间的读请求全部打到数据库,写 QPS 高时是隐形的读放大。结论:读写冲突不激烈的业务直接用方案一即可,不要为「看起来更稳」引入双删的复杂度。
方案三:订阅 Binlog——把删除交给变更事件
「删除缓存」这个动作失败怎么办?重试多少次?这是 Cache Aside 在生产上的真正软肋。基于 Canal 订阅 MySQL Binlog 的方案,把缓存失效从业务代码里剥离,交给独立链路:
text
架构链路:
业务代码 → 只写数据库(业务代码不再操作缓存失效)
MySQL Binlog → Canal Server(伪装为 MySQL 从库解析变更事件)
Canal → MQ(按表/topic 分发,有序投递同一条记录的变更)
消费者 → 删除缓存(失败无限重试,MQ 保证至少一次)properties
# canal instance 核心配置要点(conf/example/instance.properties)
canal.instance.master.address=127.0.0.1:3306
canal.instance.filter.regex=mall\\.product$ # 只订阅需要的表,降低噪音
canal.mq.servers=127.0.0.1:9092
canal.mq.partitionHash=.*\\\\..*:$pk$ # 按主键哈希:同一行的变更进同一分区保序这个方案的杀手级特性是可靠性与解耦:删除动作由 MQ 重投保证最终执行,业务代码零侵入,消费端还能按主键分区内保序处理同一行的连续变更。代价是一套 Canal + MQ 链路的运维成本,以及binlog 到消费的秒级延迟。它适合缓存删除频繁失败不可容忍、或写入口多无法统一收口的系统。还有一条务实路线:业务代码同步删除为主(低延迟),Canal 链路作为失败兜底与异构同步通道,两者取长。
选型决策路径与避坑清单
按一致性容忍度选型的决策顺序:
- 能接受「最多过期时长的不一致」→ 纯 Cache Aside + 过期时间,不加任何复杂度。
- 删除失败不可容忍、写入口分散 → Canal 订阅 Binlog 的异步失效链路。
- 读写冲突极端激烈、回填飞快 → 才考虑延迟双删,且延迟值用压测数据标定。
避坑五条:
- 永远不要「先删缓存后更数据库」,坏时序一必然产生脏数据且无自愈手段(除双删外)。
- 缓存必须设置过期时间,它是所有方案的最后防线;热点数据的过期时间可加随机抖动防雪崩。
- 删除失败不能只打日志:同步重试 + MQ 异步重投或 Canal 兜底,二选一必须落一个。
- Binlog 消费端按主键哈希保序,乱序消费会把新值删成旧值的回填,制造新的不一致。
- 上线前用「写后读一致性」断言做验证:并发下写库后立即读,统计读到旧值的比例与时长,用数据确定方案是否达标。
缓存一致性的工程真相是:先明确业务容忍窗口,再选压窗口的手段,最后永远留一条兜底防线。方案没有最好,只有与业务容忍度和团队运维能力最匹配的。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。