缓存与数据库一致性,是每一个引入 Redis 做加速的系统迟早要面对的难题。在读多写少的业务里,我们用缓存挡在数据库前面扛流量;但只要发生写操作,缓存与数据库双写之间就会出现时间差,旧值就可能被后续的读请求回填,造成用户看到过期数据。本文从最常见的不一致场景讲起,对比「先更新库再删缓存」「延迟双删」「订阅 binlog 异步更新」三种方案,并复盘生产环境真正踩过的坑与选型建议。
一、不一致是怎么发生的
设想一个经典链路:读请求先查缓存,未命中再查数据库并回写缓存;写请求更新数据库。如果写请求「先删缓存、再更新库」,那么在删缓存之后、库更新完成之前,恰好进来一个读请求,它会从库里读到旧值并写回缓存——于是缓存里长期留着脏数据。反过来,如果「先更新库、再删缓存」,在库更新后、缓存删除前的极短窗口里,读请求也可能拿到旧缓存。这两种顺序都不是银弹,差别只在于脏数据的存活时间。
// 典型读流程(伪代码)
String get(String key) {
String v = redis.get(key);
if (v != null) return v; // 命中缓存
v = db.query(key); // 回源数据库
redis.setex(key, 300, v); // 回填缓存
return v;
}
真正制造不一致的不是「双写」这个动作,而是「两个存储系统的更新不是原子的」。只要中间有并发读或网络抖动,就可能出现时序错乱。理解了这一点,后面所有方案都是在用不同的代价去压缩这个不一致窗口。
二、方案一:Cache-Aside(先更新数据库,再删缓存)
这是最常用、也是工程上最推荐的基础方案:写请求先改数据库,成功后再删除缓存(而不是更新缓存)。下次读请求发现缓存缺失,自然会回源拿到最新值并重新回填。之所以「删」而不是「更新」缓存,是为了避免并发写导致的缓存覆盖错乱,也让缓存的写入天然收敛到读路径。
// Cache-Aside 写路径(Java 示例)
@Transactional
public void updateOrder(long id, Order o) {
orderMapper.updateById(o); // 1. 先更新数据库
redis.del("order:" + id); // 2. 再删除缓存
}
public Order getOrder(long id) {
String key = "order:" + id;
String json = redis.get(key);
if (json != null) return JSON.parse(json);
Order o = orderMapper.selectById(id); // 缓存未命中回源
redis.setex(key, 300, JSON.toStr(o));
return o;
}
它的优点是简单、对读多写少场景足够。但它不是强一致:在「更新库成功」到「删除缓存成功」之间有一个微小窗口,且如果删缓存这一步失败了,脏数据会一直留着。关于缓存放慢查询、过期策略怎么设,可参考 Redis 缓存设计:穿透、击穿、雪崩与最佳实践 与 Redis 缓存三大问题:穿透击穿雪崩防护实战。
三、方案二:延迟双删
延迟双删是对 Cache-Aside 的加固:在更新数据库前后各删一次缓存,第二次删除「延迟」一小段时间(通常 500ms~1s,取业务读耗时上限)。它的意图是覆盖「删缓存后、写库完成前」那个窗口里可能回填的旧值——等读请求把旧值写回后,我们再补一刀删掉它。
// 延迟双删(Java,配合异步线程避免阻塞主流程)
public void updateWithDelayDelete(long id, Order o) {
redis.del("order:" + id); // 第一次删:写库前
orderMapper.updateById(o); // 更新数据库
// 第二次删:延迟执行,覆盖回填的旧值
scheduler.schedule(() -> redis.del("order:" + id), 800, TimeUnit.MILLISECONDS);
}
延迟时间怎么定?经验值是「读接口 99 分位耗时 + 网络抖动余量」。太短覆盖不住,太长则不一致窗口被人为拉长。它仍不保证强一致,只是把脏数据存活时间压到延迟窗口内,属于「最终一致」的折中。
| 方案 | 一致性强度 | 实现复杂度 | 适用场景 |
| Cache-Aside | 弱(极小窗口) | 低 | 绝大多数读多写少业务 |
| 延迟双删 | 弱→中(压缩窗口) | 低~中 | 对脏读敏感、写频率可控 |
| 订阅 binlog | 最终一致(解耦) | 中~高 | 多服务共享数据、异构存储 |
四、方案三:订阅 binlog 异步更新(Canal / Debezium)
当数据变更的来源变多(多个服务都写同一张表),靠业务代码里「顺手删缓存」会越来越不可靠。更彻底的做法是把缓存更新从写路径里剥离出来:用 Canal 或 Debezium 订阅 MySQL 的 binlog,把变更事件投递到消息队列,由独立的消费者负责失效或刷新缓存。这样业务代码只管写库,缓存一致性由「数据库变更」这一个事实源驱动。
# Canal 监听 MySQL binlog,将 ROW 变更发到 Kafka
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal_pwd
canal.mq.topic=db_order_change
canal.mq.partitionHash=db_order_change:order:id
// 消费者:收到变更即删除对应缓存(最终一致)
@KafkaListener(topics = "db_order_change")
public void onRowChange(ChangeEvent e) {
if ("order".equals(e.getTable())) {
redis.del("order:" + e.getAfter().get("id"));
}
}
这套架构的缓存更新与写请求完全解耦,也不会因为业务忘了删缓存而脏读。代价是要引入 Canal、Kafka 等组件,且消息有消费延迟——它提供的是最终一致性,不是实时一致。如果你们的服务是容器化部署,可参考 Docker Compose 一键编排:MySQL + Redis + 后端服务 把这套组件快速拉起。
五、生产踩坑实录
1. 删除缓存失败,旧值永久留存
Cache-Aside 最脆弱的一环是「删缓存」。如果 Redis 当时超时或网络抖动,删除没成功,而数据库已是新值,缓存就永远停留在旧值,直到自然过期。解决办法是给删除动作加重试(带退避),或把「删除失败」作为事件丢进延迟队列异步补偿;更稳妥的是走向方案三——由 binlog 保证最终一定会删。
2. 并发读写把旧值回填
经典时序:线程 A 更新库(新值),还没删缓存;线程 B 读缓存未命中,从库读到「旧值」并写回缓存。即使 A 随后删了缓存,B 刚写的旧值已经在里面了。延迟双删正是为此存在,但若延迟时间设得比读耗时还短仍会漏。本质是:只要「读回源」和「删缓存」存在竞争,就需要用延迟或 binlog 兜底。
3. 大 key / 热 key 放大不一致窗口
一个被高频读的大对象,每次回源都慢,意味着「删缓存→读请求回填旧值」的窗口被拉长,脏数据存活更久。对热 key 应做本地缓存(如 Caffeine)或拆分,减少回源压力;对大 key 控制体积,避免一次回源拖垮一致性。
4. 主从延迟造成的「读从库拿到旧值」
很多系统读请求走从库做读写分离(见 MySQL 主从复制与读写分离:高可用架构实战)。如果写完主库立刻删缓存,但读请求落在「还没同步到新值」的从库,就会把旧值回填。缓解方式是写后强制读主库一小段时间,或依赖 binlog 订阅( Canal 读的正是主库已提交的数据),从根源规避主从延迟。
六、选型建议
绝大多数业务,Cache-Aside + 删除重试就够用,简单且抖动小。当脏读后果严重(如余额、库存),再上延迟双删把窗口压到亚秒级。当多个服务共享同一份数据、或缓存异构存储繁多,直接用 binlog 订阅做最终一致,把一致性交给数据库事实源。记住一条铁律:能删缓存就别更新缓存,让缓存的写入收敛到读路径,可以消除大部分并发覆盖问题。
七、小结
缓存与数据库一致性没有银弹,只有「用多大代价换多小窗口」的权衡。Cache-Aside 是默认答案,延迟双删是加固,订阅 binlog 是解耦终态。真正决定线上是否翻车的,往往不是选了哪个方案,而是有没有为「删缓存失败」「主从延迟」「并发回填」这些边界留好补偿。把缓存失效当成异步事件去设计,系统才扛得住真实流量。




