数据库与Redis缓存的一致性,是数据库运维和架构设计里的经典难题。读写缓存没有事务保证,缓存更新失败就会让旧数据留在线上。本文对比Cache Aside、延迟双删、binlog订阅(Canal)三种主流方案,给出不同业务场景下的取舍结论和实现细节。
先确认需求:一致性与性能的边界
业务对一致性的要求分两类。容忍秒级不一致:商品浏览数、推荐列表,用Cache Aside加过期时间即可。要求强一致:库存、账户余额、订单状态,必须把缓存更新做成数据库驱动的确定性流程。先把需求分清楚,方案才能对症下药。
方案一:Cache Aside + 过期时间(基础盘)
标准读写流程:读缓存,未命中则读库并回填;写操作先更新数据库,再删除缓存。删除缓存而不是更新缓存,因为更新缓存要和DB操作保持原子性,删除则天然幂等,下次读再回填。注意点:缓存必须设置过期时间作为兜底,防止删除失败导致永久脏读。
// 写:先更新DB,再删缓存
public void update(Goods goods) {
goodsMapper.updateById(goods);
redisTemplate.delete("goods:" + goods.getId()); // 延迟删除见方案二
}
// 读:缓存未命中回源
public Goods get(Long id) {
Goods g = redisTemplate.opsForValue().get("goods:" + id);
if (g == null) {
g = goodsMapper.selectById(id);
redisTemplate.opsForValue().set("goods:" + id, g, 30, TimeUnit.MINUTES);
}
return g;
}
方案二:延迟双删(Cache Aside的补丁)
删缓存与更新数据库之间存在时间窗:请求A读旧值回填缓存,请求B更新库删缓存,A的回填把旧值又写进去了。延迟双删就是在更新库后延迟几百毫秒再删一次缓存,把A回填的脏值清掉。实现要点:延迟删除用异步任务,第二次删除要保证执行成功并做补偿。
// 写路径:更新DB -> 删缓存 -> 延迟再删一次
public void updateWithDelay(Goods goods) {
goodsMapper.updateById(goods);
redisTemplate.delete("goods:" + goods.getId());
// 延迟500ms再删一次,覆盖读回填的脏值
executor.schedule(() -> redisTemplate.delete("goods:" + goods.getId()),
500, TimeUnit.MILLISECONDS);
}
延迟双删能覆盖多数场景,但不是严格正确:延迟窗口内的读取仍可能脏。要求极高的业务不适合。
方案三:binlog订阅(Canal)解耦更新
业务写入MySQL,Canal监听binlog变更,异步把最新数据推送到Redis。缓存更新完全由数据变更驱动,业务代码不需要管缓存,适合读多写少、要求最终一致的场景。关键实现:先改DB,Canal解析后写缓存;写缓存失败要重试或回源。
// Canal监听变更,解析后更新缓存
public void onBinlogChange(BinlogEntry entry) {
if (entry.getTable().equals("t_goods")) {
Long id = entry.getRowId();
// 直接查库拿最新值,或从binlog行数据组装
Goods g = goodsMapper.selectById(id);
redisTemplate.opsForValue().set("goods:" + id, g, 30, TimeUnit.MINUTES);
// 失败重试交给MQS,保证最终一致
}
}
Canal方案引入了消息中间件和消费组件,运维复杂度上升,但对业务侵入最小,适合订单、库存这类强一致业务。
三个方案的选型结论
| Cache Aside+过期 | 秒级 | 低 | 列表页、详情页 |
| 延迟双删 | 毫秒级 | 低 | 中低并发 |
| binlog订阅 | 最终一致 | 中高 | 库存、订单 |
选型要点:并发不高用延迟双删足够;写入频繁且要求最终一致的用Canal;无论哪种方案,缓存过期时间都是最后的防线,不能省。上线前模拟并发读写压测,用数据说话。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-yu-shu-ju-ku-yi-zhi-xing-shi-zhan-yan-chi/