Kafka 顺序消息乱序:分区分配、重试与消费并发

“相同 key、消费者只有一个线程,所以 Kafka 一定不会乱序”是排查 Kafka 顺序问题时最常见的误判。这个组合只覆盖了单分区内按 Offset 读取的一小段链路,并没有覆盖扩分区后的路由变化、生产者请求重试、失败消息转入重试 Topic,以及业务线程并发导致的完成顺序倒置。

先把所有消费者的并发数一股脑改为 1,通常只能掩盖问题,还会牺牲吞吐。更有效的排查方式是按以下四步定位:

  1. 比较同一个业务键的三条事件是否进入相同 topic 和 partition。
  2. 如果分区相同,比较 Broker Offset 与业务序号的先后关系。
  3. 如果 Offset 有序,比较消费开始时间、完成时间和重试路径。
  4. 如果重启后出现相同 Partition、相同 Offset 的重复消息,重点查位点提交与重放,而不是 Broker 日志乱序。

这几类现象的证据、修复方式和代价完全不同,不能混在一起处理。

先看 topic-partition-offset,别急着把消费线程数改成 1

Kafka 原生只保证同一个 Partition 内按照 Offset 顺序保存和读取;Topic 跨分区没有全局顺序,消费者拉取有序也不代表异步业务处理会按相同顺序完成。

以 OrderCreated → OrderPaid → OrderShipped 为例,先按下面的字段模板记录每条事件。这些字段用于排查,不是某次测试的真实输出:

orderId=... businessSeq=... event=...
topic=... partition=... offset=...
producer=... sendCallbackAt=...
consumer=... thread=...
consumeStartAt=... consumeEndAt=...
retry=... retryTopic=...

其中 businessSeq 必须代表业务上的先后关系,不能直接拿事件名称排序;offset 只能在同一个 Topic 的同一个 Partition 内比较。消息进入重试 Topic 后,Offset 属于另一条日志,也不能与主 Topic 的 Offset 直接比较。

现象 关键证据 优先排查
同一业务键进入不同分区 三条事件的 topic 或 partition 不同 分区键、分区数、扩缩容、自定义分区器
Broker Offset 与业务顺序相反 同一分区内,业务序号较大的事件反而先获得较小 Offset 生产者到达顺序、并发发送、请求失败与重试
Offset 有序但完成顺序相反 Partition、Offset、业务序号均递增,但 consumeEndAt 倒置 业务线程池、同键并发、失败消息转入重试 Topic
重启后旧消息再次出现 相同 Partition、Offset 再次被消费 位点未提交、消费组重分配、重复投递

真正要定位的是“第一次出现差异的位置”,不是最后看到的状态。如果 OrderCreated 和 OrderPaid 已经进入不同分区,那么后面无论消费者是否单线程,都无法恢复它们在主链路上的先后关系;如果三者分区、Offset 都正确,但 OrderShipped 的完成时间最早,问题才应该转向消费线程和重试路径。

相同 key 仍进不同分区:查分区数和生产路由

相同 key 并不是天然等于相同分区。首先要确认 key 真的附在 Kafka 消息记录上,而不是只放在业务 JSON 里。没有分区键时,不同客户端版本和分区器可能采用不同的空 Key 路由策略;早期 Spring Cloud Stream 的讨论中,无 partitionKeyExpression 时曾表现为轮询分区,但那不能直接当成当前所有客户端的固定行为,需要结合项目版本核对。

业务键本身也不应包含随机数、当前时间或其他每次发送都会变化的值。否则调用方看起来一直在发送同一个 orderId,分区器实际看到的却可能是不同 key。

对于资料中采用的 keyed record 路由方式,分区选择可以抽象为:

扩容前:partition = murmur2(key) % oldPartitionCount
扩容后:partition = murmur2(key) % newPartitionCount

这只是判定公式,不是实际分区输出。oldPartitionCount 和 newPartitionCount 一旦不同,同一个 key 的映射结果就可能变化。扩分区不是在原有映射上追加一个新分区,而是在改变按 key 分配分区的基数;这正是“分区数变化后顺序突然乱了”的典型来源。

扩容期间还要检查正在运行的 Producer 是否已经刷新到新的分区元数据,以及不同生产者之间、不同应用发布阶段中,是否短暂使用了不同的分区数量。旧分区中的后续事件和新分区中的后续事件如果都携带同一个 orderId,消费端会看到它们并行推进。自定义分区器也可能覆盖默认路由,所以排查时不能只看业务代码有没有传 key,还要确认实际 Partitioner 的行为。

多生产者实例是另一条独立链路。即使所有生产者都使用相同 key,并最终进入同一个 Partition,Broker 仍按请求到达顺序追加记录,不会识别两个进程之间原本的业务先后关系。要表达跨生产者的顺序,需要在消息中携带 businessSeq,并决定由谁生成、校验和推进这个序号;仅仅约定“先调用 send() 的先发送”无法跨进程成立。

消息已经进入正确的 Partition 后,还要看消费组分配。同一消费组内,一个 Partition 在同一时刻只分配给一个消费者成员,这就是分区亲和性;增加消费者实例不会让一个 Partition 同时交给多个组内成员处理。消费者多于 Partition 时,多出的成员通常只能空闲,并不会自动带来更高吞吐。

消费者扩容触发 Rebalance 时,Partition 可能从一个成员转交给另一个成员。若旧成员尚未提交已处理消息,新成员会重新拉取这些消息,于是日志里看到的是重复和状态回退,而不是 Broker 把 Offset 写乱了。消费端的排查记录因此必须同时包含 consumer 和 thread,否则很容易把不同消费者或不同线程处理的消息误判为同一条顺序链路。

生产路由可以有几种取舍。稳定 key 加默认分区器最简单,但扩分区可能改变映射;显式指定 Partition 可以让业务自己控制路由和迁移,但需要承担热点分区与再平衡责任;按 key 串行发送能够约束单个生产者内的顺序,却不能解决多个生产者同时推进同一业务键的问题;分区扩容期间使用带版本的新 Topic 并做切换,可以隔离新旧分区,但消费者需要识别版本、迁移状态并处理重复消息。

如果把全部事件塞进一个 Partition,并只用一个消费者串行处理,顺序最直观,但这个 Partition 很容易成为吞吐瓶颈。一次慢消息还会阻塞后面所有业务实体,这通常称为队头阻塞。为了保留不同订单之间的并行度,更常见的目标是“同一 orderId 有序,不同 orderId 可以并行”,而不是追求 Topic 全局有序。

Offset 有序却业务倒序:查重试、线程池和位点提交

如果同一个 Partition 内的 Offset 与业务序号相反,说明乱序已经发生在消息被 Broker 追加到日志之前,消费者无法通过调整线程数修复。此时应检查生产者请求和业务发送顺序,而不能继续盯着消费端。

send() 的调用先后不等于 Broker 最终追加先后。同一连接存在多个尚未确认的请求时,如果发生失败、超时或重试,后发请求可能先被 Broker 接收。不同 Kafka 客户端版本对幂等生产者、多个 in-flight 请求和重试的顺序保证不同,应按项目实际版本检查 enable.idempotence、max.in.flight.requests.per.connection 等配置的官方说明,不能从别的项目直接复制参数值。

业务序号仍然值得保留。它可以帮助识别“生产者按业务顺序调用,但 Broker 按到达顺序追加”,也能在消费端阻止 OrderShipped 先于 OrderCreated 修改状态。不过,业务序号只能发现和拦截错误状态,不能替代真正的顺序控制。

消费端的另一种典型情况是 Offset 完全正确,完成顺序却倒置。Consumer 拉取到的记录在单个 Partition 内按 Offset 有序,但监听器如果立即把消息提交给通用业务线程池,线程调度、外部调用耗时和锁竞争都会改变完成时间。日志可能是:

OrderCreated  开始
OrderPaid     开始
OrderShipped  开始
OrderShipped  完成
OrderCreated  完成

这时 partition 和 offset 没有问题,consumeStartAt 与 consumeEndAt 却给出了直接证据。修复重点是线程亲和:同一个 Partition 或同一个 key 的后续处理,要等前一项处理完成后才能继续;不同 key 仍然可以并行。只限制消费者实例数,却没有限制监听器内部的业务线程池,并不能恢复完成顺序。

消费者失败后的处理方式会直接决定后续消息能否越过失败消息:

失败处理方式 对顺序的影响 代价与限制
阻塞当前 Partition 重试 后续同分区消息等待,顺序最容易维持 慢消息阻塞整个分区,长时间阻塞还可能触发 Rebalance
跳过失败消息并提交后续位点,再单独重试 后续消息可能先完成,严格顺序被破坏 适合业务允许跳过的场景,失败事件需要可靠保存
转入重试 Topic 具备隔离失败消息和恢复处理的可能 必须增加同 key 屏障,不能只转发失败消息

重试 Topic 方案最容易出现“设计上看似完整,实际仍乱序”的情况。假设 OrderPaid 失败后进入重试 Topic,而主 Topic 中的 OrderShipped 继续正常消费,后者就会越过前者。正确做法是:某个 key 进入重试状态后,后续同 key 消息也要被拦截、延后,或者一起进入受控的重试路径;重试成功后,再解除屏障。

这个屏障可以用按业务键维护的状态或缓存实现。重试消息仍应使用稳定 key 路由,并确认重试 Topic 的分区策略不会把同 key 事件拆散。仅仅“创建重试 Topic”并不能提供顺序保证。

位点提交则决定故障恢复后从哪里继续。最稳妥的原则是:业务处理成功后再提交位点;批量处理时,只提交连续成功处理到的最高 Offset。若 M1 到 M100 已成功,但实际提交的位点仍停在 M50,消费者重启后会从 M51 附近再次消费,表现为重复处理和状态回退,而不是 Broker 日志倒序。

异步提交还要处理提交结果,提交调用完成也不等于所有业务处理已经结束。自动提交同样不能被当作业务完成栅栏;具体提交语义需要按客户端版本和配置核对官方文档。 长时间阻塞重试时,还要检查消费者允许的 Poll 间隔,超过限制可能触发 Rebalance,随后再次出现未提交消息重放。

因此,比较稳妥的组合是:生产端按业务键稳定路由,同一业务键由明确的串行边界保护;消费端完成处理后再提交连续位点;失败消息采用阻塞重试或带同 key 屏障的重试 Topic;最后再用业务序号、幂等状态机或唯一约束兜底。业务序号与状态机只能拒绝非法跃迁,不能代替前面任何一层的顺序控制。

下一步可以选一个真实乱序订单,把 key / topic / partition / offset / retry / consumer / thread / 完成时间 补齐,先定位第一次出现差异的位置,再决定修生产路由、生产重试、消费线程还是位点提交。修复后至少验证正常发送、失败重试、分区扩容以及重启和 Rebalance 四种场景:同一个订单的业务序号不能倒退,重复投递还必须能够被幂等处理。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容