☰
Pulsar服务端消费拉取源码解析:Dispatcher、Cursor与流控机制
2026/10/22 8:29:41 网站建设 项目流程

1. 从一次消费延迟抖动说起:为什么要啃消费者拉取这块硬骨头

线上有一套基于 Pulsar 的消息链路,某天开始出现偶发的消费延迟抖动:监控上看 backlog 没有明显堆积,broker 的 CPU、网络、磁盘都正常,但客户端侧就是会间歇性地出现几秒到十几秒的“空窗期”,消息不是不来,而是来得一顿一顿的。排查了一圈负载均衡、分区分布、GC 日志,最后把怀疑点落到了消费端拉取的调度逻辑上。这件事让我意识到,平时用 Pulsar 消费消息,consumer.receive()一行代码就完事,但这一行背后服务端到底做了什么、什么时候把消息推给客户端、流控是怎么卡的、ack 又是怎么回传的,如果不清楚,遇到这类“不报错但就是慢”的问题基本只能靠猜。

这篇内容就是那次排查之后整理出来的东西,聚焦在Pulsar 服务端消费者消息拉取的底层实现。我会从源码结构出发,把消费端拉取这条链路拆开讲:客户端发起拉取请求后,请求怎么进到 broker、进到哪个组件、ManagedCursor和ManagedLedger各自扮演什么角色、消息怎么从 ledger 里被读出来、读出来之后怎么做流控和排队、ack 又是怎么被处理的。适合已经用过 Pulsar、想深入理解消费模型的人,也适合正在做消息中间件二次开发或者排查消费性能问题的同学。读完之后,你至少能回答三个问题:一条消息从存储到消费者手里经过了哪些关键节点、哪些参数会直接影响拉取效率、出问题时该去哪个环节看指标。

需要先说明一点,Pulsar 的消费模型和 Kafka 那种“客户端主动去 broker 拉、broker 无状态返回”不太一样。Pulsar 服务端为每个消费者维护了一个相当重的状态机,包括游标、待确认队列、流控窗口、重投递逻辑等等。所以“拉取”这个词在 Pulsar 里其实是个双向配合的过程:客户端说“我要消息”,服务端说“我给你这些,但你得先告诉我你还能吃多少”。理解了这个前提,后面的源码看起来才顺。

2. 消费拉取的整体架构与核心组件拆解

2.1 一条拉取请求的完整生命周期

先建立全局视角。客户端调用receive()之后,底层会通过ConsumerImpl发送一个CommandFlow命令给 broker,这个命令携带了当前消费者还能接收多少条消息(permits)。broker 侧收到CommandFlow后,由ServerCnx解析,转交给对应的Consumer对象,最终落到Dispatcher上。Dispatcher是服务端消费逻辑的核心调度器,它负责从ManagedCursor里读取消息、做流控判断、把消息通过连接写回客户端。

整条链路可以粗略概括为:

  • 客户端ConsumerImpl发送CommandFlow,声明可用 permits
  • 服务端ServerCnx.handleFlow()解析并调用Consumer.flowPermits()
  • Consumer把 permits 交给Dispatcher,触发一次或多次readEntries
  • Dispatcher通过ManagedCursor.asyncReadEntriesOrWait()从ManagedLedger读取条目
  • 读到的Entry经过过滤、去重、重投递判断后,封装成CommandMessage
  • 通过ServerCnx写回客户端,同时扣减 permits
  • 客户端 ack 后发送CommandAck,服务端更新游标、释放待确认状态

这条链路里,Dispatcher是绝对的主角,ManagedCursor是数据来源,ManagedLedger是底层存储抽象。三者职责分明:ManagedLedger管“消息存在哪、怎么读”,ManagedCursor管“这个消费者读到哪了、哪些还没确认”,Dispatcher管“什么时候读、读多少、怎么发”。

2.2 Dispatcher 的三种实现与选型逻辑

很多人不知道,Pulsar 服务端的Dispatcher并不是只有一个实现。根据订阅类型的不同,实际使用的是不同的子类:

Dispatcher 实现对应订阅类型核心特点
PersistentDispatcherSingleActiveConsumerExclusive / Failover同一时刻只有一个活跃消费者,读到的消息直接发给它
PersistentDispatcherMultipleConsumersShared多个消费者共享,需要做消息分配和重投递
PersistentStickyKeyDispatcherMultipleConsumersKey_Shared在 Shared 基础上按 key 做粘性分配

为什么这么设计?因为不同订阅类型对“消息顺序”和“消费并行度”的要求完全不同。Exclusive 要求严格顺序,那就没必要搞复杂的分配逻辑,一个消费者从头读到尾最简单。Shared 允许多消费者并行,但同一条消息只能给一个人,所以需要维护一个“待确认集合”和“可用消费者列表”,还要处理某个消费者挂掉之后消息重投递的问题。Key_Shared 更复杂,要保证同一个 key 的消息始终落到同一个消费者,所以额外维护了 key 到消费者的映射。

我个人的经验是,绝大多数消费延迟问题都出在PersistentDispatcherMultipleConsumers上,因为它的状态最多、并发路径最复杂。下面重点讲它。

2.3 ManagedCursor 与 ManagedLedger 的分工

ManagedLedger可以理解为一个“逻辑上的消息日志”,底层由多个Ledger组成,每个Ledger对应 BookKeeper 上的一个或多个 bookie 存储片段。它负责消息的持久化写入和按位置读取,对外暴露的是asyncReadEntriesOrWait()这类方法。

ManagedCursor则是“某个订阅在某个 ledger 上的读取位置”。它记录了markDeletePosition(已确认删除的位置)、readPosition(当前读取位置)、individualDeletedMessages(单条确认的集合)等状态。一个ManagedLedger上可以挂多个ManagedCursor,每个订阅一个,互不影响。

这里有个关键点:消息的读取是 cursor 驱动的,不是 ledger 主动推的。Dispatcher调用 cursor 的读方法,cursor 再去 ledger 里定位数据。如果读到末尾没有新消息,cursor 会注册一个等待,等新消息写入时被唤醒。这个“等待-唤醒”机制是理解消费实时性的关键。

3. 拉取请求的接收与 permits 流控机制

3.1 CommandFlow 的解析与 permits 语义

客户端发送的CommandFlow里只有一个核心字段:permits,表示“我还能接收这么多条消息”。注意是“条数”不是“字节数”,这是 Pulsar 流控的一个特点。服务端收到后,ServerCnx.handleFlow()会做几件事:校验消费者是否存在、校验 permits 是否合法(必须大于 0)、然后调用consumer.flowPermits(permits)。

flowPermits最终会走到Dispatcher的addPermits()。这里有个细节:permits 是累加的。如果客户端连续发了两次CommandFlow,每次 100,那服务端可用 permits 就是 200。但服务端不会无限制累加,Dispatcher内部有一个maxPermits上限,超过部分会被丢弃或者触发告警。这个设计是为了防止客户端异常情况下疯狂发 flow 导致服务端内存被撑爆。

提示:如果你在客户端看到receive()一直阻塞,第一件事就是确认 permits 有没有正常发出去。有些客户端在异步消费模式下,如果回调处理太慢,permits 补充会滞后,表现为消费吞吐上不去。

3.2 流控窗口与消费者阻塞的关系

服务端的流控逻辑可以概括为:没有 permits 就不读消息。Dispatcher在每次读取前都会检查availablePermits,如果小于等于 0,就停止读取,把当前读取请求挂起,等新的 permits 到来再继续。

这个机制直接决定了消费端的背压行为。假设客户端设置receiverQueueSize=1000,那么客户端最多会持有 1000 条未处理消息,对应的 permits 也是围绕这个值来补充的。如果客户端处理慢,permits 补充就慢,服务端读取就慢,最终消息留在 ledger 里不动的。这其实是好事,说明背压生效了。但如果你发现服务端明明有 permits,消息却迟迟不来,那问题可能出在 cursor 的读取等待上,而不是流控。

我踩过的一个坑是:某次把receiverQueueSize调得特别大,以为能提升吞吐,结果客户端内存暴涨,GC 频繁,反而导致 permits 补充不及时,吞吐不升反降。后来把队列调回合理值,配合增加消费者数量,整体吞吐才上去。所以流控参数不是越大越好,要和消费处理能力匹配。

3.3 permits 耗尽后的等待与唤醒路径

当 permits 耗尽时,Dispatcher会进入等待状态。具体来说,PersistentDispatcherMultipleConsumers内部维护了一个isResumed标志和一组等待中的读取请求。当新的 permits 通过addPermits()进来时,会触发readMoreEntries(),重新发起读取。

这里有个容易忽略的点:唤醒不是立即的。readMoreEntries()会先检查是否有正在进行的读取(havePendingRead),如果有,就设置一个标志位,等当前读取完成后再继续。这个串行化设计是为了避免并发读取导致消息顺序错乱。所以在高并发场景下,如果单次读取耗时较长(比如从磁盘读大 batch),permits 补充后并不能立刻触发下一次读取,会有一个等待窗口。这个窗口在极端情况下可能达到几十毫秒,是消费延迟抖动的一个潜在来源。

4. 消息读取的核心:从 cursor 到 entry 的完整路径

4.1 asyncReadEntriesOrWait 的内部逻辑

ManagedCursor.asyncReadEntriesOrWait()是读取消息的入口。它的逻辑大致是:

  1. 检查当前readPosition是否已经到达 ledger 末尾
  2. 如果没到末尾,直接从ManagedLedger读取指定数量的 entry
  3. 如果到了末尾,注册一个等待回调,等新消息写入时触发
  4. 读取到的 entry 经过individualDeletedMessages过滤,去掉已被单条确认的消息
  5. 返回给Dispatcher

这里第 4 步很关键。在 Shared 订阅下,消费者可以单条 ack 消息,这些被 ack 的消息位置会记录在individualDeletedMessages这个区间集合里。读取时需要跳过这些位置,否则会把已经确认的消息又发一遍。这个过滤是在内存里做的,用的是LongPairRangeSet这种高效区间结构,所以即使确认了很多条,过滤开销也不大。

4.2 读取位置的推进与 markDeletePosition 的区别

初学者容易混淆readPosition和markDeletePosition。简单说:

  • readPosition:我读到哪了,下次从这里继续读
  • markDeletePosition:我确认到哪了,这个位置之前的消息可以删了

在 Exclusive 订阅下,两者基本同步推进,因为消息是按顺序确认的。但在 Shared 订阅下,两者可能差很远:消费者可能读了 1000 条,但只确认了其中 500 条(乱序确认),那readPosition在 1000,markDeletePosition可能还在 500 之前。这个差值就是“待确认消息”的数量,也是服务端内存占用的主要来源之一。

注意:如果待确认消息长期不释放,会导致 ledger 无法被删除,磁盘占用持续增长。排查时可以用pulsar-admin topics stats看msgBacklog和unackedMessages两个指标。

4.3 批量读取与单条读取的取舍

Dispatcher读取时有一个maxEntries参数,控制单次读取的最大条数。这个值不是随便设的,它和receiverQueueSize、消息大小、网络 MTU 都有关系。读太多,单次处理时间长,延迟高;读太少,网络往返次数多,吞吐低。

我的经验值是:如果消息平均大小在 1KB 左右,maxEntries设在 100 到 500 之间比较合适;如果消息很大(比如几十 KB),要相应调小,避免单次读取占用过多内存。这个值在Dispatcher内部有默认计算逻辑,但也可以通过配置覆盖。实测下来,默认值在大多数场景下够用,除非你有非常明确的吞吐或延迟目标。

5. 消息分发与重投递的底层细节

5.1 多消费者场景下的消息分配

PersistentDispatcherMultipleConsumers维护了一个consumerList和一个messagesToReplay队列。当有消息读出来时,它会遍历消费者列表,找到第一个“可用”的消费者(有 permits 且没有阻塞),把消息分配给它。

这个“轮询”不是简单的 round-robin,而是带权重的。具体来说,它会优先选择 permits 多的消费者,避免某个慢消费者拖累整体。这个逻辑在getNextConsumer()方法里实现,核心是比较各消费者的availablePermits。

但这里有个问题:如果某个消费者处理特别慢,permits 一直很少,那它拿到的消息就少,看起来像是被“饿死”了。实际上这是设计使然,Pulsar 希望把消息给处理能力强的消费者。如果你希望公平分配,需要调整消费端的处理逻辑,让各消费者处理速度接近。

5.2 重投递机制与 redelivery 触发条件

消息重投递是 Shared 订阅的一个重要特性。触发条件主要有两个:

  • 消费者断开连接,其未确认的消息需要重新分配给其他消费者
  • 消息确认超时(ackTimeout),服务端主动重投

第一种情况在removeConsumer()里处理,会把该消费者对应的individualDeletedMessages之外的消息重新放入messagesToReplay。第二种情况由一个定时任务ackTimeoutTask触发,扫描超时未确认的消息,重新投递。

重投递的消息会优先于新消息发送,这是为了保证消息不会因为重投而无限延迟。但这也意味着,如果 ackTimeout 设置得太短,会导致大量消息被反复重投,消费端看到的就是“同一条消息来了好几次”。我见过有人把 ackTimeout 设成 1 秒,结果消费端疯狂重复消费,最后把 ackTimeout 调到 30 秒才正常。

5.3 消息过滤与去重的实现

在消息真正发给消费者之前,Dispatcher还会做一层过滤。这层过滤主要针对两种情况:一是已经被单条确认的消息(前面提到的individualDeletedMessages),二是被NegativeAcks标记为需要延迟重投的消息。

去重则依赖于MessageId的比较。Pulsar 的MessageId包含 ledgerId、entryId、batchIndex 等信息,可以精确标识一条消息。在重投递场景下,服务端会检查消息是否已经在待确认集合里,避免重复发送。

这里有个性能考量:过滤和去重都是内存操作,但如果待确认集合特别大(比如几十万条),遍历开销就不能忽略了。所以生产环境要控制单消费者的待确认消息数量,不要让它无限增长。

6. 实操排查:消费拉取问题的定位思路与常见坑

6.1 关键指标与日志观察点

排查消费拉取问题,我一般按这个顺序看:

  1. 客户端侧:receive()的调用频率、回调处理耗时、permits 补充日志
  2. 连接侧:CommandFlow和CommandMessage的收发计数,看是否有积压
  3. 服务端 Dispatcher:availablePermits、havePendingRead、messagesToReplay大小
  4. Cursor 侧:readPosition与markDeletePosition的差值、individualDeletedMessages大小
  5. Ledger 侧:读取耗时、等待唤醒次数

Pulsar 自带了不少指标,比如pulsar_subscription_msg_rate_out、pulsar_subscription_blocked_on_unacked_messages,这些在 Grafana 上都能看到。关键是知道每个指标对应链路的哪一段。

6.2 常见问题速查表

现象可能原因排查方向解决思路
消费间歇性停顿permits 补充滞后看客户端回调耗时优化处理逻辑,增大 receiverQueueSize
吞吐上不去maxEntries 太小看单次读取条数适当调大 maxEntries
消息重复消费ackTimeout 太短看重投递计数调大 ackTimeout
内存持续增长待确认消息过多看 unackedMessages检查 ack 逻辑,避免漏确认
消费延迟高单次读取耗时长看 ledger 读取耗时检查磁盘 IO,调整批量大小
消费者被饿死permits 分配不均看各消费者 permits均衡消费处理能力

6.3 几个我踩过的坑

第一个坑是在消费回调里做同步阻塞操作。比如在receive()之后直接调一个耗时几秒的外部接口,结果 permits 补充被卡住,服务端以为客户端处理不过来,停止发送。后来改成异步处理,permits 补充就正常了。

第二个坑是忽略了 batch 消息的确认粒度。Pulsar 支持批量消息,一个 entry 里可能包含多条消息。如果只确认了其中一条,整个 entry 不会被标记删除,导致markDeletePosition推进缓慢。正确做法是确认 batch 内所有消息,或者使用 batch index 级别的确认。

第三个坑是在 Shared 订阅下依赖消息顺序。Shared 订阅本身不保证顺序,多个消费者并行消费,消息到达顺序是乱的。如果业务需要顺序,要么用 Exclusive,要么用 Key_Shared 按 key 保序。这个在选型阶段就要想清楚,后期改成本很高。

7. 从源码到生产:一些个人体会

把 Pulsar 消费拉取这条链路啃下来之后,最大的感受是:它把很多本该客户端做的事搬到了服务端。好处是客户端可以很薄,坏处是服务端状态重、排查复杂。但理解了这套设计之后,很多以前觉得“玄学”的问题都有了合理的解释。

比如消费延迟抖动,以前只能归咎于“网络抖动”,现在知道可能是 permits 补充和读取唤醒之间的时间窗口导致的。再比如吞吐上不去,以前只会加消费者,现在会先看maxEntries和receiverQueueSize是否匹配。

如果让我给正在用 Pulsar 的人一个建议,那就是:不要只盯着客户端 API,花点时间看看服务端 Dispatcher 和 Cursor 的行为。尤其是当你的消费链路出现“不报错但就是慢”的问题时,服务端的流控和读取调度往往是真正的瓶颈所在。源码看起来枯燥,但每读懂一段,排查问题时心里就多一分底气。

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

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

立即咨询