用Go构建会话有序的Kafka管道
DataHot 速览
文章介绍如何在 Apache Kafka 分区之上实现会话级消息顺序,支持数千个独立通道的严格有序。方案包含应用层路由、一致哈希、按会话 worker、原地重试和连续水位提交。作者还讨论了再均衡排空、背压、卡住偏移检测、幂等、优雅关闭和死信队列等生产加固。该实践来自面向企业客户的实时对话 AI 平台,并经过大量性能测试。
为什么值得关注:文章虽非分析型数据产品,但属于数据基础设施中的流处理技术实践,对构建可靠实时数据管道、保证消息顺序和故障恢复的工程师有直接参考价值。
本文目录 22 节
译文
AI 逐段翻译要点
- 仅靠 Kafka 分区排序不足以应对会话有序的工作负载。当许多会话共享分区时,应用层仍然需要路由逻辑,以在允许跨会话并行的同时保持每个会话内的顺序。
- 一致性哈希和每会话工作线程提供会话亲和性。来自同一会话的消息始终落到同一工作线程并按顺序处理,而无关的会话继续并行处理。
- 就地重试可保持顺序。每会话 goroutine 可以在处理下一条消息之前,以退避方式重试失败的消息,从而避免消息超车,并消除对单独重试状态机的需求。
- 连续水位提交使并发处理可恢复。偏移量仅提交到最高的连续已完成偏移量,防止崩溃后某条较晚完成的消息导致较早未完成的消息被跳过。
- 生产级顺序保证需要运维层面的加固。再均衡排空、背压、待处理记录缓冲、卡住偏移量检测、幂等性、优雅关闭以及死信队列处理,都是在真实故障条件下保持顺序所必需的。
引言
在对话式 AI 中,消息顺序具有语义权重。当用户发送“帮我订一张去伦敦的机票”,紧接着又说“其实,改成巴黎”时,系统必须按顺序处理这些消息。如果第二条消息超过了第一条,AI 就会基于错误的上下文作出响应。用户会收到伦敦的订票确认,而不是改去巴黎。在大规模场景下,成千上万个并发会话流经多阶段流水线时,即使罕见的顺序违规也会降低产品质量。
我为一个服务企业客户的实时对话式 AI 平台主导了用 Go 编写的核心 Kafka 消息层的设计与实现。我与团队紧密合作,构建了一条流水线,其中每条消息都流经四个处理阶段:接入、自然语言理解(NLU)、大语言模型(LLM)推理和投递。顺序要求很严格:同一会话内的消息在全部四个阶段中都必须按顺序处理,无论单个阶段耗时多久。
该系统已在生产环境中处理超过 4000 万条消息,覆盖数千个并发聊天会话,未观察到任何顺序违规。这里的顺序保证并非来自将吞吐量保持在某个安全阈值以下。它是结构性的,由一致性哈希和每会话一个 goroutine 构建而成。此后我们在测试中已将发送侧吞吐量推高至超过每秒 100,000 条消息,且发送错误为零。
架构概览
核心挑战在于,Kafka 保证分区内的顺序,但单个分区承载着来自许多会话的交错流量。为了在保持会话内顺序的同时并行处理会话,应用层需要自己的路由。
我们的第一个实现使用了扁平的固定工作线程池,并将会话哈希到工作线程。在持续负载下,一个正在重试的会话可能阻塞同一工作线程上无关的会话,而重试协调引入不断增长的内存队列。我们将其替换为两级工作线程层级。
图 1. 用于会话有序处理的两级工作线程层级(图片来源:作者创建)
当前设计通过两级层级同时解决了这两个问题。
第 1 级:SessionWorkers(分发层)
一组轻量级分发工作线程从 Kafka 接收记录,并根据会话 ID 进行路由。一致性哈希确保来自同一会话的消息始终到达同一工作线程,而不同会话可以分布到多个工作线程上并行处理。
第 2 级:每会话 goroutine(处理层)
每个活跃会话由自己的 goroutine 处理,该 goroutine 按到达顺序一次处理一条消息。这些 goroutine 按需创建,并在一段时间不活动后被移除,从而使资源使用量与活跃会话数成正比。
每个会话 goroutine 一次处理一条消息。跨会话并行在设计上不受限制;会话内串行由构造方式保证。
一个自然的问题是,为什么不完全依赖 Kafka 分区来实现顺序。每会话一个分区并不可行,因为当有数千个并发会话时,分区数量将超过运维限制并降低 broker 性能。当分区承载交错的会话流量时,每分区单线程模型效率低下,因为会话级排序仍然需要应用层协调。一致性哈希提供了严格的会话内顺序和高跨会话并行度,而不会导致分区数量无限增长。
不破坏顺序的重试
第一个重试实现使用了单独的重试 goroutine,并在内存中排队后续消息,这使状态机变得复杂,并引入了无界内存风险。
当前模型更简单。重试在会话 goroutine 内部就地发生。对于每条消息,会话 goroutine 都会尝试处理它。如果处理成功,该消息会被标记为完成,并立即处理该会话中的下一条消息。如果故障是暂时性的(例如下游服务超时),goroutine 会使用带抖动的指数退避等待,然后重试同一条消息。
由于 goroutine 在重试期间被阻塞,该会话的后续消息会继续排队,无法超过失败的消息。如果故障不可恢复,例如消息数据无效,则该消息会直接路由到死信队列(DLQ),不进行重试。一旦失败的消息成功或被 DLQ 处理,处理就会从该会话中的下一条消息继续。
因为会话 goroutine 在退避期间休眠,消息 4 实际上无法在消息 3 完成之前执行。没有队列需要排空,没有重试通道,没有状态需要重置。goroutine 会阻塞,新消息堆积在它后面的会话通道中,这正是它们应该待的地方。
图 2. 会话 goroutine 内的原地重试(图片来源:作者创建)
这也消除了原始设计所需的外部重试 goroutine。会话 goroutine 负责处理重试。
错误分类遵循两个层级:
- 第 1 层,瞬态错误:使用指数退避和抖动进行重试。
- 第 2 层,毒丸/不可恢复错误:跳过重试并直接路由到 DLQ。
偏移量提交与崩溃恢复
由于许多会话 goroutine 会乱序完成,天真地提交最新完成的偏移量是危险的。如果偏移量 104 在 102 之前完成,提交 104 意味着崩溃时会完全跳过 102。
我们通过由每个分区的偏移量跟踪组件管理的连续水位线来解决这个问题。对于每个分区,它跟踪 InFlight(当前正在处理的偏移量)和 Completed(已完成的偏移量)。在定期的提交间隔中,提交循环会推进水位线。
图 3. 连续水位线提交(图片来源:作者创建)
关键洞察是只提交到最高的连续已完成偏移量。在途消息中的间隙充当阻碍,防止任何不安全的推进。
崩溃时,重放从偏移量 101 开始。偏移量 100 已被处理,而偏移量 102 和 104 可能会被重新处理。该管道旨在尽可能使重放安全,通过携带稳定的事件 ID 进行去重。这减少了重复处理,但外部副作用仍然取决于下游服务的幂等性保证,因此重放并不等同于恰好一次执行。
该设计有意以最小的重放工作换取确定性恢复。在分布式系统中,安全地重放比冒静默数据丢失的风险更可取。
在极少数情况下,中断的交接可能会在提交水位线中留下永久性间隙。为了防止分区无限期停滞,协调器会跟踪未解决的间隙,并在配置的处理超时之后,越过它再也无法完成的间隙继续推进。这是最后手段的可用性权衡:系统可能会为了恢复分区进度而牺牲某个永久阻塞消息的完整性,并且该事件应被视为操作故障,而不是正常的成功交付。
运维加固
四个核心原语(一致性哈希、每会话 goroutine、连续水位线提交和原地重试)解决了排序问题。生产环境的韧性需要几个额外的层次。
再平衡安全性
在再平衡期间,消费者首先将该分区标记为正在撤销,以便不再将新记录路由到它。然后等待在途会话工作完成,每 50 毫秒检查一次,并遵守 broker 的再平衡超时。一旦处理完成,最终的连续水位线被提交,分区特定状态被清除,所有权可以安全地转移到另一个消费者。
图 4. Kafka 消费者再平衡期间的安全分区交接。(图片来源:作者创建)
背压
当工作线程饱和时,系统不会丢弃消息或崩溃。它会暂停拉取。消息路由使用非阻塞通道发送。如果工作线程的分发通道已满,则会触发背压:分区被暂停,一个监控 goroutine 每秒轮询容量,当负载降至配置的恢复阈值以下时恢复
仅暂停是不够的。暂停消息拉取会阻止新记录从 broker 到达,但记录可能已经从当前批次中拉取出来了。这些记录按分区缓冲,并保持标记为在途,从而防止它们丢失或过早推进水位线。
后台监控器会在容量可用时排空缓冲区。只有在缓冲区为空且触发的工作线程具有足够容量后,才会恢复拉取,从而防止较新的记录超过较旧的缓冲消息。这改善了会话隔离,但并未消除分区级耦合:暂停一个分区可能会暂时延迟分配给它的无关会话。
卡住的偏移量检测
一个无限期阻塞的会话 goroutine,无论是由于不终止的下游调用还是死锁,都会冻结其分区的提交水位线。该分区上的每个后续偏移量都会变得无法提交。
第一个检测器将持续非空的在途偏移量集合视为卡住的分区,这在持续流量下会产生误报。当前方法单独跟踪每个偏移量,只有当其处理时间超过配置的超时时才将其标记为卡住。然后可以强制完成卡住的偏移量并记录到 DLQ 中,以便分区可以继续推进。
追踪与自动扩缩容信号
生产可观测性需要两个额外的保障措施:跨异步 Kafka 边界的追踪链接,以及分区暂停期间的独立滞后报告。这保持了追踪的准确性,并确保自动扩缩容在背压期间仍能接收到有用的滞后信号。
会话有序生产
顺序保证并不止于消费者。生产者在异步发送时也需要会话亲和性。
异步生产使用与消费者相同的一致性哈希策略,确保来自同一会话的消息被路由到同一个生产者工作线程,并按 FIFO 顺序发送。发送失败会升级到 broker DLQ,而关闭时会在 Kafka 客户端关闭前排空并刷新待处理的写入。
优雅关闭与部署安全性
在关闭期间,消费者停止拉取新记录,并给活跃的会话工作线程留出完成时间。一旦排空完成或达到配置的超时时间,它会在 Kafka 客户端关闭之前提交最终连续的 watermark 和待处理的 DLQ 写入。任何未提交的 offset 都会在重启后重放,这是安全的,因为处理是幂等的。
基准测试与验证
我们分两个阶段验证了该架构:首先是一个合成基准测试,用于在受控故障下测试顺序性、重试行为、吞吐量和延迟,随后是针对已部署流水线的生产规模负载测试。
测试环境
以下基准测试运行在 AWS EC2 c5.2xlarge 实例(8 vCPU,16GB RAM)上,使用一个 3 broker 的 Kafka 集群,每个主题 10 个分区,复制因子为 3。组内的 3 个消费者实例中,每个消费者实例处理 4 个分区。
200ms 的目标适用于 ingest、routing、hand-off 和 delivery 各环节的消息传递开销;可变的 LLM 推理时间不在该预算之内。
合成基准测试结果
第一轮验证使用了模拟的下游处理而非真实服务,其中 10% 的消息被注入瞬时错误,以验证重试在部分失败下不会破坏顺序性。因此,表 1 中的延迟数据反映的是基准测试框架和消息传递路径,而非真实的 LLM 推理延迟。
| 指标 | 结果 | 目标 |
| 原始吞吐量 | 14,027 msg/s | >10k msg/s |
| E2E 延迟 p50 | 11.7ms | <200ms |
| E2E 延迟 p95 | 115ms | <200ms |
| E2E 延迟 p99 | 185ms | <200ms |
| 顺序性违规 | 0 | 0 |
| 会话亲和性 | 100% | 100% |
表 1. 合成基准测试结果
在 50,000 条消息、10 个并发会话、注入 10% 失败的情况下,零违规。
生产规模负载测试
合成基准测试验证了正确性,但并未验证生产规模下的行为。因此,我们针对已部署的流水线运行了一个 WebSocket 驱动的测试,包含 1,000 个并发会话,将发送速率推向 100,000 msg/s。
在 50,000 msg/s 的目标下,1,000 个并发会话持续超过 4 分钟:
| 指标 | 结果 |
| 目标发送速率 | 50,000 msg/s |
| 实际发送速率 | 48,805 msg/s |
| 已发送消息数 | 11,959,510 |
| 发送错误 | 0(0.00%) |
| E2E 延迟 p50 | 446.8ms |
| E2E 延迟 p95 | 2,633.9ms |
| E2E 延迟 p99 | 2,843.1ms |
表 2. 生产规模负载测试结果
在 50,000 msg/s 的目标下,已部署的流水线以零发送错误维持了 48,805 msg/s。尾部延迟超出了 200ms 的消息传递预算,这是因为 WebSocket 路径的运行超出了 API Gateway 的消息速率配额,而非会话顺序机制所致。尽管该次运行提前结束,它仍展示了超出 50,000 msg/s 测试的额外发送侧余量。
高速率负载测试测量了吞吐量、延迟和错误率,但并未独立验证每会话的顺序性。显式的顺序性验证在合成基准测试中执行,在 14,027 msg/s 且注入 10% 失败的情况下记录到零违规。在完整负载测试规模下重新运行相同的序列单调性检查仍是一项待完成的验证步骤。
生产环境性能
合成基准测试验证了该设计。生产环境在运营层面证实了它。自生产环境上线以来,我们已处理超过 4000 万条消息,在实时流量和真实世界条件下(包括可变的 LLM 推理时间、网络抖动和自然负载峰值)未观察到任何顺序性违规。这一生产环境的结论基于运营观察和监控,而非逐条消息的详尽序列检查。这些消息中只有 460 条曾被路由到 DLQ(毒丸消息、不可恢复错误和卡住 offset 的强制完成合计),比率约为 0.002%。
我们如何验证顺序性
顺序性违规通过在生产者为每条消息分配每会话单调递增的序列号来检测。消费者侧的测试框架记录每条消息在每个会话中被处理的顺序:
会话亲和性单独验证:每条消息都记录哪个工作线程处理了它,测试断言给定会话的所有消息都由恰好一个工作线程处理。
.在生产环境中,消费者 lag、处理时长、重试、DLQ 计数、待处理缓冲区深度和背压事件为 watermark 停滞或意外的会话到工作线程重新分配提供了运营信号。这些是有用的指标,但它们并非直接的序列级顺序性检查。
为什么不用 Redis Streams?
在确定采用带自定义消费者层的 Kafka 之前,我们考虑过 Redis Streams。Redis Streams 提供消费者组、消息确认和合理的顺序性保证。对于更简单的工作负载,它可能是一个不错的选择。
对于我们的工作负载,Redis Streams 仍需要应用层协调来实现会话亲和性,并需要额外的簿记来实现恢复。Kafka 的分区和 offset 模型,结合应用层的会话感知路由,为严格的每会话顺序性、重放以及跨会话的高并行度提供了更强的基础。
结论
严格的会话顺序性需要的不仅仅是 Kafka 的分区级保证。在此设计中,一致性哈希提供会话亲和性,每会话 goroutine 序列化处理和重试,连续 watermark 使并发处理可恢复,rebalance 排空保护分区交接。
围绕背压、卡住的工作和优雅关闭的生产环境加固,将这些架构保证转化为能够在真实运营条件下存活的东西。该系统已处理超过 4000 万条生产消息,未观察到任何顺序性违规。
这些模式适用于任何消息顺序具有语义含义的场景,包括对话系统、工作流引擎和事务处理。
这篇内容对你有用吗?
反馈只用于改善内容筛选,不等同于收藏