《Java 消息中间件·可靠消息与消费治理》10.1 顺序的粒度与代价

同一条消息在单分区里排队,才轮得到顺序。本篇把全局、分区、业务键三种粒度拆开,用 200 条同 key 消息在 in-flight 1/2/5 下各跑三遍,再用暂停 broker 四秒触发超时重试:幂等组 9 次运行全部 0 乱序、0 重复,非幂等组在途 5 时 20 处逆序、5 条重复。

顺序不是一个开关

「Kafka 能不能保证顺序」这个问题没有统一答案,因为顺序总是相对于某个范围说的。至少要分成三层:

粒度保证的范围需要什么代价
全局有序整个主题的所有消息单分区 + 单消费者 + 消费端串行处理放弃分区并行,吞吐与可用性都压在一个分区上
分区有序同一个分区内同一业务键进同一分区 + 生产端不越过在途 + 单分区串行消费分区数决定并行度上限
业务键有序同一个 key 的消息分区有序 + key 语义正确 + 消费端按 key 串行热点 key 会倾斜,key 设计变难

三层是包含关系:业务键有序依赖分区有序,分区有序是全局有序的放松。工程上几乎从不选「全局有序」,因为它把整个主题钉在一个分区上,扩容、迁移、故障恢复都变成单点。真正要回答的是:哪一组消息必须按顺序,其余的可不可以并行。

libraryd 里能说清的例子:同一个 bookId 的借出、归还、库存调整必须按发生顺序处理,否则库存会算错;不同 bookId 之间没有因果关系,可以随便并行。这就是典型的业务键有序,而不是全局有序。

全局有序的账

全局有序的实现路径只有一条:所有消息进同一个分区,消费端只有一个线程按位移顺序处理。

代价可以逐项列出来:

  • 生产端:所有写入压在一个分区、一个 broker 上,其他 broker 的磁盘和网卡帮不上忙。
  • 消费端:一个消费组最多只有一个消费者拿到这个分区,多起的消费者全部空闲。
  • 故障恢复:分区 leader 切换期间整个主题停写;副本同步也只围绕这一个分区。
  • 保留与清理:单分区文件持续增长,段滚动、compaction 的压力都集中在一处。

如果业务真的需要「全局一根线」,更常见的做法不是把整个主题压成一个分区,而是把「需要全局顺序」的那部分事件单独建一个单分区主题,其余事件继续多分区。把顺序约束关在最小的范围里,是这一节最重要的取舍。

分区内顺序由谁保证

分区内顺序不是 broker 单方面给的,生产端、broker、消费端各承担一段。

生产端:批次队列与在途

生产者按分区维护一个批次队列。正常发送时,批次从队头取出;发送失败需要重试时,批次被塞回队头:

// RecordAccumulator.java:496-505
    public void reenqueue(ProducerBatch batch, long now) {
        batch.reenqueued(now);
        Deque<ProducerBatch> deque = getOrCreateDeque(batch.topicPartition);
        synchronized (deque) {
            if (transactionManager != null)
                insertInSequenceOrder(deque, batch);
            else
                deque.addFirst(batch);
        }
    }

非事务路径直接 addFirst:重试的批次排到所有还没发出去的批次前面。如果此时同一个分区已经有第二个批次在途并且成功落盘,重试批次就会拿到更大的位移,顺序在这里断掉。

拆批走的是同一条路:

// RecordAccumulator.java:511-539(节选)
        Deque<ProducerBatch> dq = bigBatch.split(targetSplitBatchSize);
        int numSplitBatches = dq.size();
        Deque<ProducerBatch> partitionDequeue = getOrCreateDeque(bigBatch.topicPartition);
        while (!dq.isEmpty()) {
            ProducerBatch batch = dq.pollLast();
            incomplete.add(batch);
            // We treat the newly split batches as if they are not even tried.
            synchronized (partitionDequeue) {
                if (transactionManager != null) {
                    // We should track the newly created batches since they already have assigned sequences.
                    transactionManager.addInFlightBatch(batch);
                    insertInSequenceOrder(partitionDequeue, batch);
                } else {
                    partitionDequeue.addFirst(batch);
                }
            }
        }

注意两个分支的差别:事务/幂等路径用 insertInSequenceOrder 按序列号插回正确位置;非幂等路径只做 addFirst,插回顺序取决于拆批的遍历顺序,不保证与原顺序一致。

为什么 max.in.flight 会牵进来

在途请求数决定「同一个分区能同时有几个批次没被确认」。大于 1 时,第一批失败重试、第二批成功,第二批就先落盘。4.3.0 的配置文档写得很直白:

// ProducerConfig.java:291-293
            + "Allowing retries while setting <code>enable.idempotence</code> to <code>false</code> and <code>" + MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION + "</code> to greater than 1 will potentially change the"
            + " ordering of records because if two batches are sent to a single partition, and the first fails and is retried but the second"
            + " succeeds, then the records in the second batch may appear first.";

所以「max.in.flight=1 保证顺序」的准确含义是:同一个分区不会有两个批次同时在途,而不是整个连接只有一个请求。卷一 7.3 节从 Sender 和 InFlightRequests 的角度讲过这条链路,本节从实验侧把它量出来。

幂等路径的不同

开启幂等后,批次在 send() 阶段就被分配 (producerId, epoch, baseSequence)。重试时,RecordAccumulator 会在前一个批次未决时停止抽取新批次:

// RecordAccumulator.java:849-857
            // If the queued batch already has an assigned sequence, then it is being retried.
            // In this case, we wait until the next immediate batch is ready and drain that.
            // We only move on when the next in line batch is complete (either successfully or due to
            // a fatal broker error). This effectively reduces our in flight request count to 1.
            return firstInFlightSequence != RecordBatch.NO_SEQUENCE && first.hasSequence()
                    && first.baseSequence() != firstInFlightSequence;

注释里的 effectively reduces our in flight request count to 1 就是幂等的吞吐代价所在:故障恢复期间,同一分区的在途会退化到 1。

实验一:用拆批逼出重试

要观察乱序,先要有一个「可重试但不致命」的错误。这里用 topic 级的 max.message.bytes 逼 broker 拒绝过大的批次,客户端收到 MESSAGE_TOO_LARGE 后拆分重试。

设置

  • 主题单分区;max.message.bytes=1400。
  • 生产者 batch.size=2048、linger.ms=0、acks=all、delivery.timeout.ms=120000;非幂等组 retries=10,幂等组用默认的无限重试。
  • 同一 key loan-B1001 发 200 条,value 是发送序号 0–199。
  • max.in.flight.requests.per.connection 取 1、2、5 三档,enable.idempotence 取 false、true 各跑一遍,每组重复 3 次。
  • 消费端 assign 到该分区,从头读到尾,按消费顺序统计逆序对(前面序号大于后面的配对数)、相邻降序数、重复值个数。

非幂等:在途 2 与 5 都乱

本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29;单次运行,随调度波动,不作基准):

max.in.flight幂等逆序对相邻降序重复值发送阶段耗时(含构造)
1false000107–133 ms
2false50441091–109 ms
5false52432085–94 ms

三遍结果的逆序对数完全一致(5044 与 5243),说明这不是偶发抖动。原始输出:

RESULT inFlight=1 idem=false records=200 consumed=200 sendMs=133 failed=0 inversions=0 descents=0 duplicateValues=0 missingValues=0
RESULT inFlight=2 idem=false records=200 consumed=200 sendMs=109 failed=0 inversions=5044 descents=1 duplicateValues=0 missingValues=0
FIRST-INVERSION sendSeq=102 offset=50 before sendSeq=50 offset=147
RESULT inFlight=5 idem=false records=200 consumed=200 sendMs=94 failed=0 inversions=5243 descents=2 duplicateValues=0 missingValues=0
FIRST-INVERSION sendSeq=199 offset=0 before sendSeq=0 offset=1

把两条乱序的消费顺序完整打出来,形态很清楚。在途 2:

0–49, 102–198, 50–101, 199

中间那块 50–101 被搬到了 102–198 之后,52 个值各自越过 97 个值,逆序对正好 52 × 97 = 5044,相邻降序只有 1 次。

在途 5 是同样的块搬移再加一次跳跃:

199, 0–49, 102–198, 50–101

最后发送的 199 号拿到了 0 号位移,越过其余 199 条;中间的 50–101 仍然被搬到后面,贡献 5044 对。合计 199 + 5044 = 5243,相邻降序 2 次。逆序对不是「每条都乱」,而是两块整体错位,所以数字很大、降序次数却很少。

幂等:顺序干净,但在途 5 撞上拆批的边界

同样的 200 条,打开 enable.idempotence=true:

max.in.flight幂等逆序对重复值结果
1true00200 条全部写入
2true00200 条全部写入
5true——三次独立运行全部在 120 秒投递超时,只写入 1 条

在途 5 + 幂等这一格不能写成「0 乱序」蒙混过去。三次 200 条的运行里,生产者在 120 秒的 delivery.timeout.ms 内反复收到同一个错误:

[kafka-producer-network-thread | producer-1] WARN org.apache.kafka.clients.producer.internals.Sender - [Producer clientId=producer-1] Got error produce response with correlation id 9 on topic-partition v2c10.ord.dbg-0, retrying (2147483646 attempts left). Error: OUT_OF_ORDER_SEQUENCE_NUMBER
[kafka-producer-network-thread | producer-1] WARN org.apache.kafka.clients.producer.internals.Sender - [Producer clientId=producer-1] Got error produce response with correlation id 60 on topic-partition v2c10.ord.dbg-0, retrying (2147483595 attempts left). Error: OUT_OF_ORDER_SEQUENCE_NUMBER

同一套参数把记录数从 200 降到 60 时,在途 5 + 幂等可以正常完成;200 条时稳定卡住:

RESULT inFlight=5 idem=true records=60 consumed=60 sendMs=101 failed=0 inversions=0 descents=0 duplicateValues=0 missingValues=0
```这是**拆批机制与幂等在途组合的边界**,不是幂等本身的语义问题:`MESSAGE_TOO_LARGE` 拆出来的批次继承了序列号,多批同时在途时序列号推进对不上,生产者宁可重试到投递超时也不跳号。本文没有继续挖根因,只把可复现的现象如实记下。

工程含义很明确:不要用「超过 broker 上限再靠拆批」当常规路径。消息大小应该在发送前就被约束住(11.1 节展开),让 `MESSAGE_TOO_LARGE` 只作为异常兜底。

这个边界必须说清楚,否则容易被误读:**触发条件是 `max.message.bytes` 被人为压到 1400 字节,导致批次被拆。** 正常配置下(1 MiB 上限、消息远小于上限),批次不会因为超限被拆开,`max.in.flight=5` + 幂等是 kafka-clients 的默认组合,也是推荐组合,不会出现上面的现象。别把这个实验读成「幂等不能配在途 5」——那等于关掉一个正确的默认值。

## 实验二:暂停 broker 四秒

拆批是人为构造的触发器。更接近真实故障的是 broker 短暂不可用:请求超时、客户端重连、重试。

### 设置

- 同样的单分区主题,`max.message.bytes` 用默认值,不触发拆批。
- 200 条同 key 消息,每 40 ms 发一条,总共约 8 秒。
- 发到第 3 秒时 `docker pause` 容器,4 秒后 `docker unpause`。
- `request.timeout.ms=3000`,因此在暂停期间的在途请求会超时重连。
- 三档在途 × 幂等开关各跑 3 次。

### 结果

本机实测(同上环境;每组 3 次,三次结果一致):

| 在途 | 幂等 | 超时请求 | 消费条数 | 重复值 | 逆序对 | 相邻降序 |
|---|---|---|---|---|---|---|
| 1 | false | 1 | 201 | 1 | 0 | 0 |
| 2 | false | 2 | 202 | 2 | 2 | 1 |
| 5 | false | 5 | 205 | 5 | 20 | 4 |
| 1 | true | 1 | 200 | 0 | 0 | 0 |
| 2 | true | 2 | 200 | 0 | 0 | 0 |
| 5 | true | 5 | 200 | 0 | 0 | 0 |

原始输出(在途 5 的两组):

```text
RESULT inFlight=5 idem=false records=200 consumed=205 sendMs=10457 failed=0 inversions=20 descents=4 duplicateValues=5 missingValues=0
FIRST-INVERSION sendSeq=54 offset=54 before sendSeq=53 offset=62
RESULT inFlight=5 idem=true records=200 consumed=200 sendMs=10034 failed=0 inversions=0 descents=0 duplicateValues=0 missingValues=0

这张表把两件事同时做实了。

第一,超时不等于没写进去。非幂等组的重复值个数与超时请求数一一对应:超时 1 个就多 1 条,超时 5 个就多 5 条。请求超时只是客户端没等到响应,broker 解冻后照样把缓冲区里的请求追加进日志。客户端重试,于是同一条消息落了两次。这与卷一 7.3 节的暂停实验是同一现象,只是那里是 12 条记录、2 个超时。

第二,幂等同时消掉了重复和乱序。9 次幂等运行,消费条数全部是 200,重复 0、逆序 0。机制不神秘:重试带着同一个 (producerId, epoch, baseSequence),broker 按分区保留最近 5 个批次的序列号,重复的直接回 DUPLICATE_SEQUENCE_NUMBER 当成功处理;同时在途超过前一个未决批次时,生产端自己停止抽新批次。

在途越大,乱序越多

非幂等组的逆序对随在途单调上升:0 → 2 → 20;重复值也随之上升:1 → 2 → 5。原因不是「在途大更容易超时」——暂停 4 秒,任何在途请求都会超时——而是超时后能被后来者超过的批次更多。在途 1 时,重试批次回来之前没有别的批次在飞,顺序天然安全;在途 5 时,最多有 4 个批次可以越过它先落盘。

幂等的吞吐代价在哪

先看无故障的稳态。200 000 条记录、单分区、acks=all、batch.size=16384、linger.ms=5,开/关幂等各跑 3 次:

idem=false ms=500 recPerSec=400000 | idem=true ms=478 recPerSec=418410
idem=false ms=494 recPerSec=404858 | idem=true ms=540 recPerSec=370370
idem=false ms=512 recPerSec=390625 | idem=true ms=426 recPerSec=469484

三次配对里,开幂等有两次更快、一次更慢,全部落在 37 万–47 万条/秒的波动范围内(单次运行,随机器调度波动,不作基准)。无故障时,幂等的吞吐差异在这台机器上测不出来。

真正可迁移的代价在故障路径上,三条:

  1. 重试期间同一分区的在途退化到 1(前面的源码注释),恢复速度受单批次往返限制;
  2. broker 每个分区要为每个 producer 保留最近 5 个批次的序列号状态,max.in.flight 因此被硬限制在 5;
  3. 批次多了一段序列号簿记,RecordAccumulator 的 insertInSequenceOrder 也要在重试时维护队列顺序。

所以「幂等很贵」这句话不准确。准确的说法是:幂等在稳态几乎免费,在恢复期用并行度换正确性。

顺带一个指标陷阱:record-send-rate 是 30 秒窗口的速率,短跑 0.5 秒的 20 万条会被摊成约 6 570 条/秒,和实际速率差两个数量级。要么看墙钟自己算,要么让进程活过一个完整窗口再读数。

业务键有序怎么落地

分区有序只到「同一个分区」为止。要让「同一个 key」有序,还需要三件事同时成立。

key 选对

library.loan.* 用 bookId 作 key,同一个 bookId 的所有借还事件进同一分区。注意 library.loan.overdue 用的是 loanId——如果一个业务要同时看「这本书的借出」和「这笔借阅的逾期」,两个主题的 key 不是一套,跨主题的顺序就无法用分区保证。跨主题顺序只能靠业务上的因果 ID 或下游归并,Kafka 不提供。

消费端不能把顺序丢在应用层

消费者在一个分区内是按位移顺序返回记录的,但如果应用把记录丢进线程池,顺序立刻失效。按 key 路由到固定线程是常用办法:

int workers = 4;
ExecutorService[] pool = new ExecutorService[workers];
for (int i = 0; i < workers; i++) {
    pool[i] = Executors.newSingleThreadExecutor();
}
// 同一个 key 永远落到同一个 worker
int idx = Math.floorMod(key.hashCode(), workers);
pool[idx].submit(() -> handle(record));

这段代码保证的是「同一个 key 串行」,不保证「同一个分区内不同 key 的顺序」。如果业务还要求分区内全序,只能每个分区一个线程,不能按 key 打散。

重分区会打断 key 的历史顺序

key 到分区的映射依赖分区数(10.3 节)。一旦改分区数,同一个 key 的新消息会落到新分区,旧分区里的历史消息和新分区里的新消息之间没有任何顺序关系。要迁移就得停写或做双写切换,顺序窗口必须显式设计。

常见误解

  • 「acks=all 就有序」:错。acks=all 管的是副本确认,不管批次到达顺序。
  • 「一个消费者就有序」:错。一个消费者如果多线程处理,应用层顺序照样丢。
  • 「开了幂等就不会重复」:错。幂等只覆盖生产者重试导致的重复;消费端再平衡、位移回退、下游重放造成的重复要靠 2.2 节的业务幂等。
  • 「max.in.flight=1 一定慢一个数量级」:没有普适倍数。它限制的是同一连接上的流水线,实际影响取决于单请求往返与批次大小;本机暂停实验里发送耗时被 40 ms 的发送间隔主导,量不出这个差异。
  • 「全局有序只要一个分区就行」:还要消费端单线程、生产端不越序,以及接受这个分区成为整个主题的吞吐与可用性单点。

本节判据

  1. 全局有序、分区有序、业务键有序各需要什么? 答:全局要单分区 + 单消费者 + 串行处理;分区要有 key→同分区 + 生产端不越序 + 单分区串行消费;业务键还要 key 语义正确、应用层按 key 串行。
  2. 为什么非幂等在途 2 也会乱序? 答:第一批失败重试时,第二批可以在它之前落盘;重试批次 addFirst 回队头,但位移已经落后。
  3. 暂停 broker 后为什么会出现重复? 答:请求超时只代表客户端没收到响应,broker 解冻后仍会追加缓冲区里的请求,客户端重试就写了第二遍。
  4. 幂等消除乱序靠哪两层? 答:broker 按 (producerId, epoch, baseSequence) 去重并拒绝跳号;生产端在重试批次未决时停止抽新批次,把分区在途压到 1。
  5. 幂等的吞吐代价什么时候出现? 答:稳态几乎测不出;恢复期以并行度换正确性,另外还有 broker 侧 5 个批次的序列号状态与 max.in.flight≤5 的硬约束。

阅读导航:上一节 9.3 最终一致性的度量 | 下一节 10.2 热点分区与 key 设计

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收