在使用Redis作为MySQL前置缓存的架构里,一个绕不开的问题是如何保证两者数据的一致性。缓存的价值在于扛住高并发读,但一旦数据库更新而缓存没有同步,用户看到的就可能是几秒甚至几分钟前的旧数据,如果恰好是库存、余额这类敏感字段,还会直接引发业务故障。强一致方案要么需要分布式锁串行化所有读写,要么干脆放弃缓存,代价极高,所以绝大多数业务选择的是最终一致性:允许短暂的不一致窗口,但保证在有限时间内数据最终会收敛到正确状态。

两种基本删除策略各自埋着什么坑
最常见的做法是更新数据时同步删除缓存,让下一次读请求触发缓存回源重建。顺序上有两种选择:先删缓存再更新数据库,或者先更新数据库再删缓存。两种方案各有明显缺陷,理解缺陷是后续所有优化方案的基础。
先删缓存、后更新数据库的问题在于并发读写交错。假设线程A执行删除缓存后、更新数据库前,线程B发起读请求,发现缓存未命中便去数据库读到了旧值并写回缓存,随后线程A才把数据库更新为新值。结果就是缓存里长期驻留旧数据,直到key过期。这个不一致窗口在并发高的场景下出现概率并不低。
先更新数据库、后删缓存则避开了大部分问题,因为在正常时序下,读请求即便回源也能拿到已提交的新值。但它依然有两个薄弱点:一是删除缓存这一步可能失败,数据库更新成功而缓存删除超时,脏数据就此固化;二是读写并发的极端交错下,线程B在数据库提交前查到旧值,在缓存删除后才把旧值写回缓存,同样产生不一致,只是发生概率比前者低得多,因为它要求读请求的整个回源过程跨越了数据库更新和缓存删除两步。
延迟双删:用一次异步删除兜底不一致窗口
延迟双删是针对上述问题的经典改进方案。流程是:更新前先删一次缓存,接着更新数据库,然后休眠一小段时间再删第二次缓存。第一次删除防止旧值被继续读取,第二次删除负责清理并发期间可能被回源写回的旧值。伪代码如下:
public void updateProduct(Product product) {
String key = "product:" + product.getId();
// 第一次删除缓存
redis.delete(key);
// 更新数据库
productMapper.update(product);
try {
// 休眠,等待可能存在的并发读请求完成回源写缓存
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 第二次删除,清掉并发期间写入的旧值
redis.delete(key);
}延迟时间是最关键的参数。估算原则是:略大于一次读请求从缓存未命中到写回缓存的完整耗时,包括查询数据库、业务逻辑处理和网络往返。通常线上可以统计回源请求的耗时分布,取P999再留一定余量,几百毫秒是常见取值。太短则并发读还没写回缓存,第二次删除形同虚设;太长则不一致窗口被人为拉长,而且如果同步休眠在请求线程里,还会拖垮接口的响应时间。
因此生产实现一般把第二次删除放到异步线程池或延迟队列里执行,而不是阻塞业务线程。同时要为第二次删除加上失败重试,可以借助消息队列记录删除任务,消费失败自动重投,确保删除动作最终执行成功。延迟双删实现简单、不依赖额外组件,是中小规模系统的首选,但它本质上只能把不一致概率压得很低,无法做到理论上的绝对一致。
订阅binlog异步删除:让数据库变更事件驱动缓存失效
如果对可靠性要求更高,可以让缓存删除动作完全由数据库的变更事件驱动,典型方案是基于Canal。Canal伪装成MySQL的从库,订阅binlog,把数据变更解析成结构化消息推送给下游,下游消费到变更事件后删除对应缓存。整体链路是:业务代码只负责更新数据库,不再直接操作缓存;Canal监听binlog,解析出变更的表和主键,组装成key后调用Redis删除。
@Subscribe
public void onDeleteEvent(CanalEntry.Entry entry) {
// 解析binlog中的变更行,取出主键
for (CanalEntry.RowData rowData : getRowDatas(entry)) {
String id = getPrimaryKeyValue(rowData);
String key = "product:" + id;
try {
redis.delete(key);
} catch (Exception e) {
// 删除失败写入重试队列,避免脏数据固化
retryQueue.offer(key);
}
}
}这个方案的最大优点是业务代码与缓存维护彻底解耦,删除操作不会因为应用重启或网络抖动而丢失,只要binlog存在,事件就能被消费,配合消费位点管理和失败重试,删除动作的可靠性接近百分之百。它的代价是引入了额外的基础组件,Canal的部署、监控、位点管理都是运维成本,链路变长后延迟也会增加,通常在百毫秒级别。
另一种折中做法是业务代码更新数据库后发一条消息到RocketMQ或Kafka,由独立的消费者负责删缓存,失败则依赖消息队列的重试机制。相比Canal方案它少了binlog解析环节,实现更轻,但消息发送本身也可能失败,需要在本地事务表里记录待发送消息,用定时任务补偿,实现起来比看起来复杂。
为什么大多数业务只需要最终一致性
追求强一致意味着每次写操作都要让缓存和数据库在同一时刻完成变更,这需要分布式锁把读写完全串行化,吞吐量会退化为接近直连数据库的水平,缓存的意义不复存在。实际上,绝大多数业务对短暂不一致是可容忍的:商品详情页晚几百毫秒更新、用户昵称晚一秒同步,用户几乎无感知。
真正需要谨慎的是资金、库存这类强敏感场景。如果一致性要求确实很高,可以考虑读取时校验版本号、对热点key加短过期时间兜底,或者干脆对这类数据放弃缓存直读数据库。此外无论如何设计,都应该给缓存key设置合理的TTL作为最后一道防线,即使所有删除机制都失效,脏数据也会在过期后自动消失。综合来看,延迟双删加TTL兜底适合大多数常规业务,订阅binlog方案适合数据规模大、对一致性要求高且有专职运维团队的平台型系统,按业务容忍度选型才是正确的思路。
Redis缓存一致性延迟双删最终一致性修改时间:2026-09-15 14:34:45