业务说"用户操作 73 秒后给他发个推送",你打开 RocketMQ 4.x 文档,只看到 18 个固定延迟级别:1s、5s、10s、30s、1m、2m、3m、4m、5m、6m、7m、8m、9m、10m、20m、30m、1h、2h。
73 秒?没有。
升级 RocketMQ 5.x 改用 RocksDB 时间轮吗?改造成本高、有迁移风险;全部丢给 Redis 延迟队列吗?长延迟的消息几万条压在 Redis 里又怕内存吃不消、宕机丢数据。
这篇文章拆解的是一套生产环境跑了两年多的混合方案 :用 MQ 的 18 个标准级别打底,Redis 延迟队列补 ≤ 5 分钟的零头,顺带把"异步任务 trace 链路不断"和"事务感知发消息"两个老大难一起解了。
📖 前置阅读 :本文中"业务代码通过applicationContext.publishEvent(new DelayEvent(...))触发 MQ 消息"的设计,来自我们前作 《@RemoteEvent 自动事件总线:1 个注解换 60 个 Consumer,赚还是亏?》。不熟悉这个事件总线设计的读者建议先读前作的 § 二 ~ § 三,本文不重复展开。
一、问题:RocketMQ 4.x 给不了任意秒数
1.1 RocketMQ 4.x 的延迟级别
RocketMQ 4.x 的延迟实现方式是 commitlog + ScheduleService ------所有延迟消息进 SCHEDULE_TOPIC_XXXX 这个内置 topic,按延迟级别分队列,定时拉取,到期投递。
为什么是 18 个固定级别?因为 broker 里写死了一个延迟时间数组:
text
messageDelayLevel: 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
每个级别对应一个 ConsumeQueue,定时任务一个 ConsumeQueue 扫一次,定时扫描的开销可控。
代价:不支持任意时间,只能"投到最近的预设级别"。
1.2 RocketMQ 5.x 解决了吗?
是的,5.x 引入了 timer wheel(时间轮) 机制,理论上可以任意时间延迟。
原理上它和 4.x 是两条路子:4.x 是「写死的级别数组」,只能投到预设档位;5.x 把每条延迟消息按到期时间戳 挂到时间轮的槽位上,指针每跳一格就处理当前槽里到期的消息------给的是连续的时间刻度 ,而不是固定档位。海量「还没到期」的消息不可能全压内存,于是把到期索引落盘 保存(append-only 的 TimerLog,新版本还可选 RocksDB 存储引擎),内存只留近期要触发的那部分。一句话对照:4.x 是固定档位的数组,5.x 是连续刻度的时间轮 + 落盘索引------这正是 4.x 给不了任意秒数的根因。
既然 5.x 原生支持,为什么不直接升?
- 升级成本不低(broker 数据格式 / 客户端 SDK 兼容)
- 老业务跑 4.x 跑得稳,升级 ROI 不高
- 很多公司是 Apache RocketMQ + 阿里云 RocketMQ 混部署,云上 4.x 升 5.x 还要走运维流程
所以给 4.x 续命做"任意秒数延迟" 还是一个真实需求。
1.3 4 种常见方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| 升级 RocketMQ 5.x | 任意时间、官方支持 | 升级成本高 |
| 纯 Redis 延迟队列 | 任意时间、精度高 | 大量消息内存吃紧、Redis 宕机风险 |
| 数据库 + 定时扫描 | 简单、可靠 | 慢、扩展性差、轮询浪费资源 |
| MQ 粗延迟 + Redis 补精度 | 可靠性靠 MQ,精度靠 Redis,各取所长 | 链路稍复杂 |
下面拆方案四的工程实现。
二、方案核心:MQ 粗 + Redis 精
2.1 一句话
业务想延 73 秒 → MQ 延 60 秒 + Redis 延 13 秒
把延迟拆成两段:
- 粗粒度:RocketMQ 的标准级别打底(60 秒以下的零头都丢给 Redis)
- 精度补齐:消费侧拿到 MQ 消息后,看零头 offset 是否 > 0,大于 0 就丢进 Redis 延迟队列再等
两个硬约束(本方案的核心防御):
- offset 必须 ≤ 5 分钟(300 秒)------超过就拒绝,防止 Redis 堆积
- 粗粒度必须 ≤ MQ 支持的最大级别(2 小时)------超过也拒绝
2.2 整体链路图
业务事件 Listener Redisson 延迟队列 Consumer 监听器 RocketMQ Spring 事件多播器 Producer 业务代码 业务事件 Listener Redisson 延迟队列 Consumer 监听器 RocketMQ Spring 事件多播器 Producer 业务代码 #mermaid-svg-VUxQgR9Ht3PEOAKd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VUxQgR9Ht3PEOAKd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VUxQgR9Ht3PEOAKd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VUxQgR9Ht3PEOAKd .error-icon{fill:#552222;}#mermaid-svg-VUxQgR9Ht3PEOAKd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VUxQgR9Ht3PEOAKd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VUxQgR9Ht3PEOAKd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VUxQgR9Ht3PEOAKd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VUxQgR9Ht3PEOAKd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VUxQgR9Ht3PEOAKd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VUxQgR9Ht3PEOAKd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VUxQgR9Ht3PEOAKd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VUxQgR9Ht3PEOAKd .marker.cross{stroke:#333333;}#mermaid-svg-VUxQgR9Ht3PEOAKd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VUxQgR9Ht3PEOAKd p{margin:0;}#mermaid-svg-VUxQgR9Ht3PEOAKd .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VUxQgR9Ht3PEOAKd text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-VUxQgR9Ht3PEOAKd .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VUxQgR9Ht3PEOAKd .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-VUxQgR9Ht3PEOAKd .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-VUxQgR9Ht3PEOAKd .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-VUxQgR9Ht3PEOAKd #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-VUxQgR9Ht3PEOAKd .sequenceNumber{fill:white;}#mermaid-svg-VUxQgR9Ht3PEOAKd #sequencenumber{fill:#333;}#mermaid-svg-VUxQgR9Ht3PEOAKd #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-VUxQgR9Ht3PEOAKd .messageText{fill:#333;stroke:none;}#mermaid-svg-VUxQgR9Ht3PEOAKd .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VUxQgR9Ht3PEOAKd .labelText,#mermaid-svg-VUxQgR9Ht3PEOAKd .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-VUxQgR9Ht3PEOAKd .loopText,#mermaid-svg-VUxQgR9Ht3PEOAKd .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-VUxQgR9Ht3PEOAKd .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VUxQgR9Ht3PEOAKd .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-VUxQgR9Ht3PEOAKd .noteText,#mermaid-svg-VUxQgR9Ht3PEOAKd .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-VUxQgR9Ht3PEOAKd .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VUxQgR9Ht3PEOAKd .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VUxQgR9Ht3PEOAKd .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VUxQgR9Ht3PEOAKd .actorPopupMenu{position:absolute;}#mermaid-svg-VUxQgR9Ht3PEOAKd .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-VUxQgR9Ht3PEOAKd .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VUxQgR9Ht3PEOAKd .actor-man circle,#mermaid-svg-VUxQgR9Ht3PEOAKd line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-VUxQgR9Ht3PEOAKd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 二分查找 ≤73 的最大预设级别 = d_1min(60s) offset = 13s 标准延迟 60s Redisson 延迟队列等 13s publishEvent(DelayEvent.create(event, 73s)) 1 sendDelay(message, d_1min) 2 60s 后投递消息 3 delayOffset = 13 > 0? 4 offer(runnable, 13s) 5 13s 后任务到期 6 applicationContext.publishEvent(event) 7
业务方完全无感:publishEvent(DelayEvent.create(event, 73)) 一行搞定。背后是不是走 MQ、是不是补了 Redis,业务不关心。
三、关键设计 1:offset 计算 + 二分查找最近级别
业务侧入口就一个静态方法 DelayEvent.create(event, delayTime):
java
public static DelayEvent create(Object event, int delayTime) {
// 二分找 ≤ delayTime 的最大预设级别(避免提前到达)
DelayLevel delayLevel = DelayLevel.findNearByLessThan(delayTime);
if (delayLevel == null) {
// 没有预设级别 ≤ delayTime(比如延迟 0.5s),只能纯 Redis 走
return new DelayEvent(event, null, delayTime);
}
int offset = delayTime - delayLevel.getSecond();
if (offset == 0) {
// 刚好命中预设级别,无零头
return new DelayEvent(event, delayLevel);
}
if (offset > 300) {
// 零头超过 5 分钟,直接拒绝,防止 Redis 队列被超长消息堆爆
throw new IllegalArgumentException("延迟时间偏移量过大,请使用其他方式推送延迟消息");
}
return new DelayEvent(event, delayLevel, offset);
}
两个细节:
为什么找"≤"的最大级别,而不是"最近的"?
- 找"≤" 等价于 宁愿迟到一点,也不要早到
- 业务侧如果"73 秒后做某事",提前 13 秒(投到 60s 级别)做了 → 业务可能还没准备好(比如订单还没完全落库)
- 推迟 13 秒(投到 60s 级别 + Redis 等 13s)→ 业务永远是"延迟到期后"的状态
为什么 offset 限制 300 秒(5 分钟)?
offset 这段时间,消息是实打实压在 Redis 里等的------压得越久,堆积越多、宕机丢的风险敞口越大。300 秒给「单条消息在 Redis 的停留时长」封了个顶,这是本方案保护 Redis 的核心阀门。
- 不封顶会怎样 :业务方调一次
create(event, 5000),匹配到d_1hour(3600),剩 1400 秒(约 23 分钟)全压 Redis,几万条这种消息就能把内存撑爆。 - 为什么 300 够用,而不是覆盖所有跨度 :低端级别很密(1s ~ 10min,相邻级别最大才差 60 秒),零头天然 ≤ 60 秒,这段区间 300 的限制根本不会触发,任意秒数畅通无阻;高端级别很稀(10min → 20min → 30min → 1h → 2h,跨度成百上千秒),这段区间里零头超过 300 秒的延迟会被刻意拒绝,引导走别的机制------这和后面 §8.2 给出的建议一脉相承:延迟太长就别硬塞这套方案,该升 5.x 就升。
- 想让高端也密起来? 自定义 broker 的
messageDelayLevel多插几档(见下方 3.1 的提醒),把级别间跨度压到 300 秒内,拒绝区间自然就消失了。
3.1 DelayLevel 怎么对应 MQ 级别
java
@AllArgsConstructor
@Getter
public enum DelayLevel {
d_1s(1, 1), d_5s(2, 5), d_10s(3, 10), d_30s(4, 30),
d_1min(5, 60), d_2min(6, 120), d_3min(7, 180), d_4min(8, 240),
d_5min(9, 300), d_6min(10, 360), d_7min(11, 420), d_8min(12, 480),
d_9min(13, 540), d_10min(14, 600), d_20min(15, 1200), d_30min(16, 1800),
d_1hour(17, 3600), d_2hour(18, 7200);
private final int level; // 对应 RocketMQ 的 delayLevel(1-based),与 §1.1 默认的 18 级 messageDelayLevel 一一对应
private final int second; // 实际延迟秒数
// 二分查找 ≤ seconds 的最大级别
public static DelayLevel findNearByLessThan(int seconds) {
// ... 标准二分实现 ...
}
}
注意:这里的 level 必须跟 broker 的 messageDelayLevel 配置一一对应 。如果你的 broker 改过 messageDelayLevel(比如插了 15s 45s),这个枚举要同步改。
四、关键设计 2:Spring 事件无侵入伪装
整套方案对业务代码是完全无感 的------业务方写的是 Spring 事件,实际跑的是 MQ + Redis。秘密在这个 ApplicationEventMulticaster 上:
java
@Configuration("applicationEventMulticaster")
@Primary
public class DelayEventMulticaster extends SimpleApplicationEventMulticaster {
@Override
public void multicastEvent(ApplicationEvent event, ResolvableType eventType) {
if (shouldHandleEvent(event)) {
handleEvent(event, eventType);
} else {
super.multicastEvent(event, eventType); // 普通 Spring 事件走默认逻辑
}
}
private void handleEvent(ApplicationEvent event, ResolvableType eventType) {
PayloadApplicationEvent<?> payload = (PayloadApplicationEvent<?>) event;
if (!(payload.getPayload() instanceof DelayEvent)) {
super.multicastEvent(event, eventType); // 不是 DelayEvent,普通走
return;
}
processDelayEvent((DelayEvent) payload.getPayload()); // 是 DelayEvent,接管
}
private void processDelayEvent(DelayEvent delayEvent) {
Message message = createMessage(delayEvent.getEvent(), delayEvent.getOffset());
if (TransactionSynchronizationManager.isSynchronizationActive()) {
// 当前在事务里 → 注册事务监听,等事务提交再发(下一节)
registerTransactionSynchronization(delayEvent, message);
} else {
sendOrScheduleMessage(delayEvent, message);
}
}
// ...
}
@Primary 让 Spring 容器优先用我们的实现,业务代码继续按 Spring 事件那套写:
java
// 业务代码 - 跟普通 Spring 事件一模一样的姿势
applicationContext.publishEvent(DelayEvent.create(myEvent, 73));
业务方完全不需要知道有 MQ、有 Redis、有延迟队列。
⚠️ 这是把双刃剑 :好处是开发体验丝滑,坏处是新人会被"看起来是 Spring 事件,实际跨进程" 这种"伪装"搞迷糊。命名上我们至少把入口对象叫
DelayEvent(而不是叫MyEvent),提醒读者"这跟普通 Spring 事件不一样"。
五、关键设计 3:事务感知(事务回滚不会留下"已发出的消息")
业务代码经常这么写:
java
@Transactional
public void doSomething() {
orderService.create(order); // 写 DB
applicationContext.publishEvent(DelayEvent.create(event, 73)); // 发延迟消息
// ...
if (somethingWrong) {
throw new RuntimeException(); // 事务回滚
}
}
问题:消息发出去了,但事务回滚了,DB 里没数据 → 下游 Listener 拿到消息找不到对应记录,出错。
解决 :在 processDelayEvent 里判断是否在事务中,在事务中就注册 TransactionSynchronization,等事务提交后再真正发:
java
private void processDelayEvent(DelayEvent delayEvent) {
Message message = createMessage(delayEvent.getEvent(), delayEvent.getOffset());
if (TransactionSynchronizationManager.isSynchronizationActive()) {
registerTransactionSynchronization(delayEvent, message);
} else {
sendOrScheduleMessage(delayEvent, message);
}
}
private void registerTransactionSynchronization(DelayEvent delayEvent, Message message) {
TransactionSynchronization sync = new AfterCommitMqSender(
mqSender, message, delayEvent.getDelayTime(), applicationContext, delayQueueDispatcher);
TransactionSynchronizationManager.registerSynchronization(sync);
}
// 真正发送的时机
class AfterCommitMqSender extends TransactionSynchronizationAdapter {
@Override
public void afterCompletion(int status) {
// 关键:只有事务真正提交了才发消息
if (status != STATUS_COMMITTED) {
// 回滚 / 未知状态 → 直接 return,不发------这正是本节要的效果:
// DB 没落数据,就别让下游收到一条"找不到对应记录"的消息
return;
}
if (delayTime != null) {
mqSender.sendDelay(message, delayTime);
}
// ... 处理 offset 的零头 ...
}
}
为什么用
afterCompletion而不是afterCommit?afterCommit只在提交时触发,而afterCompletion提交、回滚都会触发------把「提交才发、回滚做清理」收敛到一个回调里更顺手,代价就是必须自己判status == STATUS_COMMITTED,漏判就等于把本节要解决的 bug 又放回来了。
📖 延伸 :Spring 事务消息有多种实现方式(本地消息表 / 直发 / TransactionSynchronization 监听 / RocketMQ 事务消息),各有取舍。详见前作 《事务回滚了消息却发出去:Spring 事务消息的 4 种姿势对比》。
六、关键设计 4:消费侧 Redis 延迟队列(Worker Group + 监控自愈)
消费侧收到 MQ 消息后,如果 delayOffset > 0,就要进 Redis 延迟队列再等一会:
java
// 消费侧总分发(在 RocketMQ Consumer 的 onMessage 回调里)
if (eventEnvelope.getDelayOffset() != null && eventEnvelope.getDelayOffset() > 0) {
delayQueueDispatcher.add(eventEnvelope); // 进 Redis 延迟队列
} else {
applicationContext.publishEvent(event); // 直接触发业务事件
}
DelayQueueDispatcher 内部用一个 Worker Group 管理多个 Redis 延迟队列:
java
@PostConstruct
public void init() {
String name = "delay_message_worker_queue";
// 4 个 worker,每个 worker 2 个消费线程,共 8 个消费线程
this.workerGroup = new DelayWorkerGroup(4, 2, redissonClient);
this.workerGroup.initExecutors(name);
}
public void add(EventEnvelope eventEnvelope) {
workerGroup.add(eventEnvelope);
}
每个 worker 对应一个独立的 Redis 延迟队列(命名 delay_message_worker_queue{env}{0..3},{env} 是环境后缀做多环境隔离、{0..3} 是 worker 下标),用 hash 分片到 worker:
java
public void add(EventEnvelope eventEnvelope) {
int index = Math.abs(eventEnvelope.hashCode() % workerCount);
String key = getFullname(name, index);
workerGroup.get(key).add(eventEnvelope);
}
6.1 单个 worker 内部:Redisson 延迟队列 + 阻塞队列
Redisson 的 RDelayedQueue 工作原理:到期前消息只在内部 zset 里 (score = 过期时间);到期后自动转移到 RBlockingQueue,消费线程从 BlockingQueue poll 即可。
再往里拆一层,这个「自动转移」是怎么发生的:
- 两个 Redis 结构:一个 zset 按到期时间戳排序存索引(score = 触发时刻),一个 list 存实际 payload。
- 转移靠客户端定时器,不是 Redis 主动推 :Redisson 在客户端起一个转移任务(
QueueTransferTask),按 zset 头部元素 的到期时间设一个定时器;时间一到,执行一段 Lua 脚本 ,把所有score ≤ now的元素原子地从 zset 弹出、塞进目标 RBlockingQueue,然后按新的头部重设定时器。 - 更早到期的新消息会唤醒定时器 :offer 一条比当前头部更早到期的消息时,通过 Redis pub/sub 通知转移任务重算等待时间,避免「定时器还在睡,更早的消息已经该触发了」。
⚠️ 一个容易忽略的生产坑 :这个转移定时器跑在客户端 JVM (就是调
getDelayedQueue(...)的那个进程),不是 Redis 服务端。所以必须保证至少有一个持有该 DelayedQueue 的客户端存活 ,否则消息只会躺在 zset 里不被转移。好在进程重启不丢------数据都在 Redis 的 zset / list 里,重启后重新getDelayedQueue会自动接着转移没处理完的消息。这也是为什么 §8.1 把「Redis 持久化(AOF + 主从)」列为头号风险。
回到代码,单个 worker 的初始化:
java
public void init() {
blockingQueue = redissonClient.getBlockingQueue(name);
delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
retryQueue = redissonClient.getBlockingQueue(name + "_retry");
start(); // 启动消费线程(下方 start() 方法)
retry(); // 启动重试线程
monitor(); // 启动监控线程
}
public void add(EventEnvelope eventEnvelope) {
DelayEventTask runnable = new DelayEventTask(eventEnvelope);
delayedQueue.offer(runnable, eventEnvelope.getDelayOffset(), TimeUnit.SECONDS);
}
private void start() {
execute(() -> {
while (!shutdown) {
Runnable worker = blockingQueue.poll(10, TimeUnit.SECONDS);
if (worker == null) continue;
try {
worker.run();
} catch (Exception e) {
retry(worker); // 失败进重试队列
}
}
});
}
6.2 监控线程:消费线程挂了自动重启
延迟消息消费最怕的就是消费线程默默死掉------队列堆积没人知道,直到业务方发现"咦,我的延迟消息怎么没触发?"。
java
private void monitor() {
monitorExecutor.scheduleAtFixedRate(() -> {
// 1. 消息积压告警
int size = blockingQueue.size();
if (size > MAX_QUEUE_SIZE) {
log.fatal("线程池工作组队列积压,size:{}", size);
}
// 2. 消费线程存活检查 + 自动重启
if (getActiveCount() - 1 < workerThreads) {
int closeThreadsNum = getPoolSize() - getActiveCount();
for (int i = 0; i < closeThreadsNum; i++) {
start(); // 启动新消费线程
}
}
}, 0, MONITOR_INTERVAL, TimeUnit.SECONDS);
}
⚠️ "消费线程默默死掉"的根因通常是消费里抛了 RuntimeException 没 catch 干净 ,或者线程被 InterruptedException 中断 。监控自愈不是治本,治本是把
worker.run()包成"无论如何不抛出去"。
七、关键设计 5:MDC 链路 trace 透传(异步任务不能断链)
这是最容易被忽略、但生产排查时最致命的一环。
MDC = Mapped Diagnostic Context,日志框架(Log4j2 / Logback)提供的线程级上下文 ,
requestId这类全链路追踪字段就存在它里面,底层是ThreadLocal。
7.1 不做 MDC 透传会怎样?
业务链路通常这样:
text
[HTTP 请求 requestId=abc123]
↓ HTTP 线程
业务逻辑 → publishEvent(DelayEvent.create(event, 73s))
↓ Spring 多播器
进 MQ → 消费侧 → 进 Redis 延迟队列
↓ 13s 后
Redis 消费线程 poll → publishEvent(event)
↓
业务 Listener 处理 → 写日志
问题 :Redis 消费线程跑出来的 Listener,MDC 是空的 ------因为 MDC 绑定的是 ThreadLocal,跨线程不会自动传递。日志打出来 requestId 是空的(或者一个新的随机值),全链路追踪一断,排查问题想哭。
7.2 怎么修
入口 :在 DelayEventTask 构造时(producer 侧线程)抓 MDC 快照存进对象,run 时(consumer 侧线程)把快照重新设置回当前线程 。三步都是标准 MDC API:getCopyOfContextMap() 抓、setContextMap() 还原、clear() 清理:
java
@Log4j2
public class DelayEventTask implements Runnable, Serializable {
private final EventEnvelope eventEnvelope;
/** 提交延迟任务时的 MDC 快照,用于到期后还原 trace */
private final Map<String, String> mdcContext;
public DelayEventTask(EventEnvelope eventEnvelope) {
this.eventEnvelope = eventEnvelope;
// 关键:在构造函数里抓快照(此时还在 producer 线程上下文)
this.mdcContext = MDC.getCopyOfContextMap();
}
@Override
public void run() {
boolean hasTracer = false;
try {
// 把 producer 端抓到的链路标识,重新设置到当前(消费)线程,trace 就不断了
if (mdcContext != null && !mdcContext.isEmpty()) {
MDC.setContextMap(mdcContext);
hasTracer = true;
}
log.info("执行任务");
// ... 反序列化 event ...
applicationContext.publishEvent(event);
} catch (Exception e) {
log.error("延迟消息执行错误!", e);
} finally {
// 必须清理,避免线程池里的下个任务拿到上个任务的脏 MDC
if (hasTracer) {
MDC.clear();
}
}
}
}
7.3 两个关键细节
细节 1 :MDC 快照必须在构造函数抓,不能在 run 里抓
java
// ❌ 错误:run 里抓 MDC,此时已经是消费线程,MDC 是空的
public void run() {
Map<String, String> ctx = MDC.getCopyOfContextMap(); // 抓到的是 null 或新的
}
// ✅ 正确:构造函数里抓,此时还在 producer 线程
public DelayEventTask(EventEnvelope eventEnvelope) {
this.mdcContext = MDC.getCopyOfContextMap();
}
构造函数是在 producer 线程 调用的(就在 delayedQueue.offer(runnable, ...) 这一行之前),所以能拿到 producer 端的 MDC。
细节 2 :finally 里必须 clear
java
} finally {
if (hasTracer) {
MDC.clear(); // 清理,避免污染下个任务
}
}
为什么?消费线程池里的线程是复用 的------当前任务的 MDC 不清,下个任务上来就继承了上个任务的 requestId,日志全乱套。
7.4 这套模式不止用于延迟消息
runnable 构造抓 MDC + run 还原 + finally 清理 是异步任务跨线程 trace 透传的通用范式:
- 提交到
ThreadPoolExecutor的 Runnable / Callable - 提交到
ScheduledExecutorService的定时任务 - 进
RBlockingQueue/LinkedBlockingQueue的任务对象 - 发到 RocketMQ / Kafka 的消息(producer 侧写 MDC 进 message header,consumer 侧读出来还原)
熟悉了这个模式,以后任何"业务异步执行 + 日志要追踪" 的场景都能套用。
💡 进阶 :Java 8+ 也可以用
CompletableFuture.supplyAsync(() -> ..., contextAwareExecutor)配合MdcTaskDecorator,在线程池层面自动透传。但本方案的"任务对象自带 MDC 快照"更显式可控,适合不能改线程池实现的场景。
八、潜在风险 + 改进方向
8.1 已知风险
| # | 风险 | 影响 | 改进 |
|---|---|---|---|
| 1 | Redis 持久化依赖 | 没开 AOF + 主从时,Redis 重启丢消息 | 开 AOF every-second,主从 + 哨兵 |
| 2 | hash 分片不均 | 某 worker 偏热,其他空闲 | 用一致性 hash 或随机分配 |
| 3 | 重试只 1 次 | 重试失败的消息丢失 | 失败次数 ≥ N 落 DB + 人工告警 |
| 4 | offset > 300 直接抛异常 |
业务方调用易踩坑 | 上层做参数校验 + 文档提示 |
| 5 | 监控自愈是"治标" | 消费抛异常的根因没解决 | 强制 worker.run() 包成"绝不抛出去" |
8.2 什么时候不要用这套方案?
- 延迟超过 2 小时:RocketMQ 4.x 单级最大 2h,超出就要叠加(不优雅),建议直接升 5.x
- 消息量极大(单天 > 千万):Redis 延迟队列会成瓶颈,建议时间轮 + 数据库
- 强一致性要求:本方案是"最终一致",事务感知能解一部分,但 Redis 这一段没法做强一致
九、总结
这套方案的核心思想是 "贴近物理介质的能力边界,各取所长":
- RocketMQ 4.x 18 个级别 = 物理实现限制 → 用作"粗粒度打底"
- Redis 延迟队列 = 内存 zset,毫秒精度但容量有限 → 用作"精度补齐",约束 ≤ 5 分钟
- Spring 事件 = 业务方语义最自然的入口 → 包装成
DelayEvent让业务无感
再加上事务感知 和MDC 链路透传两个工程实践,基本能扛住生产级业务。
9.1 一图流总结
text
┌───────────────────────────────────────────────┐
│ 业务代码 applicationContext.publishEvent( │
│ DelayEvent.create(event, 73s) │
│ ) │
└──────────┬────────────────────────────────────┘
↓
┌───────────────────────────────────────────────┐
│ DelayEventMulticaster (@Primary) │
│ - 二分找到 ≤73 的最大级别: d_1min(60) │
│ - offset = 13 │
│ - 事务里? → 注册 TransactionSynchronization │
└──────────┬────────────────────────────────────┘
↓ 事务提交后
┌────────────────────────────┐
│ RocketMQ sendDelay 60s │
└──────────┬─────────────────┘
↓ 60s 后
┌────────────────────────────┐
│ Consumer 收到消息 │
│ offset > 0 → 进 Redis │
└──────────┬─────────────────┘
↓
┌────────────────────────────────────────┐
│ DelayEventTask │
│ - 构造时抓 producer 端 MDC 快照 │
│ - 进 Redisson RDelayedQueue,等 13s │
└──────────┬─────────────────────────────┘
↓ 13s 后
┌────────────────────────────────────────┐
│ run(): │
│ - 还原 MDC:setContextMap() │
│ - publishEvent(event) → 业务 Listener │
│ - finally: MDC.clear() │
└────────────────────────────────────────┘
9.2 你能从这篇带走什么
- 延迟消息选型框架:4 种方案 + 各自适用场景
- "贴近介质能力边界" 的思维:不要硬刚物理限制,组合现有组件各取所长
- MDC 跨线程透传范式 :
构造抓 + run 还原 + finally 清理,套用所有异步任务 - 事务感知发消息 :
TransactionSynchronizationManager+afterCompletion
十、延伸阅读
- 前作:《@RemoteEvent 自动事件总线:1 个注解换 60 个 Consumer,赚还是亏?》 --- 本文中"Spring 事件 → RocketMQ"的事件总线设计来源
- 前作:《事务回滚了消息却发出去:Spring 事务消息的 4 种姿势对比》 --- 事务感知发消息的 4 种实现细节
- 同主题:《RocketMQ 死信队列实战:消息重试 18 次后去哪了?从告警到消费全流程拆解》
- 同主题:《RocketMQ 5.x 集群部署实战:3 台机器搞定 2 主 2 从,Docker Host 模式一把梭》
- 同主题:《从 MQ 积压追到事件总线:诊断 4K 线程吃光 7G 内存的实战》
🏷️ 标签 :RocketMQ 延迟消息 Redisson MDC 链路追踪 Spring 事件 事务消息