先说结论:我们今年把内部的 Ask AI 问答服务整体迁到了 Apache Pulsar 上,现在用户任何时候发问题,系统都能接得住、排得上、回得准。这篇文章是这次迁移的完整复盘,里面有 Pulsar 架构的核心学习笔记、Ask AI 服务与 Pulsar 的集成方案、7*24 可用性的保障手段,以及我们在线上的几次重大踩坑记录。如果你在给 AI 问答、智能客服这类服务选消息中间件,或者刚接触 Pulsar 想把架构吃透,这篇应该能帮你省下不少自己折腾的时间。
1. 需求背景:为什么 AI 问答服务必须引入消息队列
1.1 场景定位:这个 Ask AI 到底服务谁
Ask AI 是我们团队维护的一个智能问答服务,对内给研发、运营、客服同事回答产品问题、查知识库,对外也开放给一部分合作客户。用户丢进来一个问题,系统先做意图识别和知识库检索,再调用大模型生成答案,最后把结果返回给用户。
这个服务刚上线的时候是典型的同步请求-响应模型:客户端发 HTTP 请求,服务端边检索边推理,等大模型吐完答案再返回。平时流量不大还好,一旦进入工作日的上午十点,几千个员工同时提问,大模型推理本身就慢,请求一多,网关直接超时,用户看到的就是转圈转到一半告诉你"请求失败"。更棘手的是夜间虽然流量低,但并不是零,而且凌晨反馈问题的人往往更着急,一旦服务抖动,影响非常直接。
真正让我们痛下决心改造的是几次线上事故。一次是知识库更新导致检索服务耗时翻倍,同步调用链路上的线程池被打满,服务雪崩;另一次是大模型供应商限流,所有请求排队等令牌,HTTP 连接被占满,连带影响了不相关的业务接口。当时我们意识到:AI 问答这种"慢业务"绝对不能直接暴露在同步链路上,必须有一层消息系统来做缓冲、削峰、异步化和故障隔离。
1.2 为什么是 Pulsar:对比 Kafka 后的三个关键决策点
选型时我们在 Kafka 和 Pulsar 之间纠结了很久。Kafka 生态成熟、团队也熟悉,但深入比对后发现,Pulsar 在几个关键点上更适合 AI 问答场景。
第一个决策点是计算与存储分离。Kafka 的存储和分区是绑死的,消费者扩容上限受分区数限制,分区数又受 Broker 磁盘和文件句柄限制。Pulsar 把 Broker 和存储层拆开,Broker 不落盘,扩容消费者不用搬数据,对于 AI 问答这种"请求量波动大、推理耗时长"的场景,弹性要好太多。
第二个决策点是订阅模型的丰富度。Pulsar 提供了 Exclusive、Shared、Failover、Key_Shared 四种订阅模式,其中 Key_Shared 能保证同一个 key 的消息按顺序发给同一个消费者,同时其他 key 的消息还能被其他消费者并行处理。这对 AI 问答至关重要——同一个用户的多轮问题需要保持会话上下文,但不同用户之间不需要排队等顺序。
第三个决策点是分层存储。Pulsar 可以把老消息自动卸载到对象存储,我们不需要像 Kafka 那样手动管理磁盘水位或做日志压缩。问答系统天然会产生大量历史日志,这些数据既要保留分析,又不能占用热存储,Pulsar 的分层存储几乎是开箱即用。
注意:这里不是要否定 Kafka。如果你们是纯事件流场景、流量比较稳定,Kafka 依然是好选择。AI 问答这种请求耗时差异巨大、并发波动明显、还要求会话级顺序的场景,Pulsar 的架构优势会更大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Pulsar 架构关键点:学透这四层,运维才不慌
既然选型定了 Pulsar,团队就得把架构吃透。这一节是我们从源码、官方文档和线上故障中总结出来的核心笔记,不追求面面俱到,只讲真正影响日常使用和排障的部分。
2.1 计算与存储分离:Broker 为什么能"无状态"
Pulsar 本质上是一个三层架构:最上层是无状态的 Broker,负责处理生产者和消费者的请求、管理 topic 的元数据、执行订阅逻辑;中间层是 BookKeeper 集群,负责真正的数据落盘和副本管理;最底层是 ZooKeeper,负责集群元数据、ledger 信息和 broker 之间的协调。
Broker 最重要的特性是"无状态"。它不持久化用户数据,只把数据转发给 BookKeeper。这意味着我们可以像加 Web 服务节点一样随意增加或摘除 Broker,不需要做数据搬迁,客户端通过服务发现自动感知变化。
打个比方:Broker 像是餐厅门口的迎宾和点餐员,BookKeeper 是后厨和仓库。客人再多,增加点餐员就能应对,后厨不用跟着搬。Kafka 的架构更像是把餐桌和后厨绑在一起,加服务员的时候还得考虑后厨空间和食材存放位置,麻烦得多。
2.2 BookKeeper 存储层:Ledger、Segment 与读写链路
理解了 Broker 无状态,接下来要搞清楚数据到底怎么存。在 Pulsar 里,一个 topic 的数据会被切分成多个 segment,每个 segment 在 BookKeeper 里对应一个 ledger。Ledger 是一个只追加写入的日志结构,分布式存储在多个 bookie 节点上。
这里有个关键参数组合:E(ensemble size)、W(write quorum)、A(ack quorum)。比如配置成 E=3、W=3、A=2,意思是每条消息写入 3 个副本,只要其中 2 个确认成功就算写入完成。这样既保证了数据冗余,又不需要等最慢的那个副本,写延迟可以压得很低。
BookKeeper 的写入链路也很有意思:每个 bookie 上有两种日志,一个是 journal(预写日志),记录操作的元数据和顺序信息,另一个是 entry log,存实际数据。消息先写 journal 保证顺序和持久性,再批量刷入 entry log。这跟数据库的 WAL 机制是一个思路,理解了 journal 和 entry log 的关系,就理解了为什么 BookKeeper 能同时保证高性能和强一致。
读链路方面,Broker 从 bookie 读取消息时会利用 read-ahead cache,因为用户消费通常是顺序读,预取可以显著降低延迟。我们实测下来,在热数据场景下 Pulsar 的读延迟完全可以做到毫秒级。
2.3 订阅模型:四种模式如何部署到问答场景
Pulsar 的订阅模型是它和 Kafka consumer group 思路差异最大的地方。四种模式各有适用场景,这里直接对齐到我们的业务场景:
| 订阅模式 | 消息分发方式 | 典型场景 |
|---|---|---|
| Exclusive | 一个订阅只能有一个消费者,消息严格顺序 | 全局有序事件、配置变更 |
| Failover | 一个消费者为主,其他为备,主挂自动切换 | 对顺序敏感且需要高可用 |
| Shared | 消息按轮询/负载算法分发给多个消费者,无序 | 高吞吐、对顺序不敏感 |
| Key_Shared | 相同 key 的消息发给同一个消费者,不同 key 可并行 | AI 问答会话、用户分片处理 |
Ask AI 核心请求链路用的是 Key_Shared,key 取会话 ID。同一个会话的多轮问答按顺序落到同一个 Worker,保证上下文完整;不同会话之间并行处理,最大化吞吐。
2.4 游标与消息确认:不丢消息的基础
消息队列的可靠性最终落到"游标"和"确认"这两个机制上。Pulsar 的订阅游标记录了每个消费者消费到的位置,消息被确认后游标前移,未确认的消息会按策略重新投递。
Pulsar 的确认模型非常灵活,可以做单条确认(ack individual message),也可以做累积确认(ack cumulative)。在 AI 问答场景我们强烈建议用单条确认——因为大模型推理可能失败,需要精确到单条消息重试,累积确认会把失败消息之前的消息全部标记为已消费,容易丢数据。
另外要注意 ackTimeout 和 negativeAckRedeliveryDelay 这两个参数。ackTimeout 是消费后长时间未确认自动重新投递的时间,negativeAck 是业务主动否定这条消息后多久重投。很多同学只调 ackTimeout 不调 negativeAck,结果业务里调用了 negativeAcknowledge() 之后还是按 ackTimeout 的时长重试,跟预期完全不符。
3. Ask AI 接入 Pulsar 的完整设计方案
3.1 整体拓扑:从一个问题到一次完整回复
我们最终的拓扑分两条链路:请求链路和响应链路。请求链路是用户问题进 Pulsar、Worker 消费后处理;响应链路是处理结果回写 Pulsar、网关订阅后推给用户。这样做的目的是把"慢推理"对网关的影响彻底隔离开。
text复制用户/客户端
↓ HTTPS
API网关(鉴权、限流、协议转换)
↓ 生产请求消息
Topic: persistent://askai/online/question
↓ 消费
Ask AI Worker(检索 + 上下文组装 + LLM推理)
↓ 生产结果消息
Topic: persistent://askai/online/answer
↓ 消费
响应网关(关联请求、推送结果)
↓ HTTPS 长轮询/SSE
用户终端
这里有个容易被忽略的设计点:为什么不直接让 Worker 调用网关的回调接口?因为回调链路会让 Worker 依赖网关的可用性和连接数,网关挂了 Worker 也跟着出问题。多绕一圈 Pulsar,Worker 只认消息队列,不认 HTTP 连接,故障边界一下就清晰了。
3.2 生产者端:批量、限流与超时,一个都不能省
生产者的配置直接影响请求进入 Pulsar 的吞吐和可靠性。我们用的是 Java 客户端,核心配置如下:
java复制PulsarClient client = PulsarClient.builder()
.serviceUrl("pulsar://pulsar-prod.internal:6650")
.enableTcpNoDelay(true)
.build();
Producer<String> producer = client.newProducer(Schema.STRING)
.topic("persistent://askai/online/question")
.enableBatching(true) // 开启批量
.batchingMaxPublishDelay(5, TimeUnit.MILLISECONDS) // 批量积攒时间
.batchingMaxMessages(500) // 批量积攒条数
.maxPendingMessages(10000) // 发送队列上限
.blockIfQueueFull(true) // 队列满则阻塞
.sendTimeout(10, TimeUnit.SECONDS) // 发送超时
.compressionType(CompressionType.LZ4) // 压缩
.create();
关键点一个一个说。enableBatching 配合 5ms 的 batchingMaxPublishDelay,可以让高并发下相邻的几条消息合并成一次 RPC 发送,显著降低 Broker 压力。blockIfQueueFull(true) 是背压机制,后端处理不过来时,请求在网关层就开始排队,而不是把消息发进 Pulsar 后无限积压。sendTimeout 设成 10 秒是告诉业务方:超过 10 秒没确认,这条消息算发送失败,需要返回给用户"系统繁忙"而不是让他们干等。
压缩选 LZ4 而不是 ZSTD,是因为 LZ4 在低延迟场景下 CPU 开销更小。问答请求体一般 5~10KB,LZ4 的压缩率够用,但不至于把 CPU 打满。
3.3 消费者端:并发模型、会话保持与动态扩缩容
消费者的设计决定了大模型推理的并发上限。大模型推理是 CPU/GPU 密集型操作,单个请求要占用算力好几秒,如果消费者线程就等于推理并发线程,消息队列还没积压,节点资源先打满了。
我们的做法是消费和推理解耦:Pulsar Consumer 用 Listener 模式接收消息后,立即丢进一个有界线程池执行推理任务。线程池大小根据 GPU 或 CPU 配额精确计算,而不是拍脑袋设个 50。比如一台 Worker 有 4 个 GPU,单卡最多同时跑 2 个推理任务,那线程池大小就是 8,多了只会排队占内存,少了浪费算力。
java复制Consumer<QuestionMessage> consumer = client.newConsumer(Schema.JSON(QuestionMessage.class))
.topic("persistent://askai/online/question")
.subscriptionName("askai-worker")
.subscriptionType(SubscriptionType.Key_Shared)
.ackTimeout(60, TimeUnit.SECONDS)
.negativeAckRedeliveryDelay(5, TimeUnit.SECONDS)
.deadLetterPolicy(DeadLetterPolicy.builder()
.maxRedeliverCount(3)
.deadLetterTopic("persistent://askai/online/question-dlq")
.build())
.subscribe();
会话保持靠消息的 key。HTTP 网关把会话 ID 放进消息的 key,Key_Shared 模式下 Pulsar 会保证同一会话的消息始终发给同一个消费者。这里要注意,如果某个消费者长时间不确认某条消息,Key_Shared 的 key 会被"卡住",同一个会话的后继消息都会被阻塞等待,所以消费端一定要有超时兜底,不能无限等大模型返回。
扩缩容我们用 KEDA 基于 Pulsar 的 backlog 指标做自动伸缩。Backlog 超过阈值时加 Worker,低于阈值减 Worker。KEDA 的 Pulsar scaler 官方就支持,不用自己写指标适配器,这是选 Pulsar 的一个隐性福利。
3.4 消息可靠性与失败重试:哪些消息必须进 DLQ
消息可靠性设计遵从一条原则:尽力重试、有限次重试、最终隔离。我们的策略分三层:
第一层,消息消费失败时先调用 negativeAcknowledge(),让消息在 5 秒后重新投递,适用于模型偶发超时、下游短暂抖动。第二层,同一消息重投 3 次仍然失败,Pulsar 客户端自动把它转入死信主题(DLQ),由人工或补偿任务处理。第三层,响应网关订阅 DLQ 后将失败原因记录到日志系统,并通知用户"问题进入人工处理队列"。
DLQ 是消息队列的"垃圾回收站",但它不是设计出来让你随便扔消息的。我们给 DLQ 配了单独的监控大盘,DLQ 里每多一条消息,值班群就响一次告警。消息进 DLQ 意味着业务没走通,可能是模型问题、知识库问题、也可能是我们的代码 bug,必须有人去看,不能让它安静地躺在那里。
提示:消息确认一定要做到"处理完再确认"。我们把推理结果写入响应 topic 并且落库之后才 ack,就是为了防止"消息处理了但确认丢了"导致重复推理。下游有幂等保证,重复写入最多覆盖一次,不会造成数据错乱。
4. 7*24 可用性:从集群部署到异常自愈的完整打法
4.1 集群拓扑与容灾配置
Pulsar 生产环境最小配置是三套集群角色分开部署:3 个 ZooKeeper 节点、3 个 BookKeeper 节点、3 个 Broker 节点。我们的实际配置是 ZooKeeper 3 台、Bookie 6 台、Broker 5 台,Broker 多出来的两台是为了应对流量高峰弹性扩展。
容灾这块有两点容易踩坑。第一,Bookie 节点要设置 anti-affinity,确保同一份数据的多个副本不要落在同一台物理机或同一个机架上。我们有次扩容直接把双副本放到了一个机架,结果那台机架的交换机抖动,一个 topic 的三个副本挂掉两个,差点酿成数据不可用事故。第二,topic 级别要显式设置 E/W/A,我们的核心问答 topic 用的是 E=3、W=3、A=2,既保证两副本故障还能读,又不至于因为一个书签慢拖垮写入延迟。
4.2 消息积压与消费延迟的监控治理
7*24 的高可用,一半靠架构,一半靠监控及时发现问题。我们重点盯三个指标:
第一个是 backlog(未消费消息数),这是最直接的积压信号。第二个是消费延迟,也就是最早未确认消息到当前时间的差距。第三个是生产与消费速率的比值,可以提前预判是否要扩容。
告警阈值一定要分级别。我们设了两级:backlog 超过 1 万条进值班群,超过 5 万条直接电话叫醒。但警告不能只看阈值,要结合消费速率判断。有次 backlog 冲到 3 万,但消费速率也在同步上升,其实是检索服务刚启动的热身阶段,过两分钟就消化完了,值班同事白紧张一趟。
排查积压问题的思路要形成一个标准动作:先看消费者线程是否满(大多数情况是推理任务卡住),再看下游依赖是否健康(大模型 API 是否限流),最后看 Broker 和 Bookie 的 IO 指标(很少出现,但也不能排除)。按这个顺序排查,积压问题一般 10 分钟内能定位。
4.3 发布升级与优雅停机实践
消息队列集群的升级比普通微服务更讲究顺序和节奏。我们的固定操作顺序是:先升级 ZooKeeper,再升级 BookKeeper,最后升级 Broker,这符合底层依赖从下往上的原则。
Broker 升级前要执行优雅停机流程:先把 Broker 标记为不可用,让它停止接收新生产者和消费者的连接,等存量连接处理完再下线。Pulsar 提供了 pulsar-admin brokers update-dynamic-config 等命令辅助操作,但实测下来,最稳妥的方式还是维护一个低峰期维护窗口,配合负载均衡策略逐台操作。
这里分享一个我们踩过的坑:升级 Broker 时如果客户端没有配置 allowLookupRetries 和合理的重试间隔,升级期间会出现大量连接失败。后来我们把客户端的 operationTimeout 调成 30 秒,并增加了服务发现重试,升级过程就从"有损"变成了"无损"。
5. 性能调优与实测数据
5.1 关键参数调优对照表
性能调优是个反复实验的过程,我们把调过的核心参数整理成了一张对照表,方便后来人直接抄作业:
| 层级 | 参数 | 默认值 | 调优值 | 说明 |
|---|---|---|---|---|
| Producer | batchingMaxPublishDelay | 1ms | 5ms | 减少 RPC 次数 |
| Producer | maxPendingMessages | 1000 | 10000 | 抗突发流量 |
| Producer | compressionType | None | LZ4 | 降低带宽 |
| Consumer | ackTimeout | 0(不生效) | 60s | 防止慢消费导致无限等待 |
| Consumer | negativeAckRedeliveryDelay | 1min | 5s | 快速重试失败消息 |
| Broker | managedLedgerMaxEntriesPerLedger | 50000 | 100000 | 减少 ledger 切换频率 |
| Broker | bookkeeperWriteQuorum | 2 | 2 | 保持默认,避免过深 |
| Bookie | journalWriteDataSyncWrites | false | true | 提升数据安全 |
有人可能会问 managedLedgerMaxEntriesPerLedger 为什么要调大。这个参数决定了 ledger 滚动切换的阈值,调大后可以减少 ledger 元数据操作的频率,在高吞吐场景下能降低 Broker 与 ZooKeeper 的交互压力,对延迟稳定性有帮助。
5.2 压测与线上表现
压测环境是三台 Broker、六台 Bookie、两台 Worker,消息体平均 7KB 左右。压测结果显示:
- 稳定运行吞吐:写入约 3000 条/秒,消费约 2700 条/秒
- 端到端延迟(从生产到消费完成 ack,不含 LLM 推理):P99 约 180ms
- 生产单条消息 P99:约 16ms(开启批量后)
- 消费单条消息 P99:约 25ms(含网络和缓存)
上线后经历了两次明显的流量高峰。一次是大模型新版本发布,所有员工集中体验新功能,请求峰值冲到了 5200 条/秒,比压测值还高。因为消息有背压和积压缓冲,Worker 虽然短暂落后了 2 分钟,但最终追平,用户没有感知到任何失败。另一次是知识库系统故障,所有消息积压了约 30 万条,我们在 15 分钟内把 Worker 从 6 台扩到 18 台,半小时内 backlog 清零,没有触发告警升级。
6. 踩坑复盘:这些坑不写出来,后面的人还会踩
6.1 消费者线程池被长耗时推理占满
上线初期的消费者代码写得很天真:直接在 Listener 回调里做 LLM 推理。运行一周后,某个下午 LLM API 突然变慢,单次推理从 2 秒变成 15 秒,50 个 Listener 线程全部被占满。更糟的是 ackTimeout 设成了 30 秒,推理超过 30 秒后消息被重新投递,同一条消息被多个线程重复处理,整个消费者进入了"全忙乱"状态。
解决方法是把消费和推理彻底解耦:Listener 只负责收消息、put 进有界任务队列、返回;推理由独立线程池执行,完成后主动 ack。任务队列满了之后 Listener 会阻塞,天然形成背压。改造后即使 LLM 再慢,也不会导致消息重复消费和线程池爆掉。
6.2 Key_Shared 模式下消息的"真假乱序"
有段时间我们发现同一个用户的会话偶尔会串上下文,排查了很久最后定位到消息乱序。Key_Shared 在默认的 autoSplit 模式下,消费者数量变化时 key 的映射关系会重新分配,为了保证"严格顺序",Broker 需要等待被迁移 key 的未确认消息处理完成,这个等待过程可能阻塞后续消息。
我们的问题是扩容时出现了短暂的跨消费者消费,个别 key 的消息被新消费者接走,而旧消费者还在处理之前的消息,顺序就乱了。解决方法有两个:一是把订阅的 KeySharedPolicy 设为 sticky 模式,约束 key 的粘性;二是给消息带上业务序号,消费端做一次乱序校验,发现序号跳变直接重投。双保险之后这个问题再没出现过。
6.3 重试风暴:一次大模型故障引发的连锁反应
最惨烈的坑来自重试策略设计失误。某个周一上午,大模型供应商 API 全面限流,所有 Worker 在几秒内同时收到失败响应,同时执行 negativeAcknowledge(),消息 5 秒后重新投递,失败后再重投,形成循环重试风暴。仅仅 10 分钟,2 万条原始消息被放大成了 30 万次投递请求,Pulsar Broker 的 CPU 被打到 90%。
这次的教训有三条:第一,下游故障时必须全局熔断,而不是每条消息各自重试。我们在网关层和 Worker 层都加了熔断器,熔断开启后直接拒绝新消息进入 Pulsar,只保留已积压消息的重试。第二,重试间隔要用指数退避,第一次 5 秒、第二次 30 秒、第三次 5 分钟,而不是固定 5 秒。第三,全部消息挂掉时要快速将订阅移到 DLQ,允许业务上"人工介入",而不是让机器空转。
6.4 还有一个小细节:消息大小别忽略
最后补一个小坑。Pulsar 默认的单条消息大小上限是 5MB,但我们的问答服务在集成知识库时会把相关文档片段一起塞进消息体,最长的一条约 8MB。结果消息一直被拒,排查了很久才发现是大小超限。解决办法是把文档片段改成引用 URL,消息体里只放文档 ID,消费端按需读取。这不仅解决了上限问题,还让消息体积从平均 30KB 降到了 8KB,整体吞吐提升了一截。
如果你也要在 Pulsar 里传大对象,建议先考虑"引用代替内容"的设计,实在不能避免再调 maxMessageSize,因为调大上限会直接影响 Broker 的内存占用和 Bookie 的写入效率,代价不小。
我个人这一年多用下来的体会是,Pulsar 的架构学习曲线比 Kafka 陡,但它解决的是更高层的问题:当你需要同时兼顾弹性、顺序、持久化、多租户这些东西时,Pulsar 的模型会让你少掉很多头发。Ask AI 的 7*24 目标能达成,一半靠大模型本身的能力,另一半靠消息链路把各种不确定性挡在外面。如果你也在做类似的 AI 应用,建议先花一周把 Pulsar 的 BookKeeper 存储模型吃透,再动手设计生产者消费者,这个顺序能帮你少走很多弯路。
