type
status
date
slug
summary
tags
category
icon
password
💡
延时消息(Scheduled Messages)是分布式系统中“在指定时间点再投递”的核心能力。随着业务从简单的订单超时发展到海量定时任务调度,对延时队列的精度、吞吐量和一致性提出了更高要求。

摘要

本文将从架构演进的角度,深度剖析 RocketMQ 5.x(基于时间切片的物理存储方案)与 Apache Pulsar(基于存算分离的内存索引方案)的最新设计差异。我们将从底层存储出发,拆解它们如何解决"同刻尖峰"、"崩溃恢复"与"海量索引"的挑战,并提供选型与治理建议。

Part 1. 延时消息设计的演进:从数据库轮询到层级时间轮

延时消息技术方案的进化路径:
  1. 第一代:数据库Redis 轮询(Polling)原理:业务将消息写入 MySQL 或 Redis zset,通过后台线程定时 select * from table where execute_time >= now()。 • 痛点:无法水平扩展,轮询间隔决定了延迟误差,高频轮询导致数据库 CPU 飙升。
  1. 第二代:单机内存优先队列(JDK DelayQueue)
      • 原理:利用最小堆 (Min-Heap) 维护内存中的定时任务。
      • 痛点:插入/删除复杂度为 O(log N),在大规模积压下性能下降;且纯内存结构,宕机即丢数据。
  1. 第三代:简单时间轮(Hashed Wheel Timer)
      • 原理:模拟钟表盘面,将时间划分为一个个 Slot(槽位)。指针每秒走一格,取出当前槽位的任务链表执行。
      • 优势:插入/删除复杂度降为 O(1)
      • 痛点:只能处理短时间跨度,或者需要巨大的内存空间来存储长时间跨度的槽位。
  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,然后被转发到 TimerLogTimerLog 可以看作是一个按时间排序的持久化索引流。
  • 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(写入):
  • 生产者发送延时消息(设置 deliverAtdeliverAfterMs)。
  • 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)读取完整消息体。
  • 重新入队
      1. 到期触发DelayedDeliveryTracker 的时间轮到达预设时间点,获取该时间槽所有到期消息的索引 (ledgerId, entryId)
      1. 消息读取:Broker 根据索引从 BookKeeper 读取完整消息,保持原始消息体不变。
      1. 状态转换:清除消息元数据中的 deliverAt 字段,使其转变为普通消息状态。
      1. 重新入队:通过对应订阅的 Dispatcher(如 PersistentDispatcherMultipleConsumers)将消息重新插入到订阅的待发送队列。
      1. 正常分发:Dispatcher 按照订阅模式(Shared/Key_Shared/Exclusive/Failover)将消息分发给消费者,完成延迟消息的最终投递。

2. 核心组件交互图解

3. 核心权衡

  • 内存压力:索引存储在内存中。当延时消息量巨大(如亿级积压)时,Broker 的堆内存压力会显著增加。
  • 重建成本:由于索引在内存,Broker 宕机重启后必须回放 Ledger(或读取 Snapshot)来重建时间轮。为加速此过程,Pulsar 引入了 DelayedDeliveryTracker 快照机制。
  • 订阅模式限制:延时消息仅在 SharedKey_Shared 模式下生效。Exclusive/Failover 模式要求严格顺序,延时会导致顺序出现空洞,在逻辑上存在冲突。

Part 4. 亿级调度的挑战与资源估算(Capacity Planning)

当我们在说"支持亿级延时消息"时,我们到底需要多少硬件资源?这是做架构选型最实在的数据。
场景假设
  • 积压量:1 亿条(100,000,000)延时消息等待投递。
  • 并发度:同一秒内有 100 万条消息同时到期(尖峰场景)。

4.1 内存开销估算

关键优化点:
  1. 分区分散:将延迟消息分散到多个 partition,每个 Broker 只需处理部分索引
  1. 堆外内存:Pulsar 3.0+ 开始探索使用堆外内存存储索引
  1. 索引压缩:对连续的 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 配置示例

⚠️ 实际部署注意事项

  1. 混合部署风险:延迟消息 Broker 与普通 Broker 分离部署
  1. 监控告警:设置延迟消息积压阈值告警
  1. 容量测试:模拟亿级延迟消息场景的压力测试
  1. 灾备方案:Broker 宕机后的延迟消息恢复策略
  1. 版本升级:延迟消息实现可能随版本变化

✅ 结论验证

  • 内存墙:Pulsar 需要 10GB+ 堆内存管理索引
  • I/O 墙:RocketMQ 需要 NVMe SSD 支撑随机读
  • 网络墙:避免同时触发导致带宽风暴
最佳实践
  1. 分散策略:时间分散(随机抖动)+ 空间分散(多分区)
  1. 分级存储:短期延迟用内存,长期延迟用磁盘
  1. 流量整形:平滑到期流量,避免尖峰
  1. 容量规划:提前计算资源需求,预留 30% buffer
Mermaid 20分钟入门GPT-Prompt
Loading...