RocketMQ源码深度解析(四)延迟消息&定时消息

一、延迟消息初步认知与业务价值

1.1 什么是延迟消息?

延迟消息是指:消息发送成功后,消费者不会立即消费,等待指定时间到达后才对消费者可见、允许消费的特殊消息类型。

RocketMQ 将延迟消息分为两类,架构完全不同:

  • 固定级别延迟消息(4.x 全量支持):仅支持官方预设18个固定延迟等级,无自定义时间,基于队列偏移量轮询实现,无时间轮

  • 任意时间定时消息(5.x+ 新特性):支持指定具体时间戳/任意延迟时长,基于时间轮算法 + TimerWheel + TimerLog 实现高精度定时调度

1.2 典型落地场景

  • 订单超时关闭、未支付自动取消

  • 超时未跟进任务提醒、用户行为延时回调

  • 延迟重试、故障延后补偿任务

  • 活动定时上线、定时推送、延迟通知

1.3 延迟消息核心优势

替代传统 JDK Timer、Scheduled线程、Redis ZSet延时队列

  • 分布式集群保障、宕机不丢失、持久化落地

  • 支持海量延时消息堆积,不占用业务内存

  • 自带重试、容错、恢复机制,稳定性远超业务自研定时任务


二、延迟消息生产核心关注点

无论是固定级别还是任意定时消息,生产落地与源码学习必须关注以下核心要点:

2.1 可靠性关注点

  • 延迟消息是否丢失?依赖 CommitLog 持久化 + 定时调度重试机制

  • 服务重启后延时进度是否保留?基于磁盘文件位点恢复,不丢失计时状态

  • 延时到期是否保证一定投递?支持定时扫描、过期兜底补发

2.2 性能关注点

  • 海量延时消息是否压垮Broker?分级队列隔离、时间轮槽位分片、批量扫描

  • 轮询扫描是否空转浪费CPU?时间轮精准调度、仅扫描当前到期槽位

  • 延时消息堆积是否影响普通消息?Topic物理隔离、线程池独立调度

2.3 业务规则关注点

  • 延迟消息不支持广播消费,仅支持集群消费

  • 延迟消息不支持事务消息叠加

  • 固定级别延迟仅18档,无法自定义任意时间(5.x前痛点)

  • 定时消息存在最大时长限制(默认支持7天内定时)


三、固定级别延迟消息源码全梳理(4.x核心)

3.1 18个固定延迟级别配置

RocketMQ 4.x 通过 messageDelayLevel 配置固定延迟档位,全局仅支持18级,不可自定义任意秒数:

1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h

级别与时间一一对应:level=1对应1s、level=18对应2h。

3.2 核心架构设计(无时间轮、纯队列轮询)

固定级别延迟消息未使用时间轮算法,采用「系统延迟Topic + 分级队列 + 后台轮询扫描」实现,架构极简、稳定性极高。

  • 系统内置Topic:SCHEDULE_TOPIC_XXXX(延迟消息专属主题)

  • 队列映射规则:QueueId = DelayLevel - 1,每一个延迟级别独占一个队列

  • 后台调度服务:ScheduleMessageService,为每一级延迟队列启动独立定时任务

3.3 完整源码执行流程

步骤1:Producer发送延迟消息

业务代码设置延迟级别,消息打上延迟属性标记:

Message msg = new Message("BUSINESS_TOPIC", "延迟消息内容".getBytes());
// 设置延迟级别
msg.setDelayTimeLevel(3); // 对应10秒延迟
rocketMQTemplate.syncSend(msg);

底层会给消息添加属性:PROPERTY_DELAY_TIME_LEVEL

步骤2:Broker拦截改写消息路由

Broker 在 CommitLog 写入前拦截判断,识别延迟消息后替换路由信息,核心源码:

// CommitLog.putMessage 核心分支
if (msg.getDelayTimeLevel() > 0) {
    // 校验延迟级别合法性
    if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
        msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
    }
    // 保存真实业务Topic、Queue到消息属性
    msg.putProperty(MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
    msg.putProperty(MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
    
    // 路由替换为系统延迟Topic + 对应级别队列
    msg.setTopic(SCHEDULE_TOPIC);
    msg.setQueueId(msg.getDelayTimeLevel() - 1);
}

核心逻辑:延迟消息不会进入业务Topic,先存入系统延迟Topic,实现消费者不可见,达到延时效果。

步骤3:消息持久化与索引分发

改写路由后的消息正常写入 CommitLog,后台分发线程生成对应 ConsumeQueue 索引,等待调度扫描。

步骤4:ScheduleMessageService 轮询调度

Broker 启动时初始化 ScheduleMessageService,为18个延迟级别分别开启独立循环任务:

  • 定时拉取对应延迟队列的消息

  • 对比消息投递时间与当前时间

  • 未到时间:跳过,等待下一轮扫描

  • 已到时间:恢复原始Topic、队列信息,重新投递

步骤5:到期消息重新投递消费

到期消息剔除延迟队列,恢复业务真实Topic,重新写入CommitLog、生成业务索引,消费者正常拉取消费,延迟流程结束。

3.4 固定级别延迟消息流程图

调度核心代码

public void executeOnTimeUp() {
    // 找到延迟队列对应的ConsumeQueue文件
    ConsumeQueueInterface cq = ScheduleMessageService.this.brokerController.getMessageStore()
            .getConsumeQueue(TopicValidator.RMQ_SYS_SCHEDULE_TOPIC,
                    delayLevel2QueueId(delayLevel));

    ReferredIterator<CqUnit> bufferCQ = cq.iterateFrom(this.offset);
    long nextOffset = this.offset;

    try {
        while (bufferCQ.hasNext() && isStarted()) {
            CqUnit cqUnit = bufferCQ.next(); // 获取一条ConsumeQueue记录
            long offsetPy = cqUnit.getPos();
            int sizePy = cqUnit.getSize();
            long tagsCode = cqUnit.getTagsCode();

            // 计算下一个ConsumeQueue单元的位置,下一次扫描就从这个地方开始
            long currOffset = nextOffset;
            nextOffset = currOffset + cqUnit.getBatchNum();

            // 计算延迟是否到期
            long deliverTimestamp = computeDeliverTimestamp(tagsCode);
            long countdown = deliverTimestamp - System.currentTimeMillis();
            if (countdown > 0) { // 还没到延迟时间
                this.scheduleNextTimerTask(currOffset, DELAY_FOR_A_WHILE);
                ScheduleMessageService.this.updateOffset(this.delayLevel, currOffset);
                return;
            }

            // 获取CommitLog中的实际消息
            MessageExt msgExt = ScheduleMessageService.this.brokerController.getMessageStore()
                    .lookMessageByOffset(offsetPy, sizePy);
            if (msgExt == null) {
                continue;
            }

            // 处理消息:转换为可投递的消息
            MessageExtBrokerInner msgInner = ScheduleMessageService.this.messageTimeUp(msgExt);

            // 时间到了就转储投递
            boolean deliverSuc;
            if (ScheduleMessageService.this.enableAsyncDeliver) { // 异步投递,默认false
                deliverSuc = this.asyncDeliver(msgInner, msgExt.getMsgId(), currOffset, offsetPy, sizePy);
            } else { // K9 固定延迟级别的延迟消息同步投递
                deliverSuc = this.syncDeliver(msgInner, msgExt.getMsgId(), currOffset, offsetPy, sizePy);
            }

            if (!deliverSuc) {
                this.scheduleNextTimerTask(nextOffset, DELAY_FOR_A_WHILE);
                return;
            }
        }
    } catch (Exception e) {
        log.error("ScheduleMessageService, messageTimeUp execute error, offset = {}, nextOffset = {}", this.offset, nextOffset, e);
    } finally {
        bufferCQ.release();
    }

    // 部署下一次任务
    this.scheduleNextTimerTask(nextOffset, DELAY_FOR_A_WHILE);
}

3.5 固定级别方案优缺点

优点:实现简单、无复杂算法、CPU开销极低、稳定性极强、适配超大规模集群

致命缺点:仅支持18个固定档位,不支持任意自定义时间,业务灵活性极差


四、RocketMQ 5.x 指定时间定时消息(任意延迟)

4.1 方案诞生背景

4.x 固定级别延迟无法满足「任意时间定时、精准延时」业务,RocketMQ5.0 基于 RIP-43 提案 全新实现时间轮算法定时消息,支持:

  • 任意时间戳定时(未来7天内)

  • 秒级精准调度

  • 支持定时消息取消、修改

  • 海量定时消息高性能调度

4.2 核心存储结构(定时消息双文件)

定时消息放弃固定队列轮询,采用TimerWheel时间轮 + TimerLog日志文件双结构协同存储:

  • TimerLog:顺序追加日志文件,存储所有定时消息的索引信息、CommitLog偏移量、时间戳,纯追加写入,性能极高

  • TimerWheel:时间轮索引文件,环形槽位结构,按时间维度分片,每一个槽位挂载对应时刻的TimerLog链表

4.3 指定时间定时消息完整流程

  1. 消息接收:Producer指定未来具体执行时间戳,发送定时消息

  2. 时间槽定位:Broker根据消息到期时间,计算对应的时间轮Slot槽位

  3. 写入TimerLog:追加写入定时索引,记录消息位置、时间、链表前驱指针

  4. 更新时间轮指针:当前槽位指向最新TimerLog记录,形成时间链表

  5. 时间轮驱动:后台线程秒级推进时间轮指针,遍历当前到期槽位

  6. 解析到期消息:根据槽位链表读取TimerLog,定位CommitLog真实消息

  7. 恢复投递:到期消息正常投递至业务Topic,完成定时调度


五、定时调度核心:时间轮算法深度详解

5.1 时间轮算法核心介绍

时间轮(TimeWheel)是一种高效定时任务调度算法,核心解决传统定时任务「遍历全量任务、CPU空转、轮询低效」的痛点,广泛用于中间件、网关、定时调度框架。

核心思想:将时间划分为固定粒度的环形槽位,时间指针匀速转动,仅处理当前时刻到期任务,无需遍历全部任务,时间复杂度 O(1)。

5.2 时间轮核心组成

  • 环形槽位Slot:将一段时间切分为N个时间窗口(RocketMQ为秒级槽位)

  • 时间指针:匀速向前推进,每秒移动一个槽位

  • 任务链表:每个槽位挂载当前时刻需要执行的所有定时任务

  • 底层存储:RocketMQ落地为TimerWheel磁盘文件,支持持久化不丢失

5.3 RocketMQ 原生磁盘时间轮完整实现原理(源码级)

普通内存时间轮仅适用于单机临时定时任务,重启丢失、无法支撑海量堆积。RocketMQ 5.x 基于 RIP-43 实现了磁盘持久化环形时间轮,完全脱离内存限制、支持重启恢复、支持7天超长定时、百万级消息调度,是中间件领域独有的工业级时间轮落地方案。

5.3.1 核心硬件与文件参数(固定不可改)

RocketMQ 时间轮采用固定精度、固定窗口、定长结构体设计,极致适配磁盘顺序读写:

  • 时间精度:1秒/槽,秒级精准调度

  • 时间窗口:默认覆盖7天

  • 总槽位数量:7×24×3600 个 Slot

  • TimerWheel 文件大小:固定约 37MB,极小内存占用、常驻磁盘

  • 调度模式:环形复用、指针步进、过期批量触发

5.3.2 Slot 槽位定长存储结构(核心数据结构)

每个时间槽位 Slot 固定占用 32Byte,结构化存储当前秒所有定时消息的链表头尾指针,是时间轮与 TimerLog 关联的核心桥梁,字段定义如下:

// 单Slot 32字节固定结构
delayed_time(8Byte)  // 当前槽位对应的绝对时间戳
first_pos(8Byte)     // 当前秒任务链表的TimerLog起始偏移量
last_pos(8Byte)      // 当前秒任务链表的TimerLog末尾偏移量
msg_count(4Byte)     // 当前秒堆积的定时消息数量
reserved(4Byte)      // 预留填充位,对齐磁盘定长读写

核心设计:槽位不存消息本体,只存链表指针,所有消息实体索引全部存在顺序写的 TimerLog 文件中,实现「时间轮索引+日志存储」的读写分离架构。

5.3.3 TimerLog 日志结构与链表挂载机制

TimerLog 是纯顺序追加写入的磁盘日志文件,每条定时消息写入都会生成一条定长日志记录,每条记录携带前驱指针,形成单秒内双向链表结构

单条 TimerLog 核心字段:

  • commitlog_pos:真实消息在 CommitLog 中的物理偏移量(精准定位原消息)

  • prev_pos:同秒上一条消息的 TimerLog 偏移量(链表前驱)

  • next_pos:同秒下一条消息的 TimerLog 偏移量(链表后继)

  • delayed_time:消息到期时间戳

挂载逻辑:同一秒到期的多条消息,通过 prev/next 指针串联成链表,时间轮 Slot 只记录该链表首尾位置,无需遍历全量消息,极致提升读写效率。

5.3.4 定时消息入队完整实现流程(源码链路)
  1. 时间戳校验:Broker 拦截定时消息,校验到期时间在「当前时间~7天后」区间,超出直接报错

  2. 槽位计算:通过到期时间对总槽位数取模,精准定位对应 Slot 位置: slotIndex = delayedTime % 1209600

  3. 顺序写TimerLog:追加写入当前定时消息索引,记录前驱指针

  4. 更新Slot指针:更新对应槽位的 last_pos、msg_count,完成链表尾部挂载

  5. 落盘持久化:TimerLog 顺序刷盘、TimerWheel 索引更新,保证断电不丢失任务关系

5.3.5 时间轮指针步进与出队执行原理

RocketMQ 后台专属时间轮线程TimerDequeueService 每秒驱动指针前进一格,实现 O(1) 调度:

  1. 指针指向当前系统时间对应的 Slot 槽位

  2. 判断当前槽位 msg_count > 0,判定是否存在到期任务

  3. 通过 first_pos、last_pos 遍历当前秒 TimerLog 链表

  4. 根据 commitlog_pos 读取 CommitLog 原始定时消息

  5. 恢复消息真实业务 Topic、队列信息,重新投递至业务队列

  6. 清空当前槽位任务计数与指针,完成本轮调度

5.3.6 7天环形复用与超长定时实现

针对超过7天的定时任务,RocketMQ 采用时间轮环形复用+轮回计数实现:

  • 通过时间戳差值计算需要轮转的圈数

  • 指针每走完一轮7天窗口,自动叠加轮回计数

  • 未到期任务保留在原槽位,等待下一轮指针命中

  • 无需扩容文件、无需迁移数据,完美支持最长7天定时

5.3.7 重启断点续跑机制(高可靠核心)

时间轮所有状态持久化在磁盘文件,Broker 重启后自动恢复现场:

  1. 加载本地 TimerWheel 文件,读取最后一次调度的时间指针位置

  2. 从断点位置继续向后步进,不会重复调度历史任务

  3. 扫描当前所有未到期槽位,重建内存链表映射

  4. 恢复正常秒级步进调度,保证定时任务不丢、不重复、不遗漏

5.3.8 核心源码片段:槽位寻址与任务挂载
// 根据到期时间计算时间轮槽位下标
public int getSlotIndex(long delayedTime) {
    // 总槽位:7天秒级总数 1209600
    return (int) (delayedTime % TIMER_WHEEL_TOTAL_SLOT);
}

// 新定时消息挂载至时间轮链表尾部
public void appendTimerLog(TimerLogRecord record, long delayedTime) {
    int slotIdx = getSlotIndex(delayedTime);
    Slot slot = this.slotTable[slotIdx];
    
    // 1. 写入TimerLog顺序日志
    long logOffset = timerLog.append(record);
    
    // 2. 更新槽位链表首尾指针
    if (slot.getMsgCount() == 0) {
        slot.setFirstPos(logOffset);
    }
    slot.setLastPos(logOffset);
    slot.incrementCount();
    
    // 3. 刷盘持久化索引
    flushWheelAndLog();
}
5.3.9 RocketMQ磁盘时间轮四大工程优势
  • 零CPU空转:仅处理当前秒到期槽位,无全局遍历、无无效轮询

  • 磁盘级海量支撑:完全落地磁盘,百万级定时消息无内存压力

  • 精准秒级调度:固定1秒粒度,无档位限制,适配任意定时场景

  • 崩溃自愈:全量持久化、断点续跑,重启不丢失定时任务状态

5.43 工作原理通俗拆解

  1. 初始化固定长度的环形时间轮(默认覆盖7天时间窗口)

  2. 新定时消息根据到期时间,哈希取模定位对应槽位

  3. 任务挂载至对应槽位的链表尾部

  4. 后台线程每秒驱动指针前进一格

  5. 指针指向的当前槽位,批量执行所有到期任务

  6. 超长定时任务自动轮转复用时间轮窗口,实现超长定时支持

5.5 RocketMQ时间轮优势(对比轮询)

  • 精准调度:秒级精度,无固定档位限制

  • 性能极致:仅扫描当前到期槽位,无空轮询CPU损耗

  • 海量支撑:槽位分片、链表挂载,支持百万级定时消息堆积

  • 持久化可靠:磁盘存储时间轮状态,重启不丢失定时进度


六、双模式节点关系与联动机制

6.1 固定级别延迟消息节点关系

Producer → Broker路由拦截 → 系统延迟Topic → 分级Queue → Schedule定时扫描 → 业务Topic → Consumer

核心:队列与延迟级别强绑定,每个队列独立调度,节点职责单一、完全隔离。

6.2 任意定时消息节点关系

Producer指定时间 → TimerWheel槽位映射 → TimerLog持久化 → 时间轮指针驱动 → 到期解析 → 重新投递消费

核心:时间维度分片存储,脱离队列限制,以时间为核心调度

6.3 两套机制核心区别

对比维度

固定级别延迟消息(4.x)

任意时间定时消息(5.x)

核心算法

队列轮询扫描

时间轮调度算法

时间精度

固定18档,精度粗糙

秒级精准、任意时间

存储结构

系统延迟Topic+分级队列

TimerWheel + TimerLog

性能损耗

低,定时轮询无复杂计算

极低,精准槽位触发

业务灵活性

差,无法自定义时间

极高,适配所有定时场景


七、源码核心关键点总结(面试绝杀)

  • 固定延迟核心:消息写入时动态替换Topic为系统延迟Topic,按级别绑定队列,后台定时轮询到期补发,无时间轮参与

  • 路由还原关键点:延迟消息必须缓存原始Topic和QueueID,到期后恢复路由才能被业务消费者消费

  • 定时消息核心:5.x通过时间轮槽位映射+TimerLog链表实现任意时间调度,解决固定档位痛点

  • 时间轮核心关键点:时间分片、槽位挂载、指针驱动、O(1)调度、无空轮询损耗

  • 可靠性关键点:双方案均基于磁盘持久化,重启位点恢复,保证延时消息不丢失、不重复、不遗漏

  • 性能关键点:级别隔离、时间分片、任务链表挂载,海量延时消息互不影响


八、全篇核心总结

1、RocketMQ 延迟消息分为4.x固定级别延迟(队列轮询)5.x任意时间定时(时间轮算法)两套完全独立的实现架构。

2、固定延迟核心原理:路由替换+分级队列+后台轮询扫描,架构简单稳定,唯一短板是时间不灵活。

3、定时消息核心原理:TimerWheel时间轮分片+TimerLog顺序日志,秒级精准调度,支持任意自定义延迟时间。

4、时间轮算法是高性能定时调度的核心,通过时间槽位拆分、指针驱动,彻底解决传统轮询CPU空转、全量遍历的性能问题。

5、两套方案均实现延时消息消费者不可见、到期自动投递的核心能力,且基于磁盘持久化,保障分布式场景高可靠。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值