
在实时对话场景中,按会话顺序处理消息是保障系统准确性的关键。当多个会话共享分区时,应用层需通过路由机制兼顾会话内顺序与会话间并行,以一致性哈希与按会话划分的协程实现会话亲和性,确保同会话消息有序处理、不同会话高效并发。本文基于已处理超4000万条消息的生产实践,总结Kafka有序处理的核心设计:采用两层工作协程架构,通过SessionWorkers分发会话路由、按会话goroutine处理消息,以原地重试保障可恢复性,按连续水位线提交实现崩溃恢复,并通过偏移量去重与动态空处理机制保障顺序不丢失;同时,从运维加固层面强调再均衡、背压、卡住检测等保障机制,全面保障生产环境的顺序稳定性与系统韧性。
--91likeyou---
要在生产环境中保证顺序,还需要做好运维层面的加固。再均衡时排空任务、背压、待处理记录缓冲、卡住的偏移量检测、幂等性、优雅关停和死信队列处理,都是系统在真实故障中维持顺序所必需的。
引言
在对话式 AI 中,消息顺序有着实际的语义影响。用户先说“帮我订一张去伦敦的机票”,接着又说“等等,改成巴黎”。系统必须按顺序处理这两条消息。如果第二条先于第一条被处理,AI 就会基于错误的上下文作出回应。用户收到的可能是伦敦机票预订确认,而不是改订巴黎的结果。随着系统规模扩大,数千个并发会话同时流经多阶段管道,即使极少发生的顺序错误,也会损害产品体验。
我负责为一个面向企业客户的实时对话式 AI 平台设计并实现 Go 语言核心 Kafka 消息层。在与团队密切协作的过程中,我们构建了一条包含四个处理阶段的管道:接入、自然语言理解(NLU)、大语言模型推理和消息送达。这里的顺序要求很严格:无论单个阶段耗时多久,同一会话中的消息都必须依次经过这四个阶段处理。
这套系统已在生产环境中处理了超过 4000 万条消息,覆盖数千个并发聊天会话,没有观察到顺序错误。这里的顺序保证并非来自把吞吐量控制在某个安全范围内,而是由一致性哈希和每个会话一个 goroutine 的结构设计提供。此后,我们还在测试中将发送端吞吐量提升到了每秒 10 万条以上,且没有发送错误。
架构概览
核心难点在于,Kafka 只能保证分区内的顺序,而单个分区承载着来自多个会话、交错到达的流量。要在并行处理多个会话的同时保留会话内的顺序,应用层需要自行完成路由。
我们的第一个实现采用扁平、固定大小的工作协程池,并将会话哈希到不同工作协程上。在持续负载下,一个正在重试的会话可能会阻塞同一工作协程上的其他会话;而协调重试又会导致内存队列不断增长。后来,我们将它替换为两层工作协程架构。
当前设计采用两层架构,同时解决了这两个问题。
第一层:SessionWorkers(分发层)
一组轻量级分发工作协程负责从 Kafka 接收记录,并根据会话 ID 进行路由。一致性哈希可以确保同一会话的消息总是到达同一个工作协程;不同会话则可以分散到多个工作协程上并行处理。
第二层:按会话划分的 goroutine(处理层)
每个活跃会话都由自己的 goroutine 处理,按消息到达的顺序逐条处理。这些 goroutine 按需创建,并在一段时间不活跃后移除,使资源消耗与活跃会话数量相匹配。
每个会话 goroutine 一次只处理一条消息。不同会话之间的并行度在设计上不设上限;同一会话内部的串行处理则由架构本身保证。
一个自然会被问到的问题是:为什么不完全依赖 Kafka 分区来保证顺序?“一个会话对应一个分区”并不可行,因为并发会话数达到数千时,分区数量会超过运维承受能力,并拖累 broker 性能。如果采用每个分区单线程处理,而分区中又交错承载多个会话的流量,效率也会很低,因为会话级别的顺序仍需要应用层协调。一致性哈希既能严格保证会话内顺序,也能让不同会话保持较高的并行度,同时避免分区数量无限增长。
不破坏顺序的重试
最初的重试实现使用了单独的重试 goroutine,并把后续消息暂存在内存中。这让状态机变得复杂,也带来了内存无限增长的风险。
现在的模型更简单:重试在会话 goroutine 内原地进行。对于每条消息,会话 goroutine 都会尝试处理。如果处理成功,就将消息标记为完成,并立即处理该会话的下一条消息。如果遇到可恢复的故障,例如下游服务超时,goroutine 就会等待一段时间,再按照带随机抖动的指数退避策略重试同一条消息。
由于重试期间 goroutine 会被阻塞,该会话的后续消息会留在队列中,无法越过失败的消息。如果故障不可恢复,例如消息数据无效,消息就会直接送入死信队列(DLQ),不再重试。失败消息成功处理或交由 DLQ 处理后,系统才会继续处理该会话的下一条消息。
由于会话 goroutine 在退避等待期间处于休眠状态,消息 3 处理完之前,消息 4 根本不可能开始执行。没有待排空的队列,没有重试通道,也没有需要重置的状态。goroutine 会被阻塞,新消息则会在会话通道中排在它后面,这正是我们想要的效果。
这种做法也省去了原始设计中必需的外部重试 goroutine。重试由会话 goroutine 自行处理。
错误分为两类:
第一类:可恢复错误——采用带随机抖动的指数退避策略重试。
第二类:有毒消息/不可恢复错误——跳过重试,直接送入 DLQ。
偏移量提交与崩溃恢复
多个会话 goroutine 可能以不同顺序完成处理,因此直接提交最新完成的偏移量很危险。如果偏移量 104 先于 102 完成,提交 104 后一旦发生崩溃,102 就会被整个跳过。
我们通过一个按分区管理偏移量的跟踪组件来维护连续水位线。对于每个分区,该组件都会跟踪 InFlight(正在处理的偏移量)和 Completed(已完成的偏移量)。提交循环会按固定间隔推进水位线。
关键在于,提交位置只能推进到最高的连续已完成偏移量。尚未完成消息造成的空缺会阻止水位线不安全地前进。
发生崩溃后,系统会从偏移量 101 开始重放。偏移量 100 已经处理过,而偏移量 102 和 104 可能会被重新处理。为了让重放尽可能安全,管道会携带稳定的事件 ID 用于去重。这可以减少重复处理,但外部副作用仍取决于下游服务提供的幂等性保证,因此重放并不等同于恰好一次执行。
这种设计有意用少量额外重放换取确定性的恢复行为。在分布式系统中,安全地重放数据,胜过冒着静默丢失数据的风险。
在少数情况下,中断的交接可能会让提交水位线留下一个永久空缺。为了避免分区因此无限期停滞,协调器会跟踪尚未解决的空缺;超过配置的处理超时时间后,就会越过那些已无法完成的空缺。这是最后手段,是一种用可用性换取完整性的取舍:系统可能会放弃处理一条永久卡住的消息,以恢复分区进度。这类情况应视为运维故障,而非正常成功的消息交付。
运维层面的加固
四个核心机制:一致性哈希、按会话划分的 goroutine、连续水位线提交和原地重试,解决了顺序问题。要让系统在生产环境中具备韧性,还需要增加几个层次的保护。
再均衡安全
发生再均衡时,消费者会先将分区标记为正在撤销,避免继续向该分区路由新记录。随后,它会等待正在处理的会话任务完成,每 50 毫秒检查一次,并遵守 broker 的再均衡超时限制。处理结束后,消费者会提交最终的连续水位线,清除分区专属状态,然后再安全地将分区所有权转移给另一个消费者。
背压
工作协程达到负载上限时,系统不会丢弃消息,也不会崩溃,而是暂停拉取。消息路由采用非阻塞的通道发送。如果工作协程的分发通道已满,系统就会触发背压:暂停对应分区,并由一个监控 goroutine 每秒检查一次容量,等负载降到配置的恢复阈值以下后再恢复拉取。
仅仅暂停还不够。暂停拉取可以阻止 broker 发送新的记录,但当前批次中可能已经有记录被拉取出来。这些记录会按分区缓冲,并继续标记为正在处理,避免丢失,也避免水位线过早推进。
后台监控程序会在容量恢复后排空缓冲区。只有缓冲区清空,且触发背压的工作协程有足够容量时,系统才会恢复拉取,避免更新的记录越过缓冲区中的旧消息。这有助于隔离不同会话,但无法消除分区级别的相互影响:暂停一个分区,可能会暂时延迟分配给该分区的其他会话。
卡住的偏移量检测
如果会话 goroutine 因下游调用始终不返回或发生死锁而无限期阻塞,该分区的提交水位线就会停住。该分区上此后的所有偏移量都会变得无法提交。
第一个检测器把持续非空的 InFlight 偏移量集合视为分区卡住的信号,在持续有流量时因此产生了误报。当前的做法是逐个跟踪偏移量,只有当某个偏移量的处理时间超过配置的超时时间时才将其标记出来。随后,可以强制将卡住的偏移量标记为完成并记录到 DLQ,让分区继续推进。
链路追踪与自动扩缩容信号
在生产环境中,要做好可观测性,还需要两项额外保障:让链路追踪信息能够跨越 Kafka 异步边界关联起来;以及在分区暂停期间,独立报告消费滞后量。这样既能保证追踪信息准确,也能确保自动扩缩容系统在背压期间仍能收到有效的滞后信号。
按会话顺序生产消息
顺序保证不能只覆盖消费者。异步发送消息时,生产者也需要维持会话亲和性。
异步生产使用与消费者相同的一致性哈希策略,确保同一会话的消息路由到同一个生产者工作协程,并按 FIFO 顺序发送。发送失败的消息会升级处理并进入 broker 的 DLQ;关闭服务时,则会先排空并刷新待发送的写入,再关闭 Kafka 客户端。
优雅关停与部署安全
关停期间,消费者会停止拉取新记录,并为活跃的会话工作协程留出时间完成任务。排空完成或达到配置的超时时间后,消费者会在关闭 Kafka 客户端之前,提交最终的连续水位线并写入待处理的 DLQ 消息。任何尚未提交的偏移量都会在重启后重新处理;由于处理过程具备幂等性,这样做是安全的。
基准测试与验证
我们分两个阶段验证这套架构:先通过合成基准测试,在可控故障下检查顺序、重试、吞吐量和延迟;再针对已部署的管道进行生产规模的负载测试。
测试环境
以下基准测试运行在 AWS EC2 c5.2xlarge 实例上(8 个 vCPU、16GB 内存),使用一个包含 3 个 broker 的 Kafka 集群;每个主题有 10 个分区,副本因子为 3。消费者组包含 3 个消费者实例,每个实例处理 4 个分区。
200 毫秒的目标针对消息在接入、路由、交接和送达各环节产生的开销;可变的 LLM 推理时间不计入这一预算。
合成基准测试结果
第一轮验证采用模拟的下游处理,而不是真实服务;我们还人为注入了瞬时错误,占消息总数的 10%,以验证部分故障下的重试不会破坏顺序。因此,表 1 中的延迟数据反映的是基准测试工具和消息处理路径的表现,不代表真实 LLM 推理延迟。
表 1. 合成基准测试结果
在 10 个并发会话、注入 10% 故障的条件下,共测试了 50,000 条消息,没有出现顺序错误。
生产规模的负载测试
合成基准测试验证了正确性,但没有验证生产规模下的表现。因此,我们通过 WebSocket 对已部署的管道进行了测试,并发会话数为 1,000,目标发送速率接近每秒 100,000 条消息。
目标发送速率为每秒 50,000 条消息时,在略多于 4 分钟的测试期间,共有 1,000 个并发会话:
表 2. 生产规模负载测试结果
目标发送速率为每秒 50,000 条消息时,已部署的管道稳定处理了每秒 48,805 条消息,发送错误为零。尾延迟超过了 200 毫秒的消息开销预算,原因是 WebSocket 链路的运行速率超过了 API Gateway 的消息速率配额,而不是会话顺序机制本身。虽然测试提前结束,但结果显示发送端仍有超过每秒 50,000 条消息的余量。
高速负载测试测量了吞吐量、延迟和错误率,但没有独立验证每个会话内的消息顺序。明确的顺序验证是在合成基准测试中完成的:在每秒 14,027 条消息、注入 10% 故障的情况下,没有记录到顺序错误。仍需在完整负载测试规模下重新运行相同的序列单调性检查。
生产环境表现
合成基准测试验证了设计,生产环境则验证了它在实际运行中的表现。上线以来,我们在真实流量和实际运行条件下处理了超过 4000 万条消息,没有观察到顺序错误。这些条件包括可变的 LLM 推理时间、网络抖动和自然出现的负载峰值。这一生产环境数据来自日常运行观察和监控,并非对每条消息进行穷尽式的序列检查。在这些消息中,只有 460 条曾被送入 DLQ,其中合并计算了有毒消息、不可恢复错误和被强制完成的卡住偏移量,比例约为 0.002%。
如何验证顺序
我们会在生产者一侧为每个会话的消息分配单调递增的序列号,以检测顺序错误。消费者侧的测试工具会记录每个会话中消息的处理顺序:
会话亲和性则单独验证:每条消息都会记录处理它的工作协程,测试会确认同一会话的所有消息都由且仅由一个工作协程处理。
在生产环境中,消费者滞后量、处理时长、重试次数、DLQ 数量、待处理缓冲区深度和背压事件,可以为水位线停滞或会话与工作协程之间意外的重新分配提供运维信号。这些都是有用的指标,但并不能直接检查消息在序列层面上的顺序。
为什么不用 Redis Streams?
在确定采用 Kafka 和自定义消费者层之前,我们也考虑过 Redis Streams。Redis Streams 提供消费者组、消息确认机制和较为可靠的顺序保证。对于更简单的工作负载,它可能是合适的选择。
对于我们的工作负载,Redis Streams 仍然需要应用层协调会话亲和性,并增加额外的恢复状态记录。Kafka 的分区和偏移量模型,结合应用层的会话感知路由,为严格的会话内顺序、消息重放以及不同会话间的高并行度提供了更坚实的基础。
结论
严格保证会话内顺序,光靠 Kafka 的分区级保证还不够。在这套设计中,一致性哈希维持会话亲和性,按会话划分的 goroutine 负责串行处理消息和重试,连续水位线让并发处理具备可恢复性,而再均衡时排空任务则能保护分区交接过程。
围绕背压、卡住的任务和优雅关停进行的生产环境加固,让这些架构保证经受住了真实运行条件的考验。系统在生产环境中已处理超过 4000 万条消息,没有观察到顺序错误。
凡是消息顺序具有实际语义影响的场景,都可以采用这些模式,包括对话系统、工作流引擎和事务处理。
:
🔥 热词:#Kafka · #AI · #处理了 · #4000 · #万条消息后 · #我们总结了 · #有序处理的几个关键设计 · #本文要点