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 实测)。
RocketMQ(Apache RocketMQ):Apache 基金会开源的分布式消息中间件,负责把消息从生产者可靠地送到消费者。你可以理解成「带保险的快递驿站,件在、单号在、签收有记录」。
RocketMQ 里有两个核心角色:NameServer 管路由,记录每个 Broker 在哪;Broker 管存储,真正存消息。启动顺序是先 NameServer,再 Broker。
前置条件:JDK 1.8+(64 位);从 rocketmq.apache.org 下载二进制包并解压,我写这篇时最新是 5.3.4,命令以你实际版本为准。
启动 NameServer:
cdrocketmq-all-5.3.4-bin-releasenohupshbin/mqnamesrv&tail-f~/logs/rocketmqlogs/namesrv.log日志里看到The Name Server boot success...就说明启动成功。
我第一次搭的时候在这翻过一次车——broker 启动得比 namesrv 还快,结果报nameserver is not ready yet。记住了:先 namesrv,等日志跑起来,再开 broker。
启动 Broker:
nohupshbin/mqbroker-nlocalhost:9876&tail-f~/logs/rocketmqlogs/broker.log日志里看到The broker[broker-a, IP:10911] boot success...就说明启动成功。
到这儿一套单机 RocketMQ 就活了。两个端口记住:9876 是 NameServer,10911 是 Broker,后面代码里要用。
想看消息长啥样,可以再起一个 rocketmq-dashboard 控制台:clone 官方仓库后mvn spring-boot:run,浏览器开http://localhost:8080,把 namesrvAddr 填成localhost:9876,Topic 和消息一目了然。
Windows 的话给个提醒:官方文档推荐 64 位 Linux/Mac 跑 sh 脚本,Windows 上要么装 WSL2,要么用 Docker 起容器,别硬刚原生环境,坑多。
三、RocketMQ 延迟消息:给消息定个闹钟
延迟消息就是给消息装了个闹钟——到点了才响,消息到点了才投递。
RocketMQ 延迟消息(Delayed Message):发送时指定延迟等级,Broker 先把消息放进调度队列,到点才投递给消费者。你可以理解成「外卖定时送达,到点才敲你门」。
用法和普通消息几乎一样,只在发送时多设一个延迟等级:
DefaultMQProducerproducer=newDefaultMQProducer("order_producer");producer.setNamesrvAddr("localhost:9876");producer.start();// 延迟 30 秒投递:第 4 级 = 30sMessagemsg=newMessage("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 分钟未支付自动取消,就是延迟消息的经典用法:下单成功发一条 level=16(30 分钟)的消息,消费者到点查订单,还没支付就取消、释放库存。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());MessageflushMsg=newMessage("cache-flush",order.getId().toString().getBytes());flushMsg.setDelayTimeLevel(4);// 30s 后再删一次producer.send(flushMsg);// 消费者:收到延迟消息 = 时间窗结束,执行第二次删除publicclassCacheFlushListenerimplementsRocketMQListener<Message>{@OverridepublicvoidonMessage(Messagemessage){StringorderId=newString(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 延迟消息做延迟双删吗?