☰
Redis与MySQL数据一致性:告别延时双删,构建高可靠缓存方案
2026/9/26 10:11:11 网站建设 项目流程

前几天一个朋友从大厂二面出来,复盘时第一句话就是:还是那道“如何保证 Redis 和 MySQL 的数据一致性”。他说自己把背熟的“延时双删”完整背了一遍,结果面试官反手三连问:Sleep 定多少毫秒?第二次删失败怎么办?并发读线程把旧值写回缓存怎么办?当场卡住。

这道题在一线互联网面试里几乎是缓存链路必考题。算法题你可以靠运气,但 Redis 和 MySQL 双写一致性基本是每个写业务的开发都得面对的实战题。网上资料很多,翻来覆去就是延时双删,但真正在线上跑过的人都知道,靠一段 sleep 根本兜不住一致性。这篇文章我想把这道题的本质、延时双删的缺陷、高性能高可靠的方案设计、可落地的代码实现,以及实际排障经验完整捋一遍。给准备面试的朋友一条能复述、有细节、经得起追问的回答路径,也给正在写缓存层代码的同学一套能直接上线的思路。

1. 这个问题到底在问什么:先看清一致性窗口长在哪

1.1 不一致是怎么产生的:一段代码引发的旧值回写

我们先用一个最典型的缓存旁路模式(Cache Aside)来分析。读路径一般是:读 Redis 缓存,未命中就去查 MySQL,查到后把结果写回缓存。写路径一般是:先更新 MySQL,再把缓存删掉,等下次读再回填。

看起来没问题,但“先读再回写”和“先写再删”一旦并发,就会出现经典时序:

  1. 线程 A 读缓存未命中,去 MySQL 查数据,得到旧值,比如库存=10。
  2. 线程 B 更新 MySQL,把库存改成 8,之后删除了缓存中的 key。
  3. 线程 A 此时才把旧值 10 写回缓存。
  4. 后续所有读请求都命中缓存,读到库存=10,脏数据持续存在。

很多人以为只有更新缓存才会造成覆盖,其实“删除缓存”也会被读线程的“写回动作”反向覆盖。这就是一致性问题最容易出事的窗口:读线程拿着旧值写回缓存的一瞬间。

除了并发旧值回写,还有另一个常见故障来源:删除缓存本身会失败。比如更新 MySQL 成功了,但 Redis 删除 key 时恰好超时、网络抖动,甚至 Redis 实例不可用,那缓存里旧值就会一直存在。两种问题叠加,才是这道题真正要解决的完整画面。

1.2 面试官到底想考什么:强一致 vs 最终一致

很多候选人一上来就背方案,但没有意识到面试官真正想考察的是你对“一致性等级”的判断力。缓存和数据库之间能不能做到强一致?理论上可以,比如引入分布式事务、2PC、TCC,或者让所有读写都走数据库,缓存只做加速。问题是这些方案在性能、可用性、改造成本上都很难接受。

打个比方:强一致像是在两栋楼之间修一座随时锁死的桥,人走时必须两边同步确认,一旦一边故障整座桥瘫痪。最终一致则是允许桥上市民走快走慢,但保证一段时间后所有人都到达正确位置。业务上大多数读多写少场景,根本不需要实时强一致,只需要最终一致,并且把不一致窗口压到可接受范围。

所以面试官要的答案通常不是“有没有万能方案”,而是你能不能讲清楚三个点:哪些环节会产生不一致窗口;用什么机制收敛这个窗口;代价是什么。理解了这一层,后面所有方案才不是死记硬背。

1.3 为什么“先删缓存再更新 DB”也不可靠

网上有些文章会建议先删缓存、再更新 DB,理由是缓存清掉后,读请求不会命中旧值。但实际操作中问题很多:如果 DB 更新失败,缓存已经被删了,原本还能命中的 key 变成空窗;此时并发请求全部穿透到 MySQL,数据库压力瞬间飙高。更关键的是,更新 DB 需要时间,期间其他读线程完全可能查旧值再把旧值回写缓存,删除动作等于白做。

反向来看,“先更新 DB 再删除缓存”在失败态下更可控:DB 更新失败时,缓存没有被动过,数据库和缓存都是旧值,仍然一致;DB 更新成功后,哪怕缓存删除失败,也只是产生了一段脏窗口,后续还能通过补偿机制收敛。这也是后面所有方案的主路径都选择“先写库再删缓存”的根本原因。

2. 延时双删为什么是玄学:三个致命缺陷

延时双删的做法是:先删缓存,再更新 DB,然后 sleep 一段时间,最后再删一次缓存。设计初衷是覆盖“第一次删除后、DB 更新完成前,读线程把旧值写回缓存”的窗口。看起来挺合理,可真到了线上,它有三个绕不过去的问题。

2.1 Sleep 窗口根本算不准

延时双删最核心的参数是 sleep 时间。要说清这个时间怎么定,你得回答:读线程从 Redis 未命中到查完 MySQL 并写回缓存,需要多少毫秒?这个时间受接口链路影响,比如底层 SQL 是否走了索引、网络 RT 是多少、GC 有没有停顿、应用服务器线程调度是否被拖慢。不同业务、不同峰值流量下,这个值完全是波动的。

我在网上看到过太多文章直接说“sleep 500ms 就行”,也有说“1 秒保底”。这种固定值在本地单测可能没问题,一上生产就露馅。并发高的时候,一次 CPU 饥饿或 Full GC 就能让读线程的写回动作拖到几秒之后;sleep 时间设短了,旧值照样写回缓存;设长了,缓存空窗时间被拉得很长,大量请求直接打到 MySQL,缓存的意义被削弱。

2.2 第二次删除仍然可能失败

延时双删的“双”字容易给人安全感,但第二次删除本质上还是一次普通 Redis 操作。如果第二次删除执行时 Redis 连接池已满、网络超时,或者 Redis 实例发生主从切换,删除命令照样失败。双删并没有为重试留出空间,Del 失败之后没有任何后续动作,缓存会一直保留旧值直到 TTL 到达。

我在线上排过类似问题:一个订单服务用了延时双删,某天 Redis 主节点抖动,写库成功、缓存删除超时,结果用户在前端看到的订单状态直到缓存过期才被纠正。根本原因就是第二次删除没有重试,也没有失败告警。所以“双删”只是增加了概率上的多一次机会,不等于可靠机制。

2.3 多了一次删除,缓存压力跟着翻倍

延时双删让每次写操作至少执行两次缓存删除,这中间缓存 key 处于“缺失状态”。在读写比为 10:1 的业务里,第二次删除后的空窗期会有大量请求回源 MySQL。如果刚好赶上热点数据,缓存击穿概率明显上升,数据库很可能被突如其来的流量打垮。

这也是延时双删最尴尬的地方:要想覆盖并发窗口就得把 sleep 调大,但 sleep 越大,缓存空窗越大,数据库压力越大;要想保护数据库就得把 sleep 调小,但又压不住旧值回写。这本质上是一个靠拍脑袋参数来赌并发时序的方案,和“高性能 + 高可靠”两个目标背道而驰。

2.4 面试时该怎么看待延时双删

不是说延时双删完全不能用。如果业务对一致性窗口容忍度很高、key 的写并发极低,用它顶一阵子也行。但面试时主动说“我用延时双删解决”,说明你对失败补偿缺少意识。更好的表达方式是:延时双删的本质是用人为 sleep 代替机制补偿,它只能降低概率,不能保证删除成功,也不能阻止极端并发下的旧值回写。所以我会选择更工程化的组合方案。

3. 高性能 + 高可靠方案:把一致性做成闭环

我一直认为,可靠的一致性方案不是找一个“完美算法”,而是把每一个可能出错的环节都设计出兜底路径。主线思路是三条:主路径尽快让缓存失效;失败路径能自动补偿;极端并发有版本或过期时间兜底。三者合起来,一致性窗口才能从“玄学”变成“可控”。

3.1 主路径:先更新 DB,事务提交后再删除缓存

先更新 DB 再删除缓存已经说过,这里重点说“事务提交后再删”这个细节。很多人写代码时把“更新 DB”和“删除缓存”放在同一个事务里,事务还没提交就去删缓存。问题在于:如果事务最后回滚了,缓存已经被删除,其他请求全部打穿到 DB;如果事务提交后缓存在删除过程中失败,还得靠外部补偿。正确的做法是更新 DB 成功后、事务提交后再触发缓存删除。

在 Spring 工程里,我常用 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 来监听事务提交事件,或者直接在业务代码里用 TransactionSynchronizationManager.registerSynchronization 注册提交后回调。这样能保证“DB 数据已生效”和“缓存删除动作”的顺序,避免无意义的缓存空窗。

至于为什么是删除缓存而不是更新缓存,道理也不复杂:并发写场景下,线程 A 和线程 B 同时更新同一个缓存 key,谁后写谁就覆盖先写的中间值;而删除缓存则和具体值无关,相当于声明“这个 key 失效了,请重新加载”。删除天然具备幂等性,重复删不会出错,也更适合放进异步重试机制。

3.2 删除失败有兜底:本地消息表 + 指数退避重试

单纯在事务提交后调一次 Redis 删除并不够——删除如果失败,就需要一个可靠的保存机制把“待删除任务”留下来。这里最实用、最容易落地的是本地消息表。

思路是:在更新 DB 的同一事务里,把业务数据更新和一条“待删除缓存”记录写进同一个 MySQL 数据库。事务提交后,一个后台任务反复扫描这张表,把所有状态为待处理的任务捞出来,执行 Redis 删除;删除成功就把任务状态置为完成,删除失败就累加重试次数,并按指数退避策略计算下次执行时间。因为删除动作是幂等的,重复执行也不会有副作用。

这个方案的本质是把“两个系统之间的最终一致性问题”转换成“单库事务内的一致性问题”:只要业务记录和删除任务同事务提交,任务就不会丢。重试表相当于给 Redis 删除动作加了持久化日志,不再依赖 sleep 来赌时序。

3.3 更彻底地兜底:订阅 Binlog 再删一次

本地消息表虽然可靠,但需要侵入业务代码,每个写方法都要记得写一条任务。有没有办法连这块代码都省掉?有,就是订阅 MySQL 的 Binlog。

常见做法是部署 Canal。Canal 会把自己伪装成一个 MySQL 从库,拿到主库 Binlog 中的变更事件,再推送给消费端。消费端拿到变更事件后,解析出表名、主键、操作类型,按业务规则拼出 Redis key,执行删除。这样做的好处非常明显:只要主库更新成功,Binlog 就一定会记录变更,漏事件的概率极低;业务代码只需要关心核心逻辑,不用额外维护重试表。

但它也有代价:从 DB 更新提交到 Binlog 被 Canal 捕获,再到消费者执行 Redis 删除,中间有一段网络和组件传输延迟,通常在几十毫秒到秒级。单独靠它做一致性,脏窗口会比本地消息表更长。所以我的建议是把它作为最终兜底:主路径尽力删除,Binlog 路径负责把主路径漏删的 key 补一次删除。两条路径互为备份,可靠性明显提升。

3.4 版本号与过期时间兜底:防止旧值回写

有了加法路径和补偿路径,是不是就完美了?还差最后一层:并发读线程把旧值写回缓存的问题。如果你的并发窗口极窄,靠 TTL 兜底就够了;但想压得更稳,可以引入版本号机制。

思路是:数据库表中增加 version 字段或 update_time 字段,缓存 value 里同时保存版本号。读线程回写缓存时带上这个版本号;写线程更新 DB 后删除缓存时,顺手记下新的版本号。后面读请求发现缓存里的版本号明显旧于期望值,就不会信任缓存,直接回源 DB。删除缓存失败后,旧缓存虽然还在,但版本号已被标记为过期,读请求会绕过它。

如果不想大动干戈,另一个轻量做法是给缓存设置较短的过期时间,比如 5 到 10 分钟。哪怕极端情况下删除失败、旧值回写,最坏也能在 TTL 时间后自动收敛。用工程上的话讲,这叫把“最大不一致时间”约束到 TTL 以内。

4. 实操演示:一套可直接复用的组合方案

方案聊再多,最后还得落代码。下面是我在项目里实际用过的组合:事务内写任务 + 事务提交后删缓存 + 失败重试表 + 可选 Canal 兜底。这套组合不依赖 sleep,性能和可靠性都远比延时双删好。

4.1 整体架构与模块划分

先看模块清单:

模块职责关键组件失败处理
写操作主流程更新 MySQL,同一事务写缓存删除任务MySQL 事务事务回滚则不产生任务
缓存删除执行器事务提交后立即删除 Redis keyRedisTemplate / Jedis失败则标记重试
重试补偿任务扫描未完成任务,按退避策略重试XXL-Job / Spring @Scheduled超过次数进入告警
Binlog 兜底消费(可选)监听变更事件,补删缓存 keyCanal + RocketMQ/Kafka消费失败持久化重试
监控告警统计删除失败次数、缓存不一致 key 数Prometheus + Alertmanager告警人工介入

主链路是:请求到达服务,更新 MySQL,事务提交后删除 Redis key。删除失败就把任务表状态更新为“重试中”,由后台任务继续处理。如果有 Canal,则无论主链路删除是否成功,Binlog 消费者都会再删一次,确保最终收敛。

4.2 核心代码:更新 DB 与写删除任务同事务

先看主流程代码,使用 Spring Boot 和 MyBatis 实现:

@Transactional public void updateOrder(OrderUpdateDTO dto) { // 1. 更新 MySQL 订单表 orderMapper.updateById(dto.toOrder()); // 2. 同一事务内写入待删除缓存任务 CacheDeleteTaskEntity task = CacheDeleteTaskEntity.builder() .bizType("ORDER") .cacheKey(CacheKeyBuilder.orderDetailKey(dto.getOrderId())) .status(CacheTaskStatus.PENDING.getCode()) .nextRetryTime(LocalDateTime.now()) .retryCount(0) .build(); cacheDeleteTaskMapper.insert(task); } // 事务提交后执行缓存删除 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onOrderUpdated(OrderUpdatedEvent event) { String cacheKey = CacheKeyBuilder.orderDetailKey(event.getOrderId()); boolean deleted = cacheService.deleteWithShortTimeout(cacheKey); if (!deleted) { cacheDeleteTaskMapper.markPendingToRetry(event.getTaskId()); } }

这里有两个关键点。第一,任务表和业务表必须在同一个事务里写入,否则事务回滚会留下“业务没更新但任务已存在”的脏状态。第二,删除缓存要放在事务提交后的监听器里,而不是业务方法内。我在代码里用 deleteWithShortTimeout 而不是直接 delete,是给 Redis 操作设置一个较短的超时时间,比如 300ms,避免删除动作拖慢主链路。

4.3 重试表结构与补偿任务设计

配套表结构可以这样建:

CREATE TABLE cache_delete_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT '业务类型', cache_key VARCHAR(255) NOT NULL COMMENT '缓存 key', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待处理 1-已完成 2-重试中 3-最终失败', retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', next_retry_time DATETIME NOT NULL COMMENT '下次执行时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_retry (status, next_retry_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缓存删除任务表';

后台补偿任务的核心逻辑是:每分钟扫描一次,捞出 next_retry_time 小于当前时间且状态为待处理或重试中的任务,限制每次处理 100 条,逐条执行 Redis 删除。删除成功后把状态置为已完成;删除失败则 retry_count 加一,按“1s、2s、4s、8s、16s、不超 5 次”的退避策略更新 next_retry_time。超过最大重试次数就把状态置为最终失败,并接入告警。

扫描逻辑千万不要一次捞全表,否则历史数据堆积后会把任务表拖垮。我的习惯是先查 100 条,处理完再查下一批;同时对已完成记录每天定时清理,只保留最近 7 天。这样任务表数据量始终可控,备份和恢复成本也低。

4.4 Binlog 兜底消费的实现要点

如果你已经引入 Canal,消费端处理流程大致是这样的:

public void onBinlogEvent(BinlogMessage msg) { if (!"order".equalsIgnoreCase(msg.getTableName())) { return; } if (msg.getEventType() != UPDATE && msg.getEventType() != INSERT) { return; } String orderId = msg.getAfterRow().get("id"); String cacheKey = CacheKeyBuilder.orderDetailKey(orderId); cacheService.deleteWithShortTimeout(cacheKey); }

这里要注意三点:解析 Binlog 后拼出的缓存 key 必须与业务代码里的 key 规则完全一致;消费端必须保证幂等,重复删除没有问题,但不要把 event 丢失;Canal 消费延迟会影响兜底时效,所以最好把它放在补偿层而不是主链路。只要主链路和 Binlog 兜底都能正常跑,即使某一条删除在业务层失败,也会在 Canal 消费后补上。

4.5 关键参数建议清单

参数推荐值说明
缓存 TTL5-10 分钟兜底最大不一致时间
删除超时时间200-500ms避免阻塞主链路
重试初始间隔1s不宜过小,防抖
退避倍数2指数退避
最大重试次数5超过就告警
任务扫描频率1 次/分钟覆盖秒级到分钟级恢复
单批扫描量100防止大事务
Canal 消费并发3-5按业务量调整
大 key 删除命令UNLINK避免 DEL 阻塞

这组参数来自我的线上调优经验。缓存 TTL 不要选太长,否则兜底太久;太短又会频繁回源 DB。删除超时控制在 500ms 以内,宁可让任务多走一次重试,也不能让写请求普遍变慢。

5. 常见问题与排查技巧实录

方案落地后,真正考验人的是所有边界场景。下面几个问题我基本都在生产环境遇到过,逐个说下我的处理方式。

5.1 删除缓存时 Redis 阻塞怎么办

一次 Redis 删除操作如果遇到大 key,DEL 可能阻塞 Redis 主线程上百毫秒,严重时整实例响应变慢。正确做法是用 UNLINK 命令,它是异步释放内存,即使 key 大小达到几 GB,也不会长时间阻塞实例。在重试任务里也要避免一次性并发删除大量 key,批量任务建议控制并发在 3 到 5 个以内,防止把 Redis 打到超时。

5.2 缓存删了还被旧值回写,怎么解决

这个问题的根源是读线程在删除前已经查出旧值,删除后它才把结果写回。我的处理思路是双层防御:给缓存 key 设置较短 TTL 兜底;对特别核心的数据,在读路径增加版本号校验,写回前先比较缓存中是否有更新版本,有就放弃回写。如果不想引入版本号,也可以退一步接受短时间脏读,但要明确它的最大窗口就是 TTL。

5.3 分布式锁要不要用

有些文章推荐在缓存更新前后加重试锁,认为可以完全消除窗口。实际工程里,分布式锁只能让并发读写变成串行,对性能影响很大,而且锁的粒度、超时时间、公平性都是一堆新问题。我更建议只在热点写冲突极其严重的 key 上,针对性地加锁,不要全局无脑上锁。缓存一致性方案的重心应该放在“失败可重试、数据可收敛”,而不是把所有并发都串起来。

5.4 缓存与 DB 不一致如何快速定位

如果业务反馈数据有延迟,我一般会写一个对账脚本,扫描缓存里的核心 key,读取 DB 最新值,比较数据内容或版本号,把不一致的 key 输出到日志。接下来看 cache_delete_task 表里有没有状态为最终失败的任务,如果有,大概率就是某个删除操作一直没成功。再看 Canal 消费的 lag 指标,确认 Binlog 消费者是否积压。三个步骤下来,不一致的范围和原因基本能锁定。

5.5 面试作答顺序建议

面试官如果让你“说说怎么保证一致性”,不要上来就讲代码。我建议这样组织答案:先说“缓存一致性本质是最终一致,强一致不是缓存场景的常规选择”;再说主路径是更新 DB 后事务提交删除缓存;接着说删除失败如何兜底,比如本地消息表重试或订阅 Binlog 补偿;最后提防旧值回写的方式是 TTL 和版本号。结尾主动指出这套方案仍存在毫秒级窗口,说明你对自己的方案边界有清醒认知。

我个人在实际项目中踩过很多次坑之后,最大的体会是:一致性方案的关键不是某个单一技巧,而是让每一个可能失败的动作都有重试、有监控、有收敛路径。延时双删的 sleep 之所以不靠谱,就是因为它把“希望”寄托在不确定的时间窗口上。真正上线后,我会优先选择“先更新 DB + 事务提交后删缓存 + 失败任务表 + Binlog 兜底”的组合。这套方案不花哨,也能在保证高性能的同时,让脏数据窗口处于可控状态。如果你也在准备面试或者正在设计缓存架构,不妨从这套组合开始,把每一步的失败场景都列出来,你会发现一致性问题的答案并不是背出来的,而是推演出来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询