☰
Apache Pulsar Lookup机制源码解析与线上排查实践
2026/10/26 6:11:31 网站建设 项目流程

1. 先搞清楚 Lookup 到底解决什么问题

第一次排查线上超时问题,是在一个跨机房部署的集群上。客户端日志里反复出现Failed to get connection to broker,但 broker 端一切正常,监控指标平得像一条直线。后来抓包才发现,客户端拿到的是一个容器内网地址,拿着这个地址去连,自然连不上。从那一刻起我才认真把 Lookup 这条路径从头读了一遍——它是 Pulsar 客户端与 Broker 之间的“寻址协议”,几乎所有生产、消费、订阅动作在真正建立连接之前,都要先跨过这道门。

Lookup 机制是 Apache Pulsar 里负责“topic 位于哪个 broker 上”的查询流程。客户端 Ping 一下任意一个 broker,问一句“persistent://tenant/ns/order-events现在归谁管”,broker 要么直接回答“就是我,连这个地址”,要么回答“不归我,你去另一个地址”,要么回答“我不确定,但我帮你转过去问”。这个答案决定了后续所有连接的落点。它解决的问题很具体:broker 是可水平扩缩的,topic 的所有权会随着扩缩容、故障转移、负载均衡而漂移,客户端不能把地址写死,必须在运行时动态解析。

这篇文章适合三类人看:一是正在做 Pulsar 集群运维、被“重定向”“连不上”这类问题磨过的人;二是想读 Pulsar 源码但被庞大代码量劝退、想找一条清晰主线切入的人;三是正在做消息中间件选型、想搞清楚 Pulsar 的元数据设计与其他方案差异的人。文中的源码路径以近年主线版本为准,不同小版本的方法签名会有出入,但主干逻辑没有变。

2. Lookup 在整条链路上的位置

2.1 一次生产请求背后其实有好几次往返

很多人以为“客户端连上 broker 就能发消息”,实际上从PulsarClient创建到第一条消息发出,中间至少有这么几步:客户端先从配置的 service URL 列表里挑一个地址建立 TCP 连接,发送CommandConnect完成握手;创建 Producer 时,客户端调用LookupService查询 topic 归属;拿到归属地址后,如果这个地址和当前连接不是同一个 broker,就要新开一条连接或者走代理;连接就绪后才发送CommandProducer注册生产者,broker 侧完成 topic 的加载(可能是从 BookKeeper 恢复 ledger),返回CommandProducerSuccess;这之后才是真正写入消息。

Lookup 的位置就在第三步。它是一次“前置寻址”,不是每条消息都做。客户端内部维护了一层 topic 到连接的映射缓存,同一个 topic 后续复用连接,只有缓存失效、连接断开、broker 返回重定向时才会重新触发。理解这一点很关键:Lookup 的 QPS 不等于消息 QPS,但它往往在集群抖动时集中爆发——因为一次 broker 重启会让成百上千个客户端同时发现旧连接不可用,集体重新寻址。

2.2 为什么不维护一张全局路由表

最直觉的方案是搞一个中心化的路由服务,客户端启动时拉一次全量 topic 到 broker 的映射,本地缓存。这个方案在 topic 数量可控时能跑,但在 Pulsar 的场景下会迅速崩掉:一个中型集群几十万 topic 不稀奇,映射表本身就有几十 MB,每次扩缩容都要全量推送,客户端还要处理版本一致性。更麻烦的是,这个中心服务一旦不可用,整个集群就无法新建连接。

Pulsar 选择的是相反的路:不维护全局表,把“归属关系”下沉成元数据存储里的一个个节点,把查询动作交给 broker 代理。客户端只需要知道任意一个可用 broker 的地址(bootstrap 地址),剩下的靠递归问出来。这个设计的好处是显而易见的——broker 端有内存缓存,热点查询打不到元数据存储;元数据存储只承担写少读多的低频访问;扩缩容时只需要改少数节点,不需要广播。

代价也有:多了一跳网络往返,而且这一跳在极端情况下可能变成两跳甚至三跳(broker 转发给 leader broker,leader 再查元数据)。还引入了重定向语义,客户端必须实现一套状态机来处理“被弹走”的情况。这套状态机如果实现得不够好,就成了各种超时和连接风暴的源头。

2.3 Bundle:把归属粒度从 topic 抬到分片

如果每个 topic 单独记录归属,元数据量会随着 topic 数量线性增长,扩缩容时也得逐个迁移。Pulsar 的做法是引入NamespaceBundle:把一个 namespace 内的 topic 按名称哈希映射到若干个固定数量的区间上,每个区间就是一个 bundle,归属关系记录在 bundle 级别。假设一个 namespace 切了 64 个 bundle,那么无论里面有多少 topic,元数据节点最多也就 64 个。

这个设计的影响很深远。第一,扩缩容时的迁移单位是 bundle,一次搬走的是“某个哈希区间内的所有 topic”,而不是单个 topic,实现简单且原子。第二,负载均衡的判断依据是 bundle 的流量统计,粒度粗但足够用。第三,它带来了一个限制:同一个 bundle 内的 topic 一定落在同一个 broker 上,如果某个 topic 是超级热点,它没办法单独被迁走,只能通过调整 bundle 切分数量来缓解。

bundle 的数量在 namespace 创建时就确定了,默认是 4,生产环境通常按预期 topic 数量调大。这个数字不能随便改,改小会合并区间,改大需要重新分配,都是运维动作。我见过因为 bundle 数量设成默认 4 导致整个 namespace 的流量全压在两三个 broker 上的情况,扩容加了机器也没用,因为新机器分不到 bundle——这是纯配置问题,但排查时很容易被误判成负载均衡算法有问题。

3. 源码入口地图:从哪几行开始读

3.1 客户端侧:LookupService 接口与两个实现

客户端所有寻址动作都收敛在LookupService这个接口上,核心方法是getBroker(TopicName),返回一个CompletableFuture<LookupResult>。这个抽象层的好处是,二进制协议和 HTTP 协议可以共用上层逻辑。默认实现是BinaryProtoLookupService,走 Pulsar 自有协议;另一个是HttpLookupService,走 HTTP 的/lookup/v2/topic/...接口,主要用于 HTTP 客户端场景。

BinaryProtoLookupService的关键字段有三个:client(持有连接池)、serviceNameResolver(决定每次向哪个 broker 发问)、listenerName(多监听器场景下指定用哪个地址)。ServiceNameResolver是个容易被忽略但很重要的组件,它有轮询、单点、内存列表等几种实现。轮询实现下,客户端会在配置的多个 broker 地址之间轮流挑,这天然带了点负载分散的效果;但如果某个地址已经挂了,客户端并不知道,还是要先连一次失败再换下一个,这会放大故障时延。

LookupResult这个返回结构也值得看一眼,它装的不是一个地址而是三个:brokerServiceUrl、brokerServiceUrlTls、nativeUrl。前两个分别对应明文和 TLS 监听,第三个是给函数计算用的。客户端根据自身是否开启 TLS 来选择用哪个。这里有个经典坑:如果集群同时开了明文和 TLS 监听,客户端配置了 TLS 但 broker 返回的 TLS 地址是空的,客户端会直接报错,而错误信息往往只写“无法连接”,不会告诉你是因为地址缺失。

3.2 服务端侧:handleLookup 的三条分支

broker 收到CommandLookupTopic后,进入BrokerService.handleLookup。这个方法不长,但信息密度很高。它先做参数校验(topic 名是否合法、是否带 domain 前缀),然后做权限检查——authorizeTopicLookup会用配置的授权器判断这个连接所属的角色能不能查这个 topic。这一步经常被忽略,但它是多租户集群里 lookup 失败的一大来源:授权器返回拒绝,客户端收到的是Failed响应,日志里可能只说“lookup failed”,得去 broker 日志里翻具体原因。

过了权限这关,进入核心的lookupTopicAsync。它会调用NamespaceService.getBrokerServiceUrlAsync拿归属信息,然后根据结果分三种情况回包:如果归属就是本机,返回Connect;如果归属是别的 broker,返回Redirect并附上对方地址;如果本地拿不到确定答案(比如自己不是 leader、元数据里没有记录),就通过内部的 broker 间连接把请求转发给 leader broker,拿到结果后以Proxy或Connect的形式回给客户端。

这三种响应语义是理解整个机制的关键,我在下面用表格列一下,后面还会展开讲客户端怎么处理:

响应类型触发条件客户端行为典型场景
Connect目标 topic 归属当前 broker直接复用或新建到本机的连接客户端恰好问到 owner
Redirect归属是另一个 broker向新地址重新发起一次 lookup最常见,扩缩容后高频出现
Proxy当前 broker 无法直接确定归属通过当前连接建立代理通道非 leader broker 收到查询
Failed权限不足、topic 不存在、bundle 未就绪抛异常或按策略重试配置错误、集群抖动

Proxy这一支是后面版本引入的优化。在更早的实现里,非 leader broker 收到查询后会把请求直接打回客户端,让客户端自己去问 leader——如果客户端配置的 bootstrap 地址又不是 leader,就会形成一次无意义的往返。改成服务端内部转发后,客户端只需要跟一个 broker 打交道,链路短了,失败面也小了。

3.3 协议层:CommandLookupTopic 里带了什么

CommandLookupTopic的字段不多,但每个都有用:topic是完整 topic 名,request_id用于匹配响应,authoritative表示客户端是否要求一个“权威答案”,advertised_listener_name在多监听器场景下指定客户端希望的监听器名字。

authoritative这个标志位值得单独说。客户端在两种情况下会把它置为 true:首次查询时(想要最准确的答案),以及收到过一次非权威答案后想再确认一次。broker 收到 authoritative 请求时,不能拿本地缓存糊弄,必须确保结果是当前最新的,拿不到就要去元数据存储读。这就带来一个隐含的性能特征:集群刚启动或者大量客户端冷启动时,第一批 lookup 会击穿缓存打到元数据存储。如果元数据存储本身扛不住,就会引发连锁反应。

响应侧的CommandLookupTopicResponse除了上面说的四种response类型,还带了authoritative回传、proxy_through_service_url标记(告诉客户端代理要用 service URL 而不是 broker URL)。这些小字段平时不用管,排查多监听器问题时却很有用。

4. 一次 Lookup 的完整源码走读

4.1 客户端第一跳:选地址、建连接、发命令

BinaryProtoLookupService.lookupTopicAsync是整个流程的起点。它的第一件事是从serviceNameResolver里解析出一个地址,然后调连接池的getConnection。这里有一个细节:连接池的 key 是逻辑地址加物理地址的组合,逻辑地址是配置里的 service URL,物理地址是 DNS 解析后的实际地址。这样设计是为了在 DNS 记录变化时能感知到,避免一直连旧 IP。

拿连接的过程是异步的,如果池里没有现成连接就新建一条,此时要完成CommandConnect握手,还要做认证(如果配置了)。认证失败在这里就会让整个 lookup 失败,报错信息通常是Authentication failed,跟寻址本身无关,但现象很像——所以排查时第一件事是确认到底是认证问题还是寻址问题。

连接就绪后,Commands.newLookup(...)构造出协议命令,通过sendRequestWithId发出去。这里用的是请求 ID 匹配机制,响应回来时在挂起请求表里找到对应的 future 完成。整个过程有超时保护,超时时间来自客户端的lookupTimeoutMs(默认 30 秒)与操作超时取小值。超时后 future 被异常完成,但底层的连接不会立刻关闭,这就可能出现“客户端已经放弃、broker 还在处理”的短暂不一致。

响应回来后,代码进入一个switch,这是整段逻辑最需要读懂的地方:

switch (response.getResponse()) { case Connect: // 归属就是刚才问的那个 broker,直接完成 future.complete(new LookupResult(serviceUrl, serviceUrlTls, nativeUrl, false)); break; case Redirect: // 换一个地址重来,注意这里传了 isRetry = true lookupTopicAsync(topicName, options, true).thenComplete(future); break; case Proxy: // 通过当前连接代理到目标 broker future.complete(new LookupResult(serviceUrl, serviceUrlTls, nativeUrl, true)); break; case Failed: future.completeExceptionally(new PulsarClientException(...)); break; }

Redirect分支里那个isRetry标志必须留意。它不只是个布尔值,还带了一整套限制:重试次数上限来自maxLookupRedirects(默认 20),超过就抛异常;重试时如果连续多次被弹到同一个地址,客户端会怀疑集群状态异常并主动放弃。这套机制是为了防止重定向循环把客户端拖死。我见过把maxLookupRedirects调到很大的配置,本意是“多试几次总能成”,结果在元数据异常时客户端死磕了半分钟才报错,还不如早点失败让上层重试。

4.2 Broker 侧:本地缓存未命中时怎么办

服务端的分支逻辑集中在NamespaceService.getBrokerServiceUrlAsync。它的查找顺序是这样的:先看本地 ownership 缓存,命中就直接构造结果返回;没命中就去元数据存储读对应 bundle 的节点;如果读到的归属是本机,顺手把缓存填上;如果读到的是别的 broker,也缓存一份(带过期时间);如果节点不存在,说明这个 bundle 当前没人认领,需要返回“未找到”让上层去触发分配。

这里有个很关键的设计:缓存里存的不是“答案”,而是“答案的来源”。具体说,缓存的值是一个CompletableFuture<Optional<BrokerServiceUrl>>,多个并发查询命中同一个 key 时会等待同一个 future,而不是各自发一次元数据读。这就是所谓的“请求合并”。在客户端冷启动风暴的场景下,这个设计能把本来 N 次元数据读压成 1 次,价值极大。读源码时如果只看到Map.get就以为只是简单缓存,会低估这段代码的分量。

元数据节点的路径规则大致是/admin/namespaces/{tenant}/{namespace}/{domain}/{encoded-topic-local-name}这样的结构,其中 domain 区分持久化和非持久化,最后一段是 topic 本地名的编码结果。节点里存的通常是 owner broker 的 service URL(JSON 序列化)。这个路径结构意味着 bundle 级别的归属是按 topic 的哈希区间推导出来的,不是每个 topic 一个节点,读的时候先用 topic 名算出 bundle,再读对应节点。

如果本机不是 leader broker 且本地查不到,就进入转发逻辑。这里用的是一个内部的 broker 间客户端,它会向 leader 发一个类似 lookup 的请求,leader 查完自己的缓存或元数据后返回结果,当前 broker 再把结果包装成Proxy或Connect回给客户端。转发有独立的超时和重试,失败时回Failed,客户端会当作一次失败的 lookup 处理。

4.3 所有权缓存的写入与失效

OwnershipCache的写入有两个来源:一是 broker 通过负载均衡分配到 bundle 后主动写入(这叫“认领”),写入时会带一个会话级别的临时节点语义,broker 掉线后节点自动消失;二是读路径上的被动缓存填充,只写内存不写元数据。

失效的触发点就更多了:负载均衡卸载 bundle、broker 关闭、元数据 watch 通知节点变化、缓存条目过期。其中元数据 watch 是最容易被忽略的一条——Pulsar 会在 ownership 节点上挂 watcher,节点一变就清掉对应缓存。这保证了跨 broker 的一致性,但也意味着元数据存储的 watch 能力是强依赖,如果 watch 丢失(网络抖动、会话超时),缓存就会变脏,表现为“客户端一直被重定向到一个已经不再负责该 topic 的 broker”。

我遇到过一次因为元数据存储会话超时导致的脏缓存:broker A 已经卸掉了某个 bundle,但 broker B 的缓存里还记着 A,客户端问 B 就被弹到 A,A 说不是我,又弹回来,来回几次后客户端放弃。日志里能看到连续的Redirect,但两边都不认为自己有问题。最后是靠重启 B 才恢复。这类问题的根因往往在元数据存储的会话健康度上,排查时要先看会话超时相关的监控,而不是盯着 broker。

4.4 客户端拿到结果之后

LookupResult返回给上层后,PulsarClientImpl会用它去连接目标 broker。如果是Proxy模式,客户端不会新建物理连接,而是在已有连接上开一条逻辑通道,后续命令都带一个 proxy 标记。这个模式的好处是省连接数,坏处是代理 broker 成了单点——如果它挂了,客户端的所有代理连接一起断。

连接建立后,客户端会把 topic 到连接的映射写进本地缓存。这个缓存没有主动过期,只靠连接断开事件来清理。所以如果 broker 侧发生了不通知客户端的归属变更(比如元数据被手工改动),客户端会一直用旧连接,直到连接出错。这也是为什么手工改元数据是高风险动作——它绕过了一整套通知机制。

5. 把 Lookup 过程看得见:实操验证

5.1 用管理命令确认归属

最直接的办法是用管理工具查一次 lookup。命令大致是这样的:

bin/pulsar-admin topics lookup persistent://public/default/order-events

输出是一个 broker 地址,比如pulsar://broker-2:6650。这条命令走的是 admin 接口,内部逻辑和客户端 lookup 基本一致,可以当作“客户端会拿到什么”的权威参考。如果输出和客户端日志里的地址不一致,说明客户端侧有缓存或者配置了不同的 service URL。

想看 bundle 的切分情况,用这个:

bin/pulsar-admin namespaces bundles public/default

输出会列出每个 bundle 的哈希区间和当前 owner。如果所有 bundle 的 owner 都是同一台机器,说明负载均衡没生效或者 bundle 数量太少。我建议在集群刚搭好、还没上业务的时候就把这个命令跑一遍,先确认切分合理,别等出问题再回头查。

5.2 打开 debug 日志观察完整流程

客户端侧把org.apache.pulsar.client.impl.BinaryProtoLookupService的日志级别调到 DEBUG,能看到每次 lookup 的目标地址、响应类型、耗时。broker 侧调org.apache.pulsar.broker.lookup包下的日志,能看到权限检查结果、缓存命中情况、是否发生了转发。

日志里最值得盯的是响应类型分布。如果Connect占绝大多数,说明缓存命中率高,链路健康;如果Redirect比例突然上升,通常意味着最近有 broker 上线或下线,集群在做重平衡。这个比例可以在监控系统里做成一个指标,比盯着单条日志有效得多。

5.3 手工构造一次重定向

想真正理解重定向,最好的办法是主动制造一次。步骤是:先查某个 topic 的 owner,然后用管理命令把这个 bundle 卸载掉,再查一次。卸载命令大致是:

bin/pulsar-admin namespaces unload public/default --bundle 0x00000000_0x40000000

卸载后 owner 会短暂为空,负载均衡器会在几秒内重新分配。这期间发起的 lookup 可能拿到Failed或触发一次重定向。把这个过程中的客户端日志和服务端日志对着看,比读十遍代码都有用。需要注意的是别在生产环境随便卸载,尤其是在流量高峰,卸载会触发 topic 重新加载,短暂不可用。

还有一种构造方式是改配置:把客户端的 bootstrap 地址指向一个不是 leader 的 broker,然后查一个归属在其他 broker 上的 topic。这样能稳定复现Proxy分支,观察代理连接是怎么建立的。

6. 常见问题与排查实录

6.1 地址不可达:最常见的那个坑

现象是客户端日志报连接超时,但 broker 日志显示 lookup 成功返回了。根因几乎总是同一条:broker 返回的地址在客户端网络里不可达。

具体有三种典型成因。第一种是容器化部署时没配advertisedAddress或advertisedListeners,broker 用了默认的机器名或容器内网 IP 当对外地址,客户端拿到后连不上。第二种是多监听器配置下,客户端没有指定listenerName,broker 返回了一个客户端网络策略里不通的监听地址。第三种是 DNS 解析差异,broker 返回主机名,客户端所在环境的 DNS 解析到了错误 IP。

排查方法很朴素:先在客户端机器上telnet或nc一下拿到的地址和端口,通不通立刻见分晓。如果通,再确认是不是 TLS 端口当成了明文端口用。这个问题看起来低级,但因为现象和“集群故障”极其相似,每次都能骗到人。

6.2 重定向风暴:循环是怎么形成的

客户端连续收到Redirect,每次都去新地址,新地址又把它弹回来,直到maxLookupRedirects耗尽。这种日志的特征是同一个 topic 在短时间内出现十几条重定向记录,目标地址来回跳。

形成原因主要有两个。一是前面说的脏缓存:broker 之间对归属的认知不一致,A 认为归 B,B 认为归 A。二是元数据存储里确实存在一个指向错误 broker 的节点,通常是 broker 异常退出后临时节点没被清掉(比如会话没有正常关闭)。第二种情况在容器环境更常见,因为容器被强杀时会话来不急优雅关闭,只能等会话超时后节点才消失,这段时间窗口内所有查询都会指向那个已经不存在的 broker。

处理办法分两步:短期靠重启有脏缓存的 broker 清掉本地状态;长期靠缩短元数据会话超时时间,让异常节点更快消失。会话超时也不能设太短,否则网络抖动会导致正常的 broker 被误判下线,反而制造更多重定向。这个参数需要在“收敛速度”和“稳定性”之间找平衡,我的经验是从 30 秒起步,观察一段时间再调。

6.3 ServiceUnitNotReady:bundle 尚未分配

这个错误的字面意思是“服务单元没准备好”,实际含义是“这个 bundle 当前没有 owner”。常见于三种时机:集群刚启动还没完成分配、bundle 刚被卸载还在等待重新分配、负载均衡器认为该 bundle 应该迁走但目标 broker 还没接手。

客户端收到这个错误后通常会自动重试,重试间隔有退避策略。如果持续几十秒还是这个错误,那就要查负载均衡器了。看 leader broker 的日志里有没有分配失败记录,看有没有 broker 处于“不可用”状态却没被剔除。我遇到过因为某台 broker 的磁盘满导致它拒绝接受新 bundle,但集群没把它标记为不可用,于是分配器一直往它身上派活、一直失败的情况。

6.4 排查速查表

把上面这些整理成一张表,出问题时按顺序对一遍,能省不少时间:

现象优先怀疑快速验证手段
lookup 成功但连不上对外地址配置错误客户端机器上直连返回的地址端口
连续重定向broker 间缓存不一致对比各 broker 日志中的 owner 判断
ServiceUnitNotReady 持续bundle 无 owner查 bundles 列表与负载均衡器日志
lookup 超时无响应元数据存储慢或 GC 停顿看元数据请求耗时与 broker GC 指标
权限类 Failed授权器配置查 broker 日志中的授权拒绝记录
只有部分客户端失败DNS 或网络策略差异对比成功与失败客户端的解析结果

7. 参数与调优:几个真正会影响结果的开关

7.1 客户端侧的关键参数

lookupTimeoutMs控制单次 lookup 的超时,默认值和操作超时挂钩。这个值设太小会在集群抖动时大量误判失败,设太大则故障时客户端卡住很久。生产上我一般保持在默认值附近,靠上层重试兜底,而不是把超时调大来“掩盖”问题。

maxLookupRedirects控制重定向次数上限。前面提过,调大不是好事,它会让真正的循环问题拖更久才暴露。

maxNumberOfRejectedRequestPerConnection这个名字看起来跟 lookup 无关,但它影响连接被拒绝后的行为,间接影响重试节奏。默认值偏保守,高并发场景下适当调整能减少连接重建带来的抖动。

连接池相关的参数(每台 broker 的最大连接数、连接空闲时间)也会影响 lookup 的表现。连接池太小,lookup 会因为拿不到连接而排队;空闲时间太短,连接频繁重建,每次重建都要重新握手,放大延迟。

7.2 服务端侧的关注点

advertisedAddress和advertisedListeners是必须配对的。单机部署可以只配前者,多监听器或者容器化部署必须把后者配对,否则返回的地址一定是错的。

bundle 数量在 namespace 创建时确定,pulsar-admin namespaces create时可以通过参数指定。我的建议是按预期峰值 topic 数的十分之一来估,同时保证 bundle 数不少于 broker 数的两倍,这样扩缩容时才有腾挪空间。数量太少会导致分配不均,数量太多会让元数据节点变多、负载均衡计算变重。

负载均衡的检查周期也值得调。默认周期偏长,扩缩容后要等一会儿才看到流量迁移;调短能让收敛更快,但会增加 leader broker 的计算压力。这个参数没有标准答案,取决于集群规模和变更频率。

7.3 几个容易忽略的实操心得

第一,把 lookup 的耗时做成监控指标。客户端侧可以埋点,服务端侧可以看方法耗时。这个指标平时很平稳,一旦出现长尾,往往是元数据存储或者 GC 的先兆。

第二,升级或重启 broker 时控制节奏。一批重启会让大量 bundle 同时失去 owner,触发集中重新分配和客户端重定向。分成小批、每批之间留出收敛时间,对客户端体验的影响会小一个数量级。

第三,不要在业务高峰期做 namespace 级别的 bundle 调整。这个操作会触发大规模迁移,虽然单次不可用时间很短,但迁移期间的 lookup 失败率会明显上升。

第四,客户端实例要复用。每个PulsarClient实例都有自己的连接池和 lookup 缓存,频繁创建销毁等于把缓存全部作废,把所有压力转嫁给服务端。这条看起来是常识,但在函数式计算或者短生命周期任务的场景里特别容易违反,我见过每次请求都新建客户端的写法,服务端的 lookup QPS 直接翻了两个数量级。

第五,多监听器配置要在客户端显式指定。如果你的集群同时提供内网和公网监听,客户端必须明确告诉 broker 自己要哪个地址,否则拿到的可能是另一个网络里的地址。这个配置项不填不会报错,只会在连接阶段失败,排查成本很高。

8. 最后说点读源码的体会

读 Lookup 这段代码最大的收获不是记住某个方法名,而是理解了一种设计权衡:把“谁负责什么”这种易变的状态放到外部元数据存储,把“怎么找到它”这个高频动作放在 broker 内存里,中间用缓存和转发把两者缝起来。这个结构决定了 Pulsar 的扩缩容可以做到秒级收敛,也决定了它在元数据存储出问题时的脆弱性——所有优雅的设计都建立在那层存储稳定可用的前提上。

我自己在排查时养成了一个习惯:先在客户端确认它拿到了什么地址,再去 broker 确认它为什么给出这个地址,最后去元数据确认这个地址是不是真的。三步走下来,绝大多数 lookup 相关的问题都能定位。反过来,直接从代码猜原因往往会在错误的方向上浪费很多时间。

还有一个细节值得单独提一句:不同版本在这个链路上的实现差异不小,早期版本没有 broker 间转发,全部靠客户端重定向;较新的版本引入了服务端转发和请求合并。如果你读的源码和线上跑的版本对不上,很多结论会失效。动手之前先确认版本号,这件事比读代码本身更重要。

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

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

立即咨询