RocketMQ 延迟消息实战:延迟双删策略解决 Redis 缓存一致性
大家好我是晚安code。更新完数据库缓存里却还躺着旧数据——这种脏数据我太熟了。上周排查一个订单价格对不上的线上问题最后定位到就是「先删缓存再写库」在并发下翻了车。这篇我把 RocketMQ 延迟消息和延迟双删Delay Double Deletion讲透先带你本地搭一套 RocketMQ再用延迟消息让「第二次删除」准时到位把 Redis 缓存一致性这个老大难摁下去。点个收藏咱们开始。一、Redis 缓存为什么会有一致性问题缓存与数据库的一致性问题本质是一场「谁先动手」的并发赛跑谁跑赢了数据就听谁的。说真的缓存这东西就是来扛并发的——读多写少的业务把热点数据扔进 Redis数据库的压力能降一个量级。但它也带来一个新麻烦同一份数据库里存一份缓存里存一份两边怎么保证不打架先看最常见的写法Cache Aside旁路缓存最常用的缓存读写模式读请求先查缓存查不到再查数据库并回填写请求更新数据库后删除缓存。你可以理解成「用到才取改完就作废」。那「改完就作废」为什么不直接更新缓存、非要删掉因为更新缓存得先把新值算出来还得防着并发写覆盖删掉让下次读的时候重新回填逻辑最省事。问题出在「删缓存」和「更新数据库」不是原子的。你删完缓存另一个线程刚好读到数据库里的旧值在你反应过来之前就把旧值塞回缓存了脏数据就这么诞生Redis 缓存一致性Redis Cache Consistency指缓存里的数据和数据库里的数据保持一致这个属性。你可以理解成「图书馆台账和实际馆藏对得上」。打个比方图书馆管理员把旧借阅登记撕了准备重新誊抄新账本结果誊抄之前有个读者照旧登记又抄了一遍。台账和馆藏就这么对不上了。所以光删一次是不够的——脏数据可能在你删完的下一秒就被人塞回来。这就要引出今天的正题删第二次而且还要「延迟」删第二次。二、RocketMQ 本地搭建两个进程跑起来本地跑 RocketMQ 用不着集群NameServer Broker 两个进程就够2026 年 8 月用 5.3.4 实测。RocketMQApache RocketMQApache 基金会开源的分布式消息中间件负责把消息从生产者可靠地送到消费者。你可以理解成「带保险的快递驿站件在、单号在、签收有记录」。RocketMQ 里有两个核心角色NameServer 管路由记录每个 Broker 在哪Broker 管存储真正存消息。启动顺序是先 NameServer再 Broker。前置条件JDK 1.864 位从 rocketmq.apache.org 下载二进制包并解压我写这篇时最新是 5.3.4命令以你实际版本为准。启动 NameServercdrocketmq-all-5.3.4-bin-releasenohupshbin/mqnamesrvtail-f~/logs/rocketmqlogs/namesrv.log日志里看到The Name Server boot success...就说明启动成功。我第一次搭的时候在这翻过一次车——broker 启动得比 namesrv 还快结果报nameserver is not ready yet。记住了先 namesrv等日志跑起来再开 broker。启动 Brokernohupshbin/mqbroker-nlocalhost:9876tail-f~/logs/rocketmqlogs/broker.log日志里看到The broker[broker-a, IP:10911] boot success...就说明启动成功。到这儿一套单机 RocketMQ 就活了。两个端口记住9876 是 NameServer10911 是 Broker后面代码里要用。想看消息长啥样可以再起一个 rocketmq-dashboard 控制台clone 官方仓库后mvn spring-boot:run浏览器开http://localhost:8080把 namesrvAddr 填成localhost:9876Topic 和消息一目了然。Windows 的话给个提醒官方文档推荐 64 位 Linux/Mac 跑 sh 脚本Windows 上要么装 WSL2要么用 Docker 起容器别硬刚原生环境坑多。三、RocketMQ 延迟消息给消息定个闹钟延迟消息就是给消息装了个闹钟——到点了才响消息到点了才投递。RocketMQ 延迟消息Delayed Message发送时指定延迟等级Broker 先把消息放进调度队列到点才投递给消费者。你可以理解成「外卖定时送达到点才敲你门」。用法和普通消息几乎一样只在发送时多设一个延迟等级DefaultMQProducerproducernewDefaultMQProducer(order_producer);producer.setNamesrvAddr(localhost:9876);producer.start();// 延迟 30 秒投递第 4 级 30sMessagemsgnewMessage(order-timeout,cancel,order-1001.getBytes());msg.setDelayTimeLevel(4);producer.send(msg);不过有个反直觉的坑延迟等级是写死的 18 级不是随便填秒数1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h想延迟 3 分钟抱歉没有这个档位你只能挑 2m 或 4m。这个设计是故意的——任意秒数会让 Broker 做全局消息排序性能扛不住所以用固定梯度换吞吐。电商下单 30 分钟未支付自动取消就是延迟消息的经典用法下单成功发一条 level1630 分钟的消息消费者到点查订单还没支付就取消、释放库存。30 分钟一到闹钟准时响。消息在 Broker 里是这么流转的底层机制一句话延迟消息先落到SCHEDULE_TOPIC_XXXX调度主题18 个队列对应 18 个等级Broker 的定时任务到点把它们转投到真正的业务 Topic。可能有人会问延迟消息能精确到秒吗不能。开源版只有这 18 个固定等级最大 2 小时投递还会带 1~2 秒误差。真要任意秒级精度得自己改源码扩展调度逻辑或者用云厂商的延迟消息版本。四、延迟双删Delay Double Deletion让第二次删除等一等普通双删怕的不是删不掉而是删完第一遍后并发读又把旧值塞了回去。延迟双删Delay Double Deletion更新数据库后先删除一次缓存隔一段延迟时间再删除一次缓存把并发读回写造成的脏数据再清一遍。你可以理解成「撕了旧公告等人都看完再补撕一次残留」。延迟双删的思路就三步更新数据库删除缓存第一次等一段延迟覆盖并发读回写脏数据的窗口再删除缓存第二次全流程画成时序图一眼就懂我以前也觉得删两次总够了吧结果自己写了个并发 demo 一压发现第一删和第二删之间读线程照样能塞旧值进来——第二删不延迟的话等于白删。这就是「延迟」两个字的关键给并发窗口盖棺而不是跟它赛跑。第二次删除怎么实现最省事的写法是主线程 sleep 几秒再删——但进程一重启就全没了。我强烈建议交给 RocketMQ 延迟消息消息落盘、不丢、能重试、还能看消费记录// 写库 第一次删缓存 发延迟消息orderMapper.update(order);redis.del(order:order.getId());MessageflushMsgnewMessage(cache-flush,order.getId().toString().getBytes());flushMsg.setDelayTimeLevel(4);// 30s 后再删一次producer.send(flushMsg);// 消费者收到延迟消息 时间窗结束执行第二次删除publicclassCacheFlushListenerimplementsRocketMQListenerMessage{OverridepublicvoidonMessage(Messagemessage){StringorderIdnewString(message.getBody());redis.del(order:orderId);// 第二次删除缓存}}这中间有个判断要做延迟多久太短盖不住并发窗口太长等于容忍长时间脏数据。我一般从「一次读请求从数据库回填缓存的平均耗时」往上翻几倍再配合压测调通常 1~5 秒够用。顺手把主流的几个方案放在一起对比你心里就有数了方案实现方式脏数据窗口复杂度适合谁只删一次Cache Aside 基础版更新 DB → 删缓存可能持续到缓存过期最低并发极低、容忍脏数据延迟双删 RocketMQ 延迟消息更新 DB → 删缓存 → 延迟消息 → 再删秒级可控中读多写少能接受最终一致订阅 binlog 异步刷新如 Canal监听 DB binlog 驱动缓存删除/更新毫秒级接近实时高核心链路一致性要求高五、两个容易踩的坑延迟双删不是银弹坑主要藏在延迟时长和二次删除失败上。坑 1延迟时长拍脑袋。延迟设太短并发窗口没盖住第二删等于白删设太长脏数据会直接展示给用户。正确做法是先量压测里读线程把旧值写回缓存要多久延迟取这个值的 2~3 倍打底再观察线上告警有没有收敛。坑 2第二次删除失败了怎么办。消费失败 RocketMQ 默认会重试16 次一般够用。但网络抖动、Redis 挂了都可能导致最终没删掉缓存里的脏数据会一直活到过期。所以生产环境我会再加一道兜底二次删除也失败就投一条更长延迟的补偿消息同时让缓存 TTL 别设太长当作最后的保险。可能有人会问第二次删除失败数据会一直脏下去吗不会永久脏缓存有 TTL到点自己失效下次读会回填新值。问题是这个窗口可能很长。所以关键不是「绝对不失败」而是「失败了能被发现、有兜底」——重试 补偿消息 合理 TTL三件套配上基本就稳了。六、小结延迟双删不是银弹延迟双删给的是「最终一致」不是「绝对一致」——这句话决定了你该不该用它。说句大实话RocketMQ 延迟消息 延迟双删是我现在做缓存一致性最顺手的一套组合本地 20 分钟能搭起来改动只有「多删一次缓存 发一条延迟消息」却把最脏的那个并发窗口堵住了。但它换来的只是最终一致如果业务要求读到的一定是最新数据比如余额、库存超卖这种该上 binlog 订阅或者分布式锁别硬用缓存。想深入的话官方文档搜RocketMQ 官方文档里有延迟消息和部署的完整说明延迟等级对应关系在broker.conf的messageDelayLevel里想加等级自己也能改。我是晚安code持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊你踩过缓存脏数据的坑吗会用 RocketMQ 延迟消息做延迟双删吗