type
status
date
slug
summary
tags
category
icon
password
延时消息(Scheduled Messages)是分布式系统中“在指定时间点再投递”的核心能力。随着业务从简单的订单超时发展到海量定时任务调度,对延时队列的精度、吞吐量和一致性提出了更高要求。
摘要
本文将从架构演进的角度,深度剖析 RocketMQ 5.x(基于时间切片的物理存储方案)与 Apache Pulsar(基于存算分离的内存索引方案)的最新设计差异。我们将从底层存储出发,拆解它们如何解决"同刻尖峰"、"崩溃恢复"与"海量索引"的挑战,并提供选型与治理建议。
Part 1. 延时消息设计的演进:从数据库轮询到层级时间轮
延时消息技术方案的进化路径:
- 第一代:数据库Redis 轮询(Polling)原理:业务将消息写入 MySQL 或 Redis
zset,通过后台线程定时select * from table where execute_time >= now()。 • 痛点:无法水平扩展,轮询间隔决定了延迟误差,高频轮询导致数据库 CPU 飙升。
- 第二代:单机内存优先队列(JDK DelayQueue)
- 原理:利用最小堆
(Min-Heap)维护内存中的定时任务。 - 痛点:插入/删除复杂度为
O(log N),在大规模积压下性能下降;且纯内存结构,宕机即丢数据。
- 第三代:简单时间轮(Hashed Wheel Timer)
- 原理:模拟钟表盘面,将时间划分为一个个
Slot(槽位)。指针每秒走一格,取出当前槽位的任务链表执行。 - 优势:插入/删除复杂度降为
O(1)。 - 痛点:只能处理短时间跨度,或者需要巨大的内存空间来存储长时间跨度的槽位。
- 第四代:层级时间轮(Hierarchical Timing Wheels)
- 原理:仿照时钟的"时、分、秒"多级结构。当"小时轮"转动一格,将任务降级迁移到"分钟轮",最终进入"秒轮"触发。
- 现状:这是目前高性能延时队列的主流算法核心(Kafka、RocketMQ 5.x 均借鉴此思想)。
Part 2. RocketMQ 5.x 的变革:任意精度的"物理时间轮"
RocketMQ 在 4.x 时代仅支持 18 个固定的延时级别(
1s 5s 10s...),这本质上是 18 个不同延迟时间的普通队列。到了 5.x 版本,RocketMQ 彻底重构了这一模块,引入了 TimerMessage。1. 核心架构设计
RocketMQ 5.x 的设计目标是基于磁盘持久化实现任意精度的定时调度。
- TimerLog(存储):所有定时消息并不直接进入目标 Topic,而是先写入 CommitLog,然后被转发到
TimerLog。TimerLog可以看作是一个按时间排序的持久化索引流。
- TimerWheel(索引):一个基于文件的哈希时间轮。每个 Slot 存储了该时间点对应的
TimerLog物理偏移量。
- TimerEnqueue/Dequeue 服务:
Broker内部有线程不断扫描当前时间的Slot。一旦时间到,从TimerLog读取消息,还原回原始Topic,消费者即可消费。
2. 关键特性
- 毫秒级精度:不再受限于固定
Level。
- 强持久化:时间轮和消息全链路落盘,
Broker崩溃重启后,通过Checkpoint恢复扫描进度,不丢消息。
- 批量搬运:专门的逻辑处理"海量到期",通过顺序读写磁盘来保证高吞吐。
Part 3. Apache Pulsar 的设计:存算分离下的"内存时间轮"
Pulsar 的架构是存算分离(Broker 计算 + BookKeeper 存储),这决定了它的延时队列设计必须更加灵活且轻量。它没有设计独立的"定时存储文件",而是利用了订阅(Subscription)的特性。
1. 底层视角:从 BookKeeper 到 Dispatcher
Pulsar 的延时消息处理流程是一个典型的"读时过滤"模型:
1. Write(写入):
- 生产者发送延时消息(设置
deliverAt或deliverAfterMs)。
- Broker 接收消息,解析
MessageMetadata中的延时信息。
- 消息像普通消息一样被持久化写入 BookKeeper 的 Ledger 中。
- 关键点:消息已持久化到存储层,但对
Consumer不可见(不在Dispatcher的待分发队列中)。
2. Index(构建索引):
- Broker 识别到
deliverAt时间 > 当前时间,触发延迟机制。
- 将消息索引
(deliverAt 时间戳, LedgerId, EntryId)添加到DelayedDeliveryTracker。
DelayedDeliveryTracker使用 分层时间轮(Hierarchical Timing Wheel)管理索引:- 内存数据结构:高效管理大量延迟消息,插入/删除复杂度 O(1)。
- 多级精度:不同层级的时间轮处理不同时间跨度(秒、分钟、小时级别)。
- 槽位组织:每个时间槽存储到期时间相同的消息索引列表。
3. Dispatch(派发):
- 时间轮调度:
- 时间轮按固定 tick 间隔(默认 1 秒)推进指针。
- 检查当前时间槽,提取所有到期的消息索引。
- 处理跨层级降级:将长时间轮中到期的任务迁移到更精细的时间轮。
- 消息检索:
- 根据索引中的
(LedgerId, EntryId)定位消息在 BookKeeper 中的物理位置。 - 从 BookKeeper(或 Broker 的 EntryCache)读取完整消息体。
- 重新入队:
- 到期触发:
DelayedDeliveryTracker的时间轮到达预设时间点,获取该时间槽所有到期消息的索引(ledgerId, entryId)。 - 消息读取:Broker 根据索引从 BookKeeper 读取完整消息,保持原始消息体不变。
- 状态转换:清除消息元数据中的
deliverAt字段,使其转变为普通消息状态。 - 重新入队:通过对应订阅的 Dispatcher(如
PersistentDispatcherMultipleConsumers)将消息重新插入到订阅的待发送队列。 - 正常分发:Dispatcher 按照订阅模式(Shared/Key_Shared/Exclusive/Failover)将消息分发给消费者,完成延迟消息的最终投递。
2. 核心组件交互图解
3. 核心权衡
- 内存压力:索引存储在内存中。当延时消息量巨大(如亿级积压)时,Broker 的堆内存压力会显著增加。
- 重建成本:由于索引在内存,Broker 宕机重启后必须回放 Ledger(或读取 Snapshot)来重建时间轮。为加速此过程,Pulsar 引入了
DelayedDeliveryTracker快照机制。
- 订阅模式限制:延时消息仅在
Shared和Key_Shared模式下生效。Exclusive/Failover模式要求严格顺序,延时会导致顺序出现空洞,在逻辑上存在冲突。
Part 4. 亿级调度的挑战与资源估算(Capacity Planning)
当我们在说"支持亿级延时消息"时,我们到底需要多少硬件资源?这是做架构选型最实在的数据。
场景假设
- 积压量:1 亿条(100,000,000)延时消息等待投递。
- 并发度:同一秒内有 100 万条消息同时到期(尖峰场景)。
4.1 内存开销估算
关键优化点:
- 分区分散:将延迟消息分散到多个 partition,每个 Broker 只需处理部分索引
- 堆外内存:Pulsar 3.0+ 开始探索使用堆外内存存储索引
- 索引压缩:对连续的 ledgerId/entryId 使用增量编码
4.2 I/O 开销估算 — RocketMQ 补充
TimerLog 物理结构:
1 亿条延迟消息的磁盘存储开销:
10⁸ × 40 Bytes ≈ 4 GB(纯索引存储)
场景一:理想情况(全部命中 PageCache)
- TimerLog 读取:100 万条索引 × 40B = 40MB 顺序读
- CommitLog 读取:100 万条消息 × 1KB = 1GB 随机读
- 总数据量:1.04GB 内存拷贝
- 瓶颈:内存带宽(约 40–80GB/s),CPU 处理能力
场景二:最坏情况(全部磁盘读取)
1. 时间轮分片(TimerWheel Sharding)
2. 批量预读机制
3. 内存分级缓存策略
4.3 治理建议补充表格
瓶颈类型 | 典型现象 | Pulsar 优化方案 | RocketMQ 优化方案 | 通用工程手段 |
内存 (Heap) | OOM / 频繁 GC | 增加 Partition 分散索引
调整时间轮精度
监控 DelayedDeliveryTracker 大小 | TimerLog 在磁盘,内存压力小
调整 TimerWheel 层级 | 设置延迟消息配额
定期清理过期索引 |
磁盘 I/O | 读写延迟飙升 | BookKeeper 使用 SSD
读写分离部署
EntryCache 调优 | TimerLog 和 CommitLog 分离
使用 NVMe SSD
调整 PageCache 大小 | 监控磁盘 IOPS
使用 RAID 或分布式存储 |
网络带宽 | 丢包 / 延迟高 | Broker 端过滤
批量推送
压缩传输 | Batch 消费
消息压缩
消费者限流 | 网络 QoS 配置
多网卡绑定
消息体瘦身 |
CPU 调度 | 时间轮 tick 延迟 | 调整 tick 时间(默认 1s)
多线程处理到期消息
避免 GC 暂停 | TimerMessageStore 线程调优
异步化处理链 | CPU 绑核
实时优先级设置 |
时钟同步 | 投递时间漂移 | 使用 NTP 同步
Broker 间时钟校准
本地时钟缓存 | 同样的 NTP 同步需求
时间戳校验机制 | 部署 chrony / NTP
硬件时钟同步 |
🚀 生产环境配置建议
Pulsar Broker 配置示例
RocketMQ 配置示例
⚠️ 实际部署注意事项
- 混合部署风险:延迟消息 Broker 与普通 Broker 分离部署
- 监控告警:设置延迟消息积压阈值告警
- 容量测试:模拟亿级延迟消息场景的压力测试
- 灾备方案:Broker 宕机后的延迟消息恢复策略
- 版本升级:延迟消息实现可能随版本变化
✅ 结论验证
- 内存墙:Pulsar 需要 10GB+ 堆内存管理索引
- I/O 墙:RocketMQ 需要 NVMe SSD 支撑随机读
- 网络墙:避免同时触发导致带宽风暴
最佳实践:
- 分散策略:时间分散(随机抖动)+ 空间分散(多分区)
- 分级存储:短期延迟用内存,长期延迟用磁盘
- 流量整形:平滑到期流量,避免尖峰
- 容量规划:提前计算资源需求,预留 30% buffer
- 作者:Lizhichao
- 链接:https://www.zhichaoli.cn//article/pulsar-delay
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。







