Skip to content

按订单号发到同一个分区,为什么还是乱序了 ​

订单的状态变更发到 Kafka,用订单号做消息的键,同一个订单的消息进同一个分区,分区内有序。运行了很久都没问题。某次扩容之后,个别订单的状态被旧的消息覆盖了:已经「已发货」的订单又变回了「已支付」。

「同一个键进同一个分区」和「分区内有序」都是成立的。但从生产者写入到业务状态被更新,中间还有好几步,每一步都可能让同一个订单的两条消息以相反的顺序生效:分区数变了、分区内的消息被并行处理、失败的消息稍后重试、不同的程序用不同的方式计算分区。

本文在 Kafka 4.3.1 上把这几种情况逐个跑出来,最后说明为什么消费端要自己判断新旧,而不是完全依赖到达的顺序。实验里消息的值形如 order-1:v3,表示订单 1 的第 3 个版本。见文末。

一、Kafka 承诺的顺序到哪里为止 ​

Kafka 的顺序保证是按分区给的:同一个分区里,消息按写入的先后排列,消费者按这个顺序读到它们。不同分区之间没有任何顺序关系。

要让同一个订单的消息有序,就得让它们进入同一个分区。带键的消息,默认的做法是对键做哈希,再对分区数取模。于是整个保证其实是三段接起来的:

  1. 同一个键算出同一个分区号(依赖哈希算法和分区数);
  2. 分区内按写入顺序保存(Kafka 保证);
  3. 消费者按读到的顺序把消息变成业务状态(依赖消费端的写法)。

第 2 段很可靠。出问题的都在第 1 段和第 3 段。

二、分区数变了 ​

主题原有 4 个分区。为 20 个订单各写入 v1、v2 之后,把分区数增加到 8,再各写入 v3。

扩容前:4 个分区分区 2v1v2order-1 的哈希 mod 4 = 2扩容后:8 个分区分区 2v1v2旧消息不会搬家,仍在这里(有积压)分区 6v3order-1 的哈希 mod 8 = 6:新消息写到这里处理顺序v3v1v2按到达顺序覆盖:最后留下 v2同一个分区里把消息交给线程池并行处理、失败的消息稍后重试,也会让旧版本晚于新版本到达消费端按版本号判断:只接受比当前更新的版本,顺序被打乱也不会用旧值覆盖新值要扩分区又要保序:新建主题并切换,或者先停写、等旧分区消费完再扩
图 1 · 按键发送只保证「同一个键、在分区数不变的期间」落在同一个分区。键到分区的映射是哈希对分区数取模,分区数从 4 改成 8,约一半的键换了地方(实验里 20 个订单换了 7 个)。order-1 的 v1、v2 留在旧分区,v3 写进了新分区。两个分区由不同的消费者各自推进,旧分区只要有积压,v3 就会先于 v1、v2 被处理。Kafka 的顺序保证没有被破坏:每个分区内部仍然有序,只是这个订单不再只在一个分区里。
  • 20 个订单里有 7 个换了分区,都是从分区 p 换到 p+4。
  • 以 order-1 为例:v1、v2 在分区 2,v3 在分区 6。
  • 每个分区内部,同一个订单的版本仍然是递增的。Kafka 的保证没有被破坏。

哈希值没变,除数从 4 变成 8,余数就可能不同。已经写入的消息不会搬家,于是同一个订单的历史分成了两段,落在两个分区里。这两个分区由不同的消费者(或同一个消费者的不同批次)各自推进,谁先谁后没有约定。实验里让新分区先被消费:

text
处理顺序:order-1:v3@分区6 → order-1:v1@分区2 → order-1:v2@分区2
按到达顺序覆盖,最后留下的是 v2

这就是开头的现象。它需要一个条件:扩容时旧分区里还有这个订单没消费完的消息。消费得越慢、积压越多,受影响的订单越多。

增加分区通常是因为消费跟不上,恰恰是积压最严重的时候。需要保序的主题,扩分区之前要先决定怎么处理这个窗口:

  • 停写、排空、再扩。 暂停生产者,等所有分区的积压消费完,再增加分区、恢复生产。有一段不可写的时间。
  • 新建主题并切换。 建一个分区数足够的新主题,生产者切到新主题;消费者先把旧主题消费完,再开始消费新主题。
  • 一开始就留够分区。 分区数只能增加不能减少,而按键保序的主题实际上很难再增加,所以初始值要按若干年的容量来定。
  • 让消费端不依赖到达顺序(第五节)。这是唯一一个对其余几种乱序也有效的办法。

三、同一个分区里并行处理 ​

消费跟不上的另一个常见对策是在消费者内部开线程池:拉到一批消息,交给多个线程同时处理。这直接放弃了第 3 段。实验里一个分区有两个订单各 6 个版本,整批交给 4 个线程的池,奇数版本处理得慢一些:

处理方式order-1 最后留下order-2 最后留下
交给 4 个线程的池v5v5
按订单号分到 4 条各自串行的通道v6v6

日志里每个订单的最后一条都是 v6。线程池里 v6 先处理完,v5 后处理完,最后写进去的是 v5。

要并行又要保序,办法是把「按键路由」在消费者内部再做一次:准备若干条通道,每条由一个线程串行处理,消息按键的哈希分到固定的通道。同一个订单的消息始终在同一条通道里按顺序处理,不同订单之间是并行的(实验里两个订单确实是同时处理的)。

分区内的消息1:v11:v21:v31:v41:v51:v6(order-2 同样 6 条)交给线程池4 个线程谁先做完谁先写v6v5→ 最后留下 v5按订单号分通道hash(订单号) % 4每条通道一个线程一条通道:order-1 v1 → v2 → … → v6另一条通道:order-2 v1 → v2 → … → v6最后留下 v6
图 2 · 实测:一个分区里两个订单各 6 个版本,奇数版本处理得慢一些。整批交给 4 个线程的池,v6 先处理完、v5 后处理完,两个订单最后留下的都是 v5;按订单号哈希到 4 条各自串行的通道,同一订单始终在同一条通道里按顺序处理,最后是 v6,不同订单之间仍然并行

并行处理还带来位点提交的问题:先完成的不一定是偏移量小的,提交时只能提交到「之前全部完成」的位置,否则重启后会丢消息。在途消息的上限与连续提交,见 队列设了上限,内存还是涨上去了;位点与重复消费的关系,见 Kafka 不丢、不重与 Exactly Once。

四、失败的消息稍后重试 ​

处理失败的消息不能一直堵着分区,常见的做法是把它转发到一个重试主题,过一会儿再处理,主流程继续往下走。这同样改变了生效的顺序:

text
v1 处理失败,转入重试主题
v2 处理成功
重试 v1
写入状态的方式最后的状态
直接覆盖v1(旧值盖掉了新值)
只接受比当前更大的版本v2(重试的 v1 被丢弃)

这里每一条消息单独看都是幂等的:重复处理 v1 多少次,结果都是「状态等于 v1」。问题在于幂等不等于有序,一个幂等的旧操作排在新操作之后执行,照样会把新值盖掉。生产者或消费者的重试、重平衡之后的重复消费、人工补发的消息,都会造成同样的结果。

五、让消费端自己判断新旧 ​

前面三种情况的共同点是:旧版本的消息晚于新版本到达。消费端如果只按到达顺序处理,无从分辨。能分辨的办法是让消息自己带着顺序信息,消费端据此决定要不要应用:

  • 版本号。 每个订单维护一个单调递增的版本,随消息发出。消费端更新状态时带条件:UPDATE … SET status = ?, version = ? WHERE id = ? AND version < ?,影响行数为 0 说明是旧消息或重复消息,直接丢弃。
  • 状态机。 状态之间有明确的先后(已支付之后才能发货),消费端只接受合法的转换。「已发货」的订单收到「已支付」的事件,不是一次合法的转换。
  • 事件携带完整状态而不是增量。 「把状态设为 X(版本 n)」可以按版本比较后丢弃;「给数量加 1」这样的增量消息丢弃或重复都会出错,必须配合去重。

版本号要由产生变更的那一方在业务事务里生成(数据库里的版本列、单调的序列),不能用消息的时间戳或偏移量代替:时间戳来自不同机器的时钟,偏移量在跨分区、跨主题时不可比。

有了这层判断,顺序从「必须保证」变成了「尽量保持、乱了也不出错」。分区内有序仍然有价值,它让绝大多数消息一次处理成功、不必走丢弃的分支;但正确性不再压在它上面。订单状态机与条件更新的完整做法,见 订单、库存与数据一致性。

六、另外两处会改变键到分区映射的地方 ​

不同的客户端用不同的哈希。 Java 客户端默认用 murmur2 对键做哈希。其他语言的客户端默认算法不一定相同,例如基于 librdkafka 的客户端默认的分区方式用的是 CRC32。实验里对 20 个订单号分别按两种算法对 8 取模,18 个得到了不同的分区。同一个主题如果有两种语言的生产者写入同一批键,同一个订单的消息从一开始就分散在两个分区里。混用时要把各方的分区方式显式配置成同一种。

没有键的消息。 键为空时,生产者按批次把消息分配到某个分区,没有任何按业务归属的保证。需要保序的消息必须带键。

生产者一侧的重试也曾经是乱序的来源:一批消息发送失败重试时,后一批可能先写入成功。当前版本的生产者默认开启幂等,在这种情况下仍然保持顺序,前提是没有手动关闭它,并且没有把每个连接上的在途请求数调到 5 以上。

七、需要保序的主题,上线前核对 ​

  1. 键是业务上需要保序的那个标识(订单号),所有生产者使用同一种分区算法。
  2. 分区数按长期容量规划;要增加分区时,有排空或切换主题的方案。
  3. 消费者内部如果并行,按键分通道,位点按连续完成的位置提交。
  4. 重试、死信、补发的路径上,消息带着版本,消费端按版本或状态机决定是否应用。
  5. 测试里有这几种输入:同一个键的消息逆序到达、重复到达、隔着别的消息到达。

事件晚到时按哪个时间统计、等多久,见晚到了五十秒的那次点击,算在哪一分钟。


配套实验

  • codesphere-labs/messaging/kafka-key-ordering:单节点 Kafka 4.3.1 上:分区数从 4 增加到 8 前后 20 个键的分区、换分区的订单在新旧分区的消费先后与最后留下的版本、分区内并行处理与按键分通道、失败消息延迟重试时直接覆盖与按版本比较、murmur2 与 CRC32 两种分区算法的差异(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权