RocketMQ 4.5.1 延迟消息"发送成功但消费不到"排查分析报告

RocketMQ 4.5.1 延迟消息"发送成功但消费不到"排查分析报告

注:本文AI含量100%,内容由人工校对

一、问题背景

环境信息: RocketMQ 4.5.1,3 Master 集群(gaia_pro_rocketmq1/2/3),生产 broker 配置 maxMessageSize=65536(64KB,远低于默认 4MB)。

故障现象: 业务方反馈延迟消息发送成功(控制台按 msgId 能在 SCHEDULE_TOPIC_XXXX 搜到),但经过一段时间后消息从 SCHEDULE_TOPIC 消失,目标业务 Topic 始终没有收到消息,消费者无法正常消费。

影响范围: 涉及延迟消息链路的所有业务 Topic,包括 OLD_OPEN_API_DELIVERY_ORDER_ASYNC_PUSH_TOPIC 等。


二、排查步骤时间线

阶段一:代码分析 --- 定位延迟消息投递机制

假设: 延迟消息投递链路存在代码缺陷或配置问题。

操作: 在 RocketMQ 4.5.1 源码中追踪延迟消息的完整生命周期。

关键发现:

延迟消息的写入发生在 CommitLog.putMessage() 方法中(CommitLog.java:554-568)。当 msg.getDelayTimeLevel() > 0 时,消息的 topic 被改写为 SCHEDULE_TOPIC_XXXX,queueId 改为 delayLevel - 1,原始 topic 和 queueId 备份到消息属性 PROPERTY_REAL_TOPICPROPERTY_REAL_QUEUE_ID 中:

java 复制代码
// CommitLog.java:554-568 --- 延迟消息写入侧
if (msg.getDelayTimeLevel() > 0) {
    if (msg.getDelayTimeLevel() > this.defaultMessageStore
            .getScheduleMessageService().getMaxDelayLevel()) {
        msg.setDelayTimeLevel(this.defaultMessageStore
            .getScheduleMessageService().getMaxDelayLevel());
    }
    topic = ScheduleMessageService.SCHEDULE_TOPIC;
    queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());
    // Backup real topic, queueId
    MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
    MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID,
        String.valueOf(msg.getQueueId()));
    msg.setTopic(topic);
    msg.setQueueId(queueId);
}

延迟消息的回投由 ScheduleMessageService.DeliverDelayedMessageTimerTask.executeOnTimeup() 负责(ScheduleMessageService.java:262-372)。该方法从 ConsumeQueue 读取索引,逐条判断是否到期,到期后调用 messageTimeup() 恢复原始 topic/queueId,再通过 putMessage() 写回真实 topic:

java 复制代码
// ScheduleMessageService.java:300-326 --- 回投核心逻辑
if (countdown <= 0) {
    MessageExt msgExt = defaultMessageStore.lookMessageByOffset(offsetPy, sizePy);
    if (msgExt != null) {
        MessageExtBrokerInner msgInner = this.messageTimeup(msgExt);
        PutMessageResult putMessageResult = writeMessageStore.putMessage(msgInner);
        if (putMessageResult != null
            && putMessageResult.getPutMessageStatus() == PutMessageStatus.PUT_OK) {
            continue;  // 成功,继续下一条
        } else {
            log.error("ScheduleMessageService, a message time up, but reput it failed, "
                + "topic: {} msgId {}", msgExt.getTopic(), msgExt.getMsgId());
            timer.schedule(new DeliverDelayedMessageTimerTask(
                this.delayLevel, nextOffset), DELAY_FOR_A_PERIOD); // 10s后重试
            updateOffset(this.delayLevel, nextOffset);
            return;  // 阻塞!后续消息全部等待
        }
    }
}

关键设计特征: 同一 delayLevel 对应同一个 queueId,消息严格按 offset 顺序串行投递。一条消息 putMessage 失败会阻塞整个队列后续所有消息。

结论: 代码层面确认了多个可能导致"消息滞留 SCHEDULE_TOPIC"的环节,需要在生产日志中逐一排查。


阶段二:日志采集缺失问题

问题: 原始日志采集系统(Kibana/ES)缺少 RocketMQ broker 的关键日志文件。store.logstoreerror.log 等核心日志虽然能查到,但部分 broker 内部日志(如 transaction.logwatermark.logstats.log 等)未被完整采集到 ES 平台,导致排查过程中无法直接看到延迟消息调度及事务消息检查的完整链路日志。

措施: 已联系运维团队,要求增加 RocketMQ broker 关键日志目录(/usr/local/rocketmq/logs/rocketmqlogs/)的采集配置,确保后续排查时能看到完整的 broker 端日志链路。


阶段三:日志排查 --- 发现 reput it failed

假设: 延迟消息到期后回投真实 topic 失败。

操作: 通过 Kibana(log.shuhaisc.ltd)查询 broker 端 storeerror.logstore.log,使用 tags:"rocketmqlogs" 过滤 broker 服务端日志。

关键发现:

大量 reput it failed 日志,核心卡死消息为 msgId AC16C3A400002A9F000005D905ECE8EE(broker gaia_pro_rocketmq2),从 2026-08-21 02:35 开始每 10 秒重试一次(DELAY_FOR_A_PERIOD=10000ms 特征),持续到 2026-08-23 15:26,同一消息永久卡死,全集群 reput it failed 共 23502 条。

紧跟 reput it failed 的关键日志:

arduino 复制代码
WARN ScheduleMessageTimerThread - message size exceeded,
  msg total size: 65550, msg body size: 65286, maxMessageSize: 65536

结论: 消息回投时触发了 MESSAGE_SIZE_EXCEEDED 校验,putMessage 返回非 PUT_OK,消息每 10 秒无限重试。


阶段四:本地复现测试 --- 140KB 消息仍能成功

假设: 消息 body 超过 maxMessageSize=65536 导致回投失败。

操作: 用户在本地环境(同样配置 maxMessageSize=65536)发送 140KB 的延迟消息(delayLevel=1),消息正常投递并消费成功。

结论: 140KB 远超 64KB 限制却能正常投递,说明问题不是简单的 body 超限。经过代码分析发现,MQ 客户端在发送时会对消息 body 进行 zlib 压缩(默认压缩级别 5) ,压缩阈值为 4KB(compressMsgBodyOverHowmuch = 1024 * 4)。140KB 的 body 经过压缩后体积大幅缩小,加上 topic、属性等其他字段后,总 msgLen 仍小于 64KB,因此能通过 broker 的 maxMessageSize 校验。

关键代码位于 DefaultMQProducerImpl.tryToCompressMessage():883-904):

java 复制代码
// DefaultMQProducerImpl.java:883-904 --- 客户端发送时自动压缩
private boolean tryToCompressMessage(final Message msg) {
    if (msg instanceof MessageBatch) {
        return false; // 批量消息不压缩
    }
    byte[] body = msg.getBody();
    if (body != null) {
        if (body.length >= this.defaultMQProducer.getCompressMsgBodyOverHowmuch()) {
            try {
                byte[] data = UtilAll.compress(body, zipCompressLevel); // zlib 级别 5
                if (data != null) {
                    msg.setBody(data); // 替换为压缩后的 body
                    return true;
                }
            } catch (IOException e) { ... }
        }
    }
    return false;
}

压缩标志通过 sysFlag |= MessageSysFlag.COMPRESSED_FLAG:716)写入消息头,broker 存储的是压缩后的 body


阶段五:代码深入分析 --- 发现三阶段消息大小校验差异

假设: 正常消息发送、延迟消息写入 SCHEDULE_TOPIC、延迟消息回投真实 topic 三个阶段,消息大小校验的输入不同。

操作: 分析 CommitLog.calMsgLength() 的计算公式,对比三个阶段的消息属性差异。

关键发现:

消息总长度计算公式:msgLen = 84(固定头) + 4 + bodyLength + 1 + topicLength + 2 + propertiesLength

broker 端校验点在 CommitLog.AppendMessageCallback.doAppend():1252):

java 复制代码
// CommitLog.java:1252-1255 --- broker 写入时的大小校验
if (msgLen > this.maxMessageSize) {
    CommitLog.log.warn("message size exceeded, msg total size: " + msgLen
        + ", msg body size: " + bodyLength
        + ", maxMessageSize: " + this.maxMessageSize);
    return new AppendMessageResult(AppendMessageStatus.MESSAGE_SIZE_EXCEEDED);
}

三个阶段的消息大小差异:

阶段 topic 名 body properties 校验结果
正常消息发送 业务 topic(如 30 字节) 压缩后 body(如 10KB) 业务自定义属性 通常能通过(body 已压缩)
延迟消息写入 SCHEDULE_TOPIC SCHEDULE_TOPIC_XXXX(18 字节,极短) 压缩后 body 业务属性 + REAL_TOPIC + REAL_QUEUE_ID 更容易通过:topic 名最短(18 字节)
延迟消息回投真实 topic 真实业务 topic(如 30+ 字节,比 SCHEDULE 长) 同一个压缩后 body 业务属性 + REAL_TOPIC + REAL_QUEUE_ID未清除 可能失败:topic 变长 + 残留属性

核心差异在于:

  1. topic 名长度变化 :写入 SCHEDULE 时 topic 为 SCHEDULE_TOPIC_XXXX(18 字节),回投时恢复为真实 topic(通常更长),topicLength 增加导致 msgLen 增大。

  2. properties 未清理messageTimeup() 方法(:374-402)只清除了 PROPERTY_DELAY_TIME_LEVEL:393),没有清除 PROPERTY_REAL_TOPICPROPERTY_REAL_QUEUE_ID。这些属性在写入 SCHEDULE_TOPIC 时已经存在,回投时仍然保留,占用额外的 propertiesLength

  3. body 本身不变 :body 在整个生命周期中保持压缩状态(msgInner.setBody(msgExt.getBody()):376),压缩/解压不影响大小差异。

结论: 差异的根源是 topic 名变长 + properties 残留属性 ,使得同一条消息在"写入 SCHEDULE_TOPIC"和"回投真实 topic"两个阶段的 msgLen 不同。当消息总长恰好卡在 maxMessageSize 边界时,写入 SCHEDULE 能通过,回投则失败。


阶段六:CommitLog 离线导出 --- 验证卡死机制

假设: 卡死消息的 sizePy 逼近 64KB 上限,且后续正常消息被阻塞。

操作: 为验证根因机制,在本地环境构造了与生产相同条件的测试数据(maxMessageSize=65536,delayLevel=1),编写 Python 脚本解析 ConsumeQueue + CommitLog 二进制文件,导出本地测试环境的 SCHEDULE_TOPIC 队列数据进行离线分析。

关键发现:

导出 12851 条本地测试消息,结果清晰分为两类:

类别 cqOffset 范围 数量 sizePy 特征 状态
卡死消息 1956138~1956140 3 条 65533/65534/65536 含 ~5.4KB 大属性(本地测试数据中名为 bigProperty),回投超限
被阻塞的正常消息 1956141+ 12848 条 338 字节(body 113 字节) 完全正常,被前面卡死消息阻塞

卡死消息的属性中包含一个约 5.4KB 的大属性字段(本地测试数据中名为 bigProperty),导致 sizePy 逼近 64KB。回投时 topic 名变长 + 残留属性,总长超过 maxMessageSize

结论: 本地测试数据复现了与生产相同的卡死机制------大属性消息卡在队列头部,后续所有正常消息被阻塞。这验证了根因分析的正确性。


阶段七:Offset 错乱现象(根因的衍生表现)

现象描述: 在排查过程中,broker storeerror.log 发现 约 113.9 万条 Offset not matched 错误,其中约 41%(46 万条)的特征为 mappedFileSize: 300000------该值远小于普通业务 topic 的 ConsumeQueue 映射大小(通常为 1GB),符合系统内部队列(如 SCHEDULE_TOPIC)的配置特征。

yaml 复制代码
WARN AdminBrokerThread - Offset not matched.
  Request offset: 698640, firstOffset: 0, lastOffset: 300000, mappedFileSize: 300000

分析: 这些 Offset 错乱不是独立根因 ,而是消息长期卡死的衍生表现。由于卡死消息导致 executeOnTimeup() 每 10 秒重试同一 offset(:320-325),offsetTable 中的 offset 被反复更新但实际投递进度停滞。与此同时,ConsumeQueue 文件可能因 broker 正常清理而过期,导致持久化的 offset 与实际数据范围脱节。事务消息 RMQ_SYS_TRANS_HALF_TOPICOFFSET_ILLEGAL 也属于同类衍生现象。

结论: Offset 错乱是"大消息卡死 → 调度进度异常 → 与 ConsumeQueue 实际范围脱节"的连锁反应,不是独立的存储层故障。


阶段八:排查 TOOLS_CONSUMER 干扰线(弯路)

假设: 消费者端 TOOLS_CONSUMER 大量订阅全量 topic 导致正常消费者抢不到队列。

操作: 分析 order_item_quantity topic 的消费统计,发现 TOOLS_CONSUMER 拉取 TPS 740 但消费 TPS 仅 1.28。

排除原因: TOOLS_CONSUMER 是 RocketMQ 内置的系统消费者组,由 mqadmin 命令行工具、Dashboard 控制台、监控系统(如 rocketmq-exporter)在查询消费进度/延迟统计时临时注册,不会真正消费业务消息。日志验证确认 TOOLS_CONSUMER 的 GROUP_GET_NUMS 数据量极小,属于监控查询行为。


三、核心问题分析

根因:大属性消息导致延迟消息回投失败,串行阻塞后续消息

问题链路:

  1. 业务方发送了携带大自定义属性(bigProperty ~5.4KB,本地测试数据中的属性名)的延迟消息,存储的 body 大小约 59.8KB
  2. 写入阶段 :消息被改写 topic 为 SCHEDULE_TOPIC_XXXX(仅 18 字节),msgLen 恰好 < 65536,校验通过 → SEND_OK
  3. 回投阶段messageTimeup() 恢复真实 topic(名字比 18 字节长)+ 残留 REAL_TOPIC/REAL_QUEUE_ID 属性未清除,msgLen = 65550 > 65536 → MESSAGE_SIZE_EXCEEDED
  4. executeOnTimeup() 走入 :312-326 分支:每 10 秒重试同一 offset,不跳过该消息
  5. 由于同一 delayLevel 严格串行,后续所有正常消息全部被阻塞(本地测试中观察到 12848 条正常消息被阻塞)
  6. 衍生问题 :调度进度长期停滞 → offset 与 ConsumeQueue 实际范围脱节 → 触发大量 Offset not matched 错误

代码定位:

  • 写入侧:CommitLog.java:554-568(topic 改写 + 属性备份)
  • 回投侧:ScheduleMessageService.messageTimeup():374-402(恢复 topic,但未清理残留属性)
  • 失败处理:ScheduleMessageService.executeOnTimeup():312-326(putMessage 失败后 return,不跳过)
  • 大小校验:CommitLog.doAppend():1252-1255(msgLen > maxMessageSize 拒绝写入)

四、解决方案对比

方案对比表

方案 是否需要停机 实施复杂度 风险 是否采用
A. 修改 delayOffset.json 跳过卡死消息 需要重启 Broker 中(需单节点停服) ❌ 未采用
B. 调整业务端代码(限制属性大小) 不需要 最终采用
C. 重建 ConsumeQueue 需要重启 Broker 高(CommitLog 可能不完整) ❌ 未采用
D. 调整 maxMessageSize 配置 需要重启 Broker 中(可能引入其他问题) ❌ 未采用
E. Arthas 热更新调整消息限制大小 不需要 高(修改运行时常量) ⚠️ 备选
F. Arthas 调整内存中的 delayOffset 不需要 中(需精确定位内存地址) ⚠️ 备选
G. 手动修改 ConsumeQueue 投递时间戳 不需要 极高 极高(二进制文件操作,单独使用无效) ⚠️ 理论方案

方案 A:修改 delayOffset.json(需停机,未采用)

原理: ScheduleMessageService 的投递进度存储在 $storeRoot/config/delayOffset.json 中,结构为 {"offsetTable": {1:1956138, ...}}。将对应 delayLevel 的 offset 向前推进到卡死消息之后(如从 1956138 改为 1956141),跳过卡死消息。

操作步骤: 停止目标 Broker → 备份 store 目录 → 编辑 delayOffset.json → 重启 Broker。

未采用原因: 需要重启 Broker,生产环境停机窗口难以协调。

注意: 控制台的"重置消费位点"(resetOffsetByTime无效,因为它操作的是消费组的 consumer offset,不是 ScheduleMessageService 的 delayOffset。两者是完全独立的存储。

方案 B:调整业务端代码(最终采用)

操作: 业务方修改发送端代码,从以下两个方面确保消息总长在回投阶段不超过 maxMessageSize

  1. 限制自定义属性大小 :对发送消息时附加的自定义属性(如 bigProperty)进行大小检查,超过阈值时进行截断、压缩或拒绝发送。建议单个属性值不超过 2KB,所有属性总长不超过 4KB。
  2. 控制 body 大小 :确保消息 body(压缩前)不超过合理范围,避免压缩后仍逼近 maxMessageSize 限制。

实施要点:

  • 需要业务方发版上线,属于计划内变更,可选择低峰期发布
  • 修改后需验证延迟消息在回投阶段能正常通过 maxMessageSize 校验
  • 建议同时在发送端增加 msgLen 预估日志,便于后续监控

优点: 无需停机,从源头解决问题,风险最低。修复后不仅解决当前卡死问题,还能预防后续同类问题。

方案 C:重建 ConsumeQueue(需停机,未采用)

操作步骤: 停止 Broker → 备份 store → 删除 store/consumequeue/SCHEDULE_TOPIC_XXXX/ → 重启 Broker 自动重建。

未采用原因: 需要停机,且如果 CommitLog 也有损坏则重建不完整。

方案 D:调整 maxMessageSize 配置(需停机,未采用)

操作:broker.confmaxMessageSize 从 65536 调大(如默认 4MB)。

未采用原因: 需要重启 Broker,且调大限制可能引入其他问题。

方案 E:Arthas 热更新调整消息限制大小(不停机备选)

原理: 使用阿里巴巴开源的 Java 诊断工具 Arthas,在线修改 Broker 进程中 CommitLog 内部类 DefaultAppendMessageCallbackmaxMessageSize 运行时值。

操作思路:

bash 复制代码
# 1. 连接目标 Broker JVM
java -jar arthas-boot.jar <broker_pid>

# 2. 通过 vmtool 获取 CommitLog 实例,查看当前 maxMessageSize
vmtool --action getInstances \
  --className org.apache.rocketmq.store.CommitLog \
  --express 'instances[0].getMaxMessageSize()'

# 3. 修改 DefaultAppendMessageCallback 实例的 maxMessageSize 字段
#    (DefaultAppendMessageCallback 是 CommitLog 的内部类,需通过反射或 vmtool 访问)
vmtool --action getInstances \
  --className 'org.apache.rocketmq.store.CommitLog$DefaultAppendMessageCallback' \
  --express 'instances[0].maxMessageSize=4194304'

风险: maxMessageSize 可能是 final 字段(取决于版本),vmtoolfinal 字段的修改可能无效。此外,修改运行时常量可能影响 broker 其他行为(如内存分配策略),需要充分测试。建议在测试环境验证后再考虑生产使用。

方案 F:Arthas 调整内存中的 delayOffset(不停机备选)

原理: 通过 Arthas 直接修改 ScheduleMessageService 内存中 offsetTable 的值,跳过卡死消息的 offset。

操作思路:

bash 复制代码
# 1. 获取 ScheduleMessageService 实例
vmtool --action getInstances \
  --className org.apache.rocketmq.store.schedule.ScheduleMessageService \
  --express 'instances[0].offsetTable'

# 2. 修改 delayLevel=1 的 offset 到卡死消息之后
vmtool --action getInstances \
  --className org.apache.rocketmq.store.schedule.ScheduleMessageService \
  --express 'instances[0].offsetTable.put(1, 1956141L)'

优点: 不需要重启 Broker,直接跳过卡死消息。

风险: 修改后需等待 persist() 自动刷盘(间隔 flushDelayOffsetInterval=10s),否则重启后 offset 回退又会重新卡住。可通过 vmtool 手动触发 persist() 加速。

方案 G:手动修改 ConsumeQueue 投递时间戳(理论方案)

原理: 延迟消息的投递时间由写入时计算的 tagsCode(即 deliverTimestamp = storeTimestamp + delayLevel)决定,该值存储在 ConsumeQueue 的 20 字节索引条目中(第 13-20 字节)。理论上可通过修改 ConsumeQueue 中卡死消息条目的 tagsCode 为一个已过去的时间戳,使 countdown <= 0 条件成立,从而让调度服务认为消息已到期并尝试投递。

操作思路: 需要精确定位卡死消息在 ConsumeQueue 中的条目位置(offsetPy / CQ_STORE_UNIT_SIZE),然后二进制修改对应条目的 tagsCode 字段(8 字节,条目内偏移 12 处)为一个过去的时间戳。但即使投递时间条件满足,putMessage 仍会因消息大小超限而失败,因此该方案单独使用无效 ,需配合方案 E(调大 maxMessageSize)才有意义。

风险: 极高。直接操作 ConsumeQueue 二进制文件,任何错误都可能导致索引损坏、消息丢失或调度服务异常。仅作为理论方案记录,不建议在生产环境实施。


五、不同方向的尝试与排除

方向一:延迟消息调度线程未启动(SLAVE 角色)

假设: Broker 角色变为 SLAVE 导致 ScheduleMessageService 被 shutdown。

排除原因: 通过节点状态确认故障节点 brokerRole=MASTERputTps 正常,scheduleMessageOffset 有推进记录。

方向二:messageTimeup 抛异常导致丢消息

假设: PROPERTY_REAL_QUEUE_ID 缺失导致 Integer.parseInt NPE,消息被 drop。

排除原因: 查询 messageTimeup execute error 日志为 0 条。

方向三:schedule CQ offset invalid(offset 低于最小值)

假设: 持久化 offset 小于 ConsumeQueue 最小 offset,触发 :361-366 的修正逻辑。

排除原因: 查询 schedule CQ offset invalid 日志为 0 条。实际走的是另一个路径------offset 大于 maxOffset(代码未处理)。

方向四:TOOLS_CONSUMER 抢占队列

假设: TOOLS_CONSUMER 全量订阅所有 topic,抢占业务消费者的队列分配。

排除原因: TOOLS_CONSUMER 是 RocketMQ 内置系统消费者组,由 mqadmin/Dashboard/监控系统查询统计时临时注册。日志验证其 GROUP_GET_NUMS 数据量极小,属于监控查询行为,不真正消费业务消息。

方向五:body 超过 maxMessageSize

假设: 消息 body 直接超过 64KB 限制。

排除原因: 本地测试 140KB 消息在同样配置下正常投递。原因是 MQ 客户端在发送时会自动对 body 进行 zlib 压缩(级别 5,阈值 4KB),140KB body 压缩后远小于 64KB。实际问题是发送阶段和回投阶段的消息总长度不同(topic 名变长 + 残留属性),而非 body 本身超限。

方向六:gaia_pro_rocketmq3 节点存储层系统性故障

假设: Offset not matchedOFFSET_ILLEGAL 表明 ConsumeQueue/CommitLog 一致性故障。

排除原因: 这些 Offset 错乱是消息长期卡死的衍生表现,不是独立的存储层故障。卡死消息导致调度进度停滞,同时 offset 被反复更新,最终与 ConsumeQueue 实际范围脱节。


六、流程图

6.1 延迟消息完整流转流程

flowchart TD subgraph Producer[&#34;生产者&#34;] A[&#34;业务消息<br/>setDelayTimeLevel&#34;] A1[&#34;tryToCompressMessage()<br/>body > 4KB 时 zlib 压缩<br/>级别 5&#34;] end subgraph Broker_Write[&#34;Broker 写入阶段&#34;] B{delayTimeLevel > 0?} C[&#34;CommitLog.putMessage()<br/>CommitLog.java:554-568<br/>──────────────<br/>topic → SCHEDULE_TOPIC_XXXX<br/>queueId → delayLevel - 1<br/>备份 REAL_TOPIC / REAL_QUEUE_ID&#34;] D[&#34;calMsgLength 校验<br/>CommitLog.java:1252<br/>──────────────<br/>msgLen = 84 + body + topic + props<br/>msgLen > maxMessageSize → 拒绝&#34;] E[写入 CommitLog<br/>+ ConsumeQueue 索引] end subgraph Schedule[&#34;ScheduleMessageService 调度&#34;] F[&#34;executeOnTimeup()<br/>:262-372&#34;] G[&#34;cq.getIndexBuffer(offset)<br/>读取 ConsumeQueue 索引&#34;] H{offset 有效?} I{countdown <= 0?<br/>消息是否到期} J[&#34;messageTimeup()<br/>:374-402<br/>──────────────<br/>恢复 realTopic / realQueueId<br/>清除 DELAY_TIME_LEVEL<br/>⚠️ 未清除 REAL_TOPIC/REAL_QUEUE_ID&#34;] K[&#34;writeMessageStore<br/>.putMessage(msgInner)<br/>:308-310&#34;] L{putMessage<br/>返回 PUT_OK?} M[继续下一条消息<br/>continue] N[&#34;reput it failed<br/>:316-325<br/>10s 后重试同一 offset<br/>⚠️ 阻塞后续所有消息&#34;] O[&#34;调度下一批次<br/>DELAY_FOR_A_WHILE 100ms&#34;] end subgraph Broker_Deliver[&#34;Broker 回投阶段&#34;] P[&#34;写入真实 Topic<br/>消费者可拉取&#34;] end subgraph Error_Paths[&#34;异常路径&#34;] Q[&#34;message size exceeded<br/>回投时 topic 变长 + 残留属性<br/>msgLen > maxMessageSize&#34;] R[&#34;messageTimeup execute error<br/>NPE 等异常 → drop 消息&#34;] S[&#34;Offset 衍生错乱<br/>调度停滞 → offset 与 CQ 脱节&#34;] end A --> A1 --> B B -->|是| C B -->|否| P C --> D D -->|通过| E D -->|超限| Q E --> F F --> G G --> H H -->|null| S H -->|有效| I I -->|未到期| O I -->|已到期| J J --> K K --> L L -->|PUT_OK| M L -->|非 PUT_OK| N L -->|异常| Q J -->|NPE 等| R M --> F O --> F N --> F S --> F style Error_Paths fill:#ffcccc,stroke:#cc0000 style N fill:#ff9999,stroke:#cc0000 style Q fill:#ff9999,stroke:#cc0000 style R fill:#ff9999,stroke:#cc0000 style S fill:#ffccaa,stroke:#cc6600

6.2 故障链路全景图

flowchart TD subgraph Root_Cause[&#34;根因:大属性消息 + 不对称校验&#34;] A1[&#34;业务发送大属性消息<br/>存储 body ~59.8KB<br/>+ 大属性 ~5.4KB&#34;] A2[&#34;写入 SCHEDULE_TOPIC<br/>topic=18字节(极短)<br/>总长 < 65536 ✅&#34;] A3[&#34;回投真实 topic<br/>topic 更长 + 残留属性<br/>总长 65550 > 65536 ❌&#34;] A4[&#34;MESSAGE_SIZE_EXCEEDED<br/>putMessage 返回失败&#34;] A5[&#34;executeOnTimeup :312-326<br/>每 10s 重试同一 offset<br/>不跳过 → 永久阻塞&#34;] end subgraph Cascade[&#34;连锁反应&#34;] B1[&#34;后续正常消息全部被阻塞<br/>(本地测试观察到 12848 条)&#34;] B2[&#34;调度进度长期停滞<br/>offsetTable 反复更新&#34;] B3[&#34;offset 与 ConsumeQueue 脱节<br/>触发 Offset not matched&#34;] B4[&#34;衍生影响事务消息<br/>RMQ_SYS_TRANS_HALF_TOPIC<br/>OFFSET_ILLEGAL&#34;] end subgraph Symptom[&#34;故障现象&#34;] C1[&#34;延迟消息发送成功 ✅&#34;] C2[&#34;SCHEDULE_TOPIC 可查到消息&#34;] C3[&#34;业务 Topic 收不到消息 ❌&#34;] C4[&#34;消费者无法消费 ❌&#34;] end A1 --> A2 --> A3 --> A4 --> A5 A5 --> B1 --> B2 --> B3 --> B4 B1 --> C3 C1 -.-> C2 C3 -.-> C4 style Root_Cause fill:#fff3cd,stroke:#856404 style Cascade fill:#f8d7da,stroke:#721c24 style Symptom fill:#d4edda,stroke:#155724

6.3 三阶段消息大小校验差异

flowchart TD subgraph Stage1[&#34;阶段一:客户端发送&#34;] S1A[&#34;原始 body: 140KB&#34;] S1B[&#34;zlib 压缩后: ~10KB&#34;] S1C[&#34;msgLen = 84 + 10KB + topic + props<br/>✅ 通过校验&#34;] S1A --> S1B --> S1C end subgraph Stage2[&#34;阶段二:写入 SCHEDULE_TOPIC&#34;] S2A[&#34;压缩 body: ~10KB(不变)&#34;] S2B[&#34;topic: SCHEDULE_TOPIC_XXXX<br/>18 字节(极短)&#34;] S2C[&#34;props: 业务属性 + REAL_TOPIC + REAL_QID&#34;] S2D[&#34;msgLen = 84 + 10KB + 18 + props<br/>✅ 通过校验(topic 最短)&#34;] S2A --> S2B --> S2C --> S2D end subgraph Stage3[&#34;阶段三:回投真实 topic&#34;] S3A[&#34;同一个压缩 body: ~10KB(不变)&#34;] S3B[&#34;topic: 真实业务 topic<br/>30+ 字节(更长)&#34;] S3C[&#34;props: 业务属性 + REAL_TOPIC + REAL_QID<br/>(未清除,仍然残留)&#34;] S3D[&#34;msgLen = 84 + 10KB + 30+ + props<br/>⚠️ 边界情况可能超限&#34;] S3A --> S3B --> S3C --> S3D end Stage1 --> Stage2 --> Stage3 style Stage2 fill:#d4edda,stroke:#28a745 style Stage3 fill:#f8d7da,stroke:#dc3545

七、经验总结

排查思路提炼

  1. 先代码后日志: 先通读 RocketMQ 延迟消息的完整代码链路(写入 → SCHEDULE_TOPIC → 调度 → 回投 → 真实 Topic),建立全局心智模型,再带着模型去日志中验证每个环节。

  2. 关注"不对称"设计: 本次问题的核心机制是"写入 SCHEDULE_TOPIC"和"回投真实 topic"两个阶段的 msgLen 不同(topic 名长度 + 残留属性)。排查 MQ 问题时,要特别关注消息在不同阶段的"形态变化"。

  3. 注意客户端压缩机制: MQ 客户端默认对 body > 4KB 的消息进行 zlib 压缩(级别 5),broker 存储的是压缩后的 body。排查消息大小问题时,必须考虑压缩因素------body 的原始大小不等于存储大小。

  4. 串行阻塞是延迟消息的致命弱点: RocketMQ 4.5.1 的 ScheduleMessageService 按 delayLevel 严格串行投递,一条消息失败会阻塞整个队列。排查"部分消息消费不到"时,要考虑是否被前面的"毒消息"阻塞。

  5. 区分 consumer offset 和 delayOffset: 控制台的"重置消费位点"操作的是 consumer offset(消费组的消费进度),而延迟消息的调度进度存在 delayOffset.json 中,两者完全独立。修复延迟消息卡死需要修改后者。

  6. 日志入库时间 ≠ 业务时间: Kibana 的 @timestamp 是日志采集入库时间,可能与日志内容中的业务时间相差数小时甚至一天。排查时必须以日志内容中的时间戳为准。

  7. 区分根因与衍生现象: 排查过程中发现的 Offset not matchedOFFSET_ILLEGAL 等异常,看似是独立的存储层故障,实际是消息卡死的衍生表现。排查时要理清因果关系,避免被杂音误导。

注意事项

  • putMessage 失败不跳过: :312-326 分支在 putMessage 失败时 return 退出,不跳过该消息,后续消息全部被阻塞。生产环境需要监控 reput it failed 日志并及时介入。
  • maxMessageSize 配置需谨慎: 如果生产环境将 maxMessageSize 从默认 4MB 调小(如 64KB),要确保业务方不会发送接近该限制的大消息(含属性),否则可能在写入/回投的边界条件下触发问题。
  • messageTimeup 未清理残留属性: messageTimeup() 只清除了 PROPERTY_DELAY_TIME_LEVEL,未清除 PROPERTY_REAL_TOPICPROPERTY_REAL_QUEUE_ID。这是 RocketMQ 4.5.1 的设计缺陷,高版本已修复。
  • 日志采集要覆盖完整: 确保 broker 所有关键日志文件(store.logstoreerror.logtransaction.logwatermark.logstats.log 等)都被采集到日志平台,避免排查时信息缺失。
  • 不停机修复优先考虑: 生产环境停机成本高,排查问题时应优先考虑不停机方案(如 Arthas 热更新、业务端代码调整),将停机方案作为最后手段。
相关推荐
码上观网1 小时前
VXLAN 中的 ARP 代答:从原理到 SONiC 代码实现
后端
计算机毕设定制辅导-无忧学长1 小时前
《基于SpringBoot的马术俱乐部管理系统》
java·spring boot·后端
摇滚侠1 小时前
《SpringBoot 3:入门与应用实战》第 12 章 JDBC 与事务 JDBC 事务管理 阅读笔记 33
spring boot·笔记·后端
学长毕业设计2 小时前
基于SpringBoot的民间艺术传承管理系统(源码+文档+讲解视频)
java·spring boot·后端
小蒜学长2 小时前
基于Springboot+Vue的环保行动志愿者招募系统设计与实现(代码+数据库+LW)
java·数据库·vue.js·spring boot·后端
用户233376852182 小时前
Ubuntu上fpm多架构打包实战
后端
JavaGuide3 小时前
轻量开源版 IDEA 来了!
前端·后端
Json____3 小时前
springboot开发高校选课管理系统:从零构建前后端分离的全栈实践
java·spring boot·后端·毕业设计·毕设·wwwoop.com
站大爷IP3 小时前
Python的列表推导式把我写崩了,原来圆括号和方括号不是一回事
后端