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,触发一次或多次readEntriesDispatcher通过ManagedCursor.asyncReadEntriesOrWait()从ManagedLedger读取条目- 读到的
Entry经过过滤、去重、重投递判断后,封装成CommandMessage - 通过
ServerCnx写回客户端,同时扣减 permits - 客户端 ack 后发送
CommandAck,服务端更新游标、释放待确认状态
这条链路里,Dispatcher是绝对的主角,ManagedCursor是数据来源,ManagedLedger是底层存储抽象。三者职责分明:ManagedLedger管“消息存在哪、怎么读”,ManagedCursor管“这个消费者读到哪了、哪些还没确认”,Dispatcher管“什么时候读、读多少、怎么发”。
2.2 Dispatcher 的三种实现与选型逻辑
很多人不知道,Pulsar 服务端的Dispatcher并不是只有一个实现。根据订阅类型的不同,实际使用的是不同的子类:
| Dispatcher 实现 | 对应订阅类型 | 核心特点 |
|---|---|---|
PersistentDispatcherSingleActiveConsumer | Exclusive / Failover | 同一时刻只有一个活跃消费者,读到的消息直接发给它 |
PersistentDispatcherMultipleConsumers | Shared | 多个消费者共享,需要做消息分配和重投递 |
PersistentStickyKeyDispatcherMultipleConsumers | Key_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()是读取消息的入口。它的逻辑大致是:
- 检查当前
readPosition是否已经到达 ledger 末尾 - 如果没到末尾,直接从
ManagedLedger读取指定数量的 entry - 如果到了末尾,注册一个等待回调,等新消息写入时触发
- 读取到的 entry 经过
individualDeletedMessages过滤,去掉已被单条确认的消息 - 返回给
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 关键指标与日志观察点
排查消费拉取问题,我一般按这个顺序看:
- 客户端侧:
receive()的调用频率、回调处理耗时、permits 补充日志 - 连接侧:
CommandFlow和CommandMessage的收发计数,看是否有积压 - 服务端 Dispatcher:
availablePermits、havePendingRead、messagesToReplay大小 - Cursor 侧:
readPosition与markDeletePosition的差值、individualDeletedMessages大小 - 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 的行为。尤其是当你的消费链路出现“不报错但就是慢”的问题时,服务端的流控和读取调度往往是真正的瓶颈所在。源码看起来枯燥,但每读懂一段,排查问题时心里就多一分底气。