☰
Apache Pulsar 延迟消息深度剖析:从内存追踪器到混合架构演进
2026/10/14 7:00:25 网站建设 项目流程

Apache Pulsar 的延迟消息一直是个“看着简单、用起来问题一堆”的功能。这次关于延迟消息深度剖析与混合架构演进的分享,我把重点放在了一个问题上:一条消息如果要在指定时间之后才投递给消费者,Broker 内部到底应该怎么组织它的索引和存储,才能既不丢状态、又扛得住海量延迟消息。文章我会按延迟消息的价值、Pulsar 的底层处理链路、两代追踪器架构演进、关键参数调优、以及现场交流中高频出现的问题来展开,适合正在使用 Pulsar 做订单超时、任务调度、重试队列的同学参考,也适合那些准备把延迟消息上生产但还没想清楚原理的人。

1. 延迟消息解决什么问题,以及为什么 Pulsar 要把这件事做得这么重

1.1 用订单超时关单的场景说清楚延迟消息

延迟消息最经典的场景是电商订单超时关单。用户下单后你有 30 分钟时间等他付款,超时未支付就要自动把订单置为关闭、库存回滚。这个需求听起来简单,就是“30 分钟之后触发一个动作”,但真正落到工程上,你要处理的是海量订单:每个订单都有一条消息,每条消息的到期时间都不一样,而且系统要保证哪怕 Broker 重启、网络抖动、消费者扩容缩容,到点之后该触发的动作一个都不能少。

如果用 Pulsar 来做,思路是生产者在下单时发一条延迟消息,deliverAtTime设置为当前时间加 30 分钟。消费者这边不需要自己判断时间,到点了 Pulsar 自然把这条消息投递出来,消费端收到消息后执行关单逻辑就行。这套模型最大的好处是业务侧代码极简,分布式系统里最难的时间计算和可靠性保障全部交给消息中间件。

类似的场景还有:红包到账提醒、会员过期通知、IoT 设备定时控制、微服务里的失败重试间隔。它们本质上都是同一种需求:一条消息要在未来某个时间点被处理,而不是立刻被处理。

1.2 延迟消息、定时消息、延时队列的边界

很多人把延迟消息和定时任务调度混在一起,其实它们解决的问题维度不同。

维度延迟消息定时任务调度延时队列
核心能力消息在指定时间后投递周期性/一次性执行任务元素在队列中等待指定时间
典型载体消息队列分布式调度框架队列数据结构
可靠性依赖 MQ 的持久化与确认机制依赖调度框架的状态存储依赖队列本身的持久化
适用场景事件驱动、异步通知定时报表、周期巡检本地任务延迟处理

Pulsar 的延迟消息本质上是前者:它还是在走消息队列的完整链路,只是把“投递时间”这个属性变成了消息元数据的一部分,由 Broker 在投递时把关。这一点非常重要,因为这意味着你不需要额外维护一套调度系统,消息的持久化、副本、消费确认这些能力,延迟消息天然获得。

1.3 “混合架构演进”到底指什么

标题里提到的“混合架构演进”,指的是 Pulsar 延迟消息追踪器从纯内存实现,演进到“内存+持久化存储”混合实现的过程。第一代方案里,Broker 把延迟消息的索引全部放在内存里,用一个按到期时间排序的结构来管理;但消息量一上来、延迟时间一长,内存和恢复成本都成了问题。后面引入的方案把索引拆成时间桶,定期把快照持久化到存储层,内存只保留活跃部分的数据,两者配合着工作。

所以这不是一个孤立的小优化,而是延迟消息能力从“能用到好用”的关键跨越。下面我会先把整个处理链路讲清楚,再拆这两代方案的细节。

2. Pulsar 延迟消息的核心原理:一条延迟消息的完整旅程

2.1 生产者侧:消息如何被标记为延迟

在 Pulsar 的客户端 API 里,延迟投递的入口非常直观。以 Java Client 为例:

Message<String> message = MessageBuilder.newBuilder() .value("close_order:20240101") .setDeliverAfter(30, TimeUnit.MINUTES) .build(); producer.newMessage() .value("close_order:20240101") .deliverAfter(30, TimeUnit.MINUTES) .send();

如果用底层一点的TypedMessageBuilder,你也可以用deliverAt(long timestamp)直接指定一个绝对时间戳。这两种方式最终都会在消息的二进制元数据中写入deliver_at_time字段,这个字段是 Pulsar 协议的一部分,并不是某个客户端私有的东西,也就是说任何语言客户端都能设置和识别。

一个容易被忽略的点:延迟消息的持久化时机和普通消息没有任何区别。生产者发出去,Broker 写入 BookKeeper 的 Ledger,返回确认。也就是说消息从发送成功那一刻起就不会丢,延迟只是影响了它“什么时候可见”,不影响“它是否已经存在”。

2.2 Broker 侧:写入与延迟分离的处理流程

Broker 收到一条带deliver_at_time的消息之后,处理逻辑可以拆成四条线:

  1. 消息正常追加写入当前 Topic 的 ManagedLedger,这一步和普通消息完全一样。
  2. 如果deliver_at_time大于当前时间,Broker 会把这条消息的位置信息登记到延迟消息追踪器里。
  3. 追踪器内部按到期时间组织索引,并配合定时机制周期性检查有没有到期的消息。
  4. 发现到期消息后,追踪器把它归还给 Dispatcher,让消费者在正常的拉取流程中可见。

这里有个关键点:消息在 Ledger 里的物理存储位置,和它在消费者侧的逻辑可见性,是解耦的。Ledger 是追加写的,消息按照生产顺序落盘,但消费端读取的时候,Broker 会“跳过”未到期的消息。这个跳过动作不是删除,而是基于追踪器索引的临时过滤。

我画个流程大家可能更好懂:

Producer -> Broker 持久化 -> 已确认 -> 追踪器登记索引 ↓ 定时检查到期消息 ↓ 归还给 Dispatcher -> Consumer 拉到消息

2.3 为什么延迟消息不能直接放进普通消费队列

现场有朋友问过一个问题:为什么不干脆把延迟消息单独放一个队列,到时间了再转到正式队列?这个思路乍一看很符合直觉,但放在 Pulsar 里会遇到几个硬伤。

首先,Pulsar 的消息是按 Topic 和 Subscription 组织的,如果延迟消息先进“延迟 Topic”,到期后再转发到“业务 Topic”,那生产者、消费者都要感知这层结构,消费顺序也容易乱。更麻烦的是消息会从一个 Topic 复制到另一个 Topic,如果期间消费者已经启动了,还要处理“重复消息”和“先到消息后到”的问题。

其次,Pulsar 的消费模型是基于游标的。Broker 为每个订阅维护一个 Cursor,标记消费者读到 Ledger 的哪个位置。如果消息物理上排在前面,但逻辑上还不能投递,Broker 不能直接把 Cursor 往后移动,否则消息就永久跳过了。延迟消息追踪器做的事情,本质上就是“游标和投递集之间的一个人工闸门”,它不改变消息的物理位置,只控制哪些位置的条目现在可以进入投递集合。

所以不是架构师不想用“普通队列”,而是消息队列的底层模型决定了你必须有一层独立的延迟索引。只有理解了这一点,后面演进方案的取舍才看得明白。

2.4 投递之后的确认与重试逻辑

延迟消息到期之后,投递给消费者,然后正常走 ACK 机制,这一点和普通消息没有区别。但要注意的是,如果消费者 NACK 或者消费超时,消息会进入重新投递流程,这个重投不受延迟时间控制,而是遵循订阅级别的ackTimeout和redeliveryDelay。

具体到业务设计上,我的建议是:不要把“业务重试”和“消息延迟”混为一谈。延迟消息解决的是“什么时候第一次投递”,而业务重试解决的是“投递失败了怎么办”。如果两者混着调,很容易出现一条消息既在追踪器里未到期、又被 Redelivery 机制重复投递的尴尬情况。比较好的做法是延迟消息到期后,消费者处理失败时用独立的退避策略,或者直接走死信 Topic,而不是反过来修改消息元数据继续延迟。

3. 演进第一站:基于内存的延迟消息追踪器设计与代价

3.1 内存优先队列的基本思路

最初版本的 Pulsar 延迟消息,Broker 端用的是一个内存追踪器。这个追踪器为每个 Topic 的每个订阅维护一个按到期时间排序的结构,最朴素的实现就是优先队列:到期时间越近的消息排在越前面,Broker 周期性地看队头元素是否到期,到期就弹出并归还给 Dispatcher。

这个方案在消息量不大的时候表现不错,逻辑清晰,实现也简单。每条延迟消息在追踪器里占一个索引项,里面记录消息在 Ledger 中的位置(LedgerId + EntryId)和到期时间。检查到期时只需要看队头,时间复杂度 O(1),插入时也只需要按时间排序,整体开销可控。

进阶版本会使用时间轮或者多层时间轮来优化插入和删除。时间轮的思路是把时间切成一个个 slot,每个 slot 放一批同一个时间范围内的消息,指针按 tick 移动,移动到一个 slot 就把里面到期的消息全部释放。这样可以在延迟消息比较密集的场景下减少排序开销。

3.2 内存版本最痛的三件事

内存方案在生产环境跑起来之后,问题会集中暴露在三个地方。

第一是内存占用。延迟消息的数量级一旦上来,每个索引项的内存开销是不可忽视的。我见过线上场景里一个 Topic 积累了上百万条延迟消息,单是追踪器就吃掉了几个 GB 的堆内存。而且这些内存是长期占用的,消息要一直等到到期才能释放,如果业务经常用小时级甚至天级的延迟,这部分内存几乎不会降低。

第二是延迟范围受限。纯内存追踪器为了控制内存占用和检查开销,通常会对最大可配置的延迟时间做限制。但业务场景经常会提出“7 天后提醒我”这种需求,消息量一大,内存追踪器就只能对时间范围妥协,或者通过降低精度来换容量。

第三是 Broker 重启后的状态恢复。内存里的索引在进程重启后全部丢失,Broker 必须重新扫描 Ledger 来重建追踪器。如果 Topic 里积累了大量的延迟消息,这个重建过程可能长达几分钟甚至更久,期间这些延迟消息都不可见,消费者会感觉到明显的空窗期。如果依赖外部表决权和 Leader 切换,新 Leader 也需要时间重建状态,这在大型集群里是非常难受的。

3.3 从生产反馈看为什么要演进

在交流环节,不止一位听众提到了类似的痛点:他们在一个业务里同时发了大量 24 小时延迟的消息,结果 Broker 内存曲线持续走高,最后只能通过限流和扩容来缓解。还有一个团队反馈,Broker 滚动升级之后,因为追踪器重建时间过长,导致一部分本应准点触发的业务提醒全部延迟了好几分钟。

这些反馈指向同一个结论:延迟消息追踪器不能只活在内存里,它需要把状态落到存储层,并且要支持按需恢复。这就是混合架构演进的实际驱动力。后面要讲的方案,目标不是抛弃内存,而是让内存只做它擅长的事——加速访问,让持久化存储做它擅长的事——长期保存。

4. 演进第二站:分层追踪器与混合架构的落地

4.1 核心设计:时间桶、快照与按需加载

演进后的追踪器采用了分层的设计思路,把时间维度切成桶。每个桶代表一个时间窗口,比如一秒一个桶、一分钟一个桶。延迟消息登记时,根据它的到期时间计算出落在哪个桶里,然后在桶内部记录消息的位置状态。桶本身的状态可以压缩成位图的形式,非常节省空间。

这些桶不能全部放在内存里,否则就退化回第一代方案了。所以新的设计会把桶定期生成快照,快照持久化到 BookKeeper。Broker 内存里只保留当前正在活跃使用的桶,以及一个范围很小的时间窗口内的桶。当需要查找某一条延迟消息时,先查内存里的活跃桶,如果不在,再从 BookKeeper 加载对应的桶快照到内存,用完之后可以释放。

这个过程类似于操作系统里的页面换入换出:热数据留在内存,冷数据放磁盘,用的时候再换进来。这种“内存+持久化”的组合,就是我理解的混合架构核心。

4.2 内存与存储如何分工协作

几乎所有的混合架构都存在一个分工问题:什么数据放内存,什么数据放存储,边界划在哪里。

在延迟消息追踪器这套设计里,边界是按“时间活跃度”来划的。当前临近到期的消息,比如最近几分钟内要投递的那些,对应的桶必然要放在内存里,因为检查线程要高频地访问它们。而时间还很久的桶,比如 24 小时之后才到期的,就只需要保留快照在存储层,Broker 不需要为它们占用堆内存。

这就把内存占用从“延迟消息总量”变成了“时间窗口内活跃桶的量”。举个例子,如果一秒一个桶,内存只保留 5 分钟的活跃桶,那最多只需要维护 300 个桶的状态,哪怕底层有上千万条未来 7 天才到期的延迟消息,它们都安安静静躺在存储快照里,不消耗 Broker 内存。

检查线程的职责也简化了:它只需要关注内存里的活跃桶,到期时间在 tick 范围内的桶,把里面的消息归还给 Dispatcher。对于不在内存的桶,只有在需要投递时才加载,而且加载完可以再次释放。

4.3 混合架构带来的能力变化

这套混合架构落地之后,最直接的变化是 Broker 可以支持更长时间的延迟,而且延迟消息总量对内存的冲击大幅下降。对于业务方来说,原来不敢用的“天级延迟”变得可行,订单 7 天自动关闭、会员 30 天到期提醒这类需求可以直接用延迟消息做了。

另一个变化是恢复速度。因为桶快照在 BookKeeper 上有持久化副本,Broker 重启后不需要全量扫描 Ledger 来重建索引,只需要加载最近的快照集合,配合少量元数据拼接出当前状态。整个恢复过程从“分钟级”压缩到“秒级”,Broker 滚动升级对在线业务的影响明显变小。

还有一个容易被忽视的好处:快照机制天然提供了更好的扩展性。当某个 Topic 的延迟消息量特别大时,Broker 不再需要为它预留大块内存,存储层的压力可以通过多副本和分层存储机制来承接,整个系统的容量边界更清晰了。

4.4 一点补充:追踪器工厂与自定义实现

Pulsar 的延迟消息追踪器在演进过程中也变成了一个可插拔的组件。Broker 里可以通过配置指定延迟消息追踪器的工厂类,默认实现走内嵌的分层追踪器,但如果你在自己的环境里做了额外定制,也可以替换成自己的实现。

这个设计对深度用户非常友好。有些人可能希望把桶快照写到本地磁盘而不是 BookKeeper,有些人可能希望在追踪器里额外记录一些业务维度信息,这些都可以通过实现追踪器工厂接口来做。不过我要提醒一点:自定义实现之前,一定要先吃透默认实现里“内存活跃桶+持久化快照”的边界逻辑,因为很多性能问题都出在这个边界的误用上。

5. 延迟消息的关键参数、配置与调优实践

5.1 开启延迟消息要做哪些配置

Pulsar 默认是支持延迟消息的,但有些参数需要根据业务场景调整。在broker.conf里,最常用的配置长这样:

delayedDeliveryEnabled=true delayedDeliveryTickTimeMillis=1000 delayedDeliveryMaxDelayInMillis=86400000 delayedDeliveryMaxNumBuckets=5000 delayedDeliveryTrackerFactoryClassName=org.apache.pulsar.broker.delayed.LayeredDelayedMessageTrackerFactory

delayedDeliveryEnabled是总开关;delayedDeliveryTickTimeMillis是检查到期消息的周期,默认 1000 毫秒,也就是说延迟消息的时间精度受这个 tick 限制;delayedDeliveryMaxDelayInMillis是允许的最大延迟时间,注意这类配置项在不同版本里的命名可能有差异,用的时候以你的版本官方文档为准。

另外有几个概念需要区分:tick time决定的是“检查频率”,不是“绝对精度”。如果你设置deliverAfter(1500, MILLISECONDS),而 tick 是 1000 毫秒,消费者可能在第 2 秒才看到消息,这是符合预期的。如果需要更精确的投递,可以把 tick 调小,但代价是检查线程更频繁地访问追踪器,CPU 和锁竞争都会上升。

5.2 三个最影响生产的取舍点

调优延迟消息,本质上是在三个指标之间找平衡。

第一个是精度和吞吐。tickTimeMillis越小,消息越能准点投递,但 Broker 的检查开销越大。普通业务秒级精度就够了,没必要追到毫秒级。我之前见过有人把 tick 调到 10 毫秒,结果 Broker 的负载直线上升,收益却很有限。

第二个是内存和延迟范围。你要支持的延迟时间越长,活跃桶的数量就可能越多,因为内存需要覆盖的“当前活跃时间窗口”不变,但如果业务经常使用特别分散的到期时间,加载/释放桶的操作会更频繁。如果内存桶数配得太小,高并发下容易出现桶反复换入换出的抖动。

第三个是快照写入频率和恢复速度。快照写得太频繁,BookKeeper 的写入压力会加大;写得太少,Broker 崩溃时丢失的状态窗口就越大,恢复后可能要回放更多的 Ledger 段。多数情况下,让默认策略根据桶的活动状态自动决定快照时机是更稳妥的选择,不建议手动把快照周期调到极端值。

5.3 监控、统计与定位问题手段

线上排查延迟消息问题,最常用的入口是pulsar-admin的 Topic 统计命令:

bin/pulsar-admin topics stats persistent://public/default/your-topic bin/pulsar-admin topics stats-internal persistent://public/default/your-topic

stats输出里能看到订阅的积压数量和未确认消息数,配合存储层的 Ledger 信息就能判断延迟消息堆积到底是在追踪器里,还是消费者这边消费不掉。stats-internal可以看到游标位置和 Ledger 的详细信息,用来对比消息物理位置和逻辑投递位置。

另外要特别关注几个指标的组合:如果 Topic 的积压数量不断上涨,但消费者的处理耗时和速率都很正常,那就要怀疑是不是有大量延迟消息还没到期;如果延迟消息数量不高,但消费者还是拉不到消息,则要检查追踪器恢复状态是否正常,以及 tick 检查线程是否健康。

还有一个实践技巧:评估延迟消息的规模时,不要只看 Topic 消息总量,要单独看追踪器里的索引数量。这个数字才能反映内存和恢复的真实压力。有些监控面板会把延迟消息数和积压消息数混在一起,容易误导判断。

6. 高频问题与避坑建议

6.1 Broker 重启后延迟消息会不会丢

这个问题几乎每次交流都会被问到,答案是:不会丢。消息本身就是持久化在 BookKeeper Ledger 里的,Broker 只是负责“延迟投递”的状态层。进程重启丢的只是内存索引,不是消息本体。

关键点在于恢复时间和期间的行为。旧版内存方案重启后要从 Ledger 重建索引,恢复期间延迟消息处于“不可见”状态;新版混合架构通过快照恢复,时间大大缩短,但极端情况下如果快照不完整,仍然可能回放一部分 Ledger 数据来补齐。所以生产环境里,延迟消息的物理存储和追踪器快照都要做合理的多副本配置,不要为了省钱把 BookKeeper 副本数降到阈值以下。

6.2 延迟消息和顺序消费冲突怎么办

Pulsar 的普通消息在单个分区内是顺序的,但延迟消息天然会把“生产顺序”和“消费顺序”拉开。如果你在同一分区里发了三条消息:第一条正常消息,第二条延迟 1 分钟,第三条正常消息,那消费者收到的顺序会是第一条、第三条、第二条,因为第二条要等到期才能投递。

业务上如果严格依赖顺序,而且又要使用延迟消息,建议把延迟消息和非延迟消息放到不同分区,用Message Key做业务粒度隔离。不要试图在一个分区里同时维护强顺序和随机延迟,这在机制上就是矛盾的。现场有位做账务通知的工程师提到,他们一开始把延迟消息和普通消息混在一个 Topic,结果对账消息偶发乱序,最后拆分区才解决。

6.3 延迟消息变多带来的存储与性能压力

延迟消息虽然投递晚,但在 Ledger 里还是会占用物理存储。而且因为延迟消息要保留足够长的时间,对应的 Ledger 段不会立刻被清理,存储占用可能比普通消息更高。

实践中要注意两点:一是合理设置 Topic 的retention策略,不要对延迟 Topic 和普通 Topic 使用一刀切的保留时间;二是延迟 Topic 的积压监控阈值要单独设置,因为它天然会有比较高的“延迟可见积压”,用普通 Topic 的告警基线去套容易误报。另外如果 BookKeeper 的存储压力增大,可以考虑对延迟 Topic 开启分层存储,把不活跃的 Ledger 段冷化到对象存储,这个思路和延迟消息的混合架构方向是一致的。

6.4 我自己实战中的几条避坑建议

最后分享几条我在实际使用中总结的经验,算不上多高深,但每一件都是踩过坑之后才记住的。

第一,延迟消息的消费者要有幂等设计。虽然消息机制本身做了很多可靠性保障,但“至少一次”的语义下重复投递是可能的,尤其是 Broker 状态恢复和 Redelivery 叠加的时候。关单、扣款这类操作必须做幂等。

第二,尽量把延迟时间设置得比较规整。如果延迟时间都是 5 分钟、1 小时这种对齐的值,桶的分布会比较集中,追踪器和快照的压力都会小一些。如果每个消息都用随机时间戳,桶会非常离散,内存换入换出的频率会升高。

第三,测试环境一定要验证“Broker 重启后延迟消息的恢复表现”。不要只在功能层面验证“到点能不能收到”,要把重启、切换、断网这些故障场景纳入测试范围。我见到过一些团队功能测试全过,上线后第一次滚动升级就出问题的案例,就是因为从没测过恢复路径。

第四,升级 Pulsar 版本前,重点看延迟消息追踪器相关的变更日志。这个模块在多个版本里都有比较大的行为变化,配置项含义、默认值、监控指标都可能变动,升级文档里往往不会用加粗字体标出来,需要自己去翻。我一般是先在一个小集群里升级,观察延迟消息的投递延迟和内存曲线稳定后再扩大到生产集群。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询