Skip to content

顺序消息 ​

看在前面 ​

顺序消息是 MQ 面试的第二大题,它和「不丢失」是一对:不丢失考链路完整性,顺序考并发模型的代价。这题的核心不是背方案,而是想清楚一件事。顺序和吞吐是同一枚硬币的两面,Kafka 用分区有序换吞吐,你要做的是把顺序需求收敛到最小范围。

前置阅读:Kafka架构与高性能原理(分区为什么是顺序的最小单位)。消息不丢失与不重复(重试如何引发乱序)。

全局有序 vs 分区有序 ​

text
全局有序(1 个分区)               分区有序(N 个分区 + 分区键)
┌────────────┐                    ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Partition0 │                    │    P0    │ │    P1    │ │    P2    │
│ A创建→A支付 │                    │ A:创建→支付│ │ B:创建→支付│ │ C:创建→支付│
│ B创建→B支付 │  吞吐 = 单分区上限    │          │ │          │ │          │
│ (串行瓶颈) │                    └──────────┘ └──────────┘ └──────────┘
└────────────┘                     同一 key 恒进同一分区 → key 内 FIFO
                                   吞吐随分区数线性扩展
  • 全局有序:整个 topic 一条队列,绝对有序,但并行度=1,任何一环卡住全部卡住。生产上只在审计日志、binlog 单流回放这类低吞吐场景用(且往往用分区数为 1 表达强约束)。
  • 分区有序:Kafka 的正式承诺——只保证分区内 FIFO。同一实体(订单号、用户 ID)的消息 hash 到同一分区,实体内有序,实体间并行。业务上的「顺序需求」99% 是实体内有序,不是全局有序——这是答这题的第一句正确话。

分区键怎么选 ​

选择原则:有序粒度 = 业务一致性边界。

java
// 同一订单的全部事件进同一分区
producer.send(new ProducerRecord<>("order-events", orderId, event));
// DefaultPartitioner:murmur2(key) % partitionCount,正数取模
// 不指定 key → 轮询(sticky) → 完全无序;key 变更 → 老 key 历史在新分区不连续

必须注意的三个坑:

  1. null key 直接失去顺序:默认分区器对无 key 消息轮询。同一个订单的事件会被打散到不同分区。
  2. key 与分区数的关系:扩分区后 hash(key) % partitionCount 的结果会变。老 key 的新消息可能与历史不在同一分区。扩分区前的存量顺序不保证延续。对顺序强依赖的 topic 要么一开始定足分区。要么自定义分区器固定映射(按 key 哈希环)。
  3. 热点问题:大客户 key 的流量集中在单分区,形成数据倾斜。解法:二级 key(orderId + 子序号 拆分不相关流程)、或接受该 key 有序即可时用 userId 粗粒度摊薄。

乱序的两个根源 ​

根源一:生产端重试 ​

text
时间线(max.in.flight > 1 且未开幂等):
  批次1(msg A1) 发送 → 超时      ┐
  批次2(msg A2) 发送 → 成功落盘   │ broker 端顺序:A2, A1  → 乱序!
  批次1 重试 → 成功              ┘
  • 未开幂等时:max.in.flight.requests.per.connection=1 可以硬保序,但完全串行化,吞吐损失大。
  • 开了幂等(enable.idempotence=true)后:broker 按 seq 对单分区的 in-flight 批次(<=5)重排去重,重试不再乱序——这是 99% 场景的正解。
  • 跨会话/跨分区依然可能乱(producer 重启换 PID),强需求用事务(见 事务消息与延迟消息)。

根源二:消费端多线程 ​

Kafka 一个分区只给组内一个消费者,但一个消费者内部开线程池并行处理,就亲手破坏了拉取顺序:

java
// 错误示范:拉下来直接丢线程池,完成顺序不可控
for (ConsumerRecord<String, String> r : records) {
    executor.submit(() -> process(r));   // A1/A2 谁先处理完看调度心情
}

保序方案 ​

方案一:单分区单线程(最简单) ​

一个消费者消费一个分区,处理逻辑就在 poll 线程里跑。吞吐不够就加分区加消费者,靠横向扩展而不是单机多线程——Kafka 设计的本意。

方案二:内存队列按 key 二次分发(单机多线程的标准解) ​

text
 poll 线程                      worker 线程池
┌────────────┐   按 key 哈希   ┌─────────────┐
│ partition0 │ ──────────────► │ queue[0] ─► worker0 │ 只处理 key 落 0 号队列
│ (拉取有序) │   (有界队列)   │ queue[1] ─► worker1 │
└────────────┘                 └─────────────┘
 同一 key 恒进同一 queue,同一 queue 串行消费 → key 内有序,queue 间并行
java
int queueNum = Runtime.getRuntime().availableProcessors();
BlockingQueue<ConsumerRecord<String, String>>[] queues = new BlockingQueue[queueNum];
for (int i = 0; i < queueNum; i++) {
    final int idx = i;
    new Thread(() -> {                       // 每个 queue 一个单线程 worker
        while (true) {
            ConsumerRecord<String, String> r = queues[idx].take();
            process(r);                      // 同 key 串行
        }
    }, "worker-" + i).start();
}
for (ConsumerRecord<String, String> r : records) {
    int idx = Math.abs(murmur2(r.key().getBytes())) % queueNum;
    queues[idx].put(r);                      // 有界队列 + put 背压
}

注意 offset 提交要改成「取该批中最小的未完成 offset」(队列异步完成,poll 线程不能直接提交)。否则会回到先提交后处理的丢消息问题。

方案三:失败重试不跳过 ​

顺序消费里最隐蔽的乱序源:消息 A 失败进重试队列,消息 B 先被处理。RocketMQ 的 MessageListenerOrderly 用分区锁 + 消费失败阻塞当前队列(SUSPEND_CURRENT_QUEUE_A_MOMENT)而不是跳去重试 topic,就是为了保序。自研方案同理:失败就地有限重试 -> 仍失败落库告警,绝不放行后续消息。

与性能的取舍 ​

手段顺序保证吞吐代价适用
单分区 topic全局并行度=1,最低binlog 回放、审计
分区键 + 单线程消费key 内有序中(消费者=分区数)常规业务默认
分区键 + 内存队列多线程key 内有序高(单机并行)高吞吐顺序流
in-flight=1 禁重试乱序生产端强保序高(完全串行)用幂等替代,很少选
失败阻塞队列消费端强保序毒药消息拖慢整队列交易类

一句话:把顺序范围收到 key 粒度,把并行度扩到分区/队列粒度,失败路径宁可阻塞不可并发。

追问链 ​

Q1:为什么 Kafka 不支持全局有序还这么流行?

因为业务要的是实体内有序。Kafka 把「顺序」拆成可组合的小单元(分区),用分区键组合出任意粒度的顺序,同时保住水平扩展。比提供一个全局串行队列的系统的工程价值高得多。

Q2:同一个 key,先发创建后发支付,消费端会先收到支付吗?

正常不会(同分区 FIFO);但生产端未开幂等且重试乱序、或消费端线程池并行、或 key 中途换分区,都可能。答「不会」不给推导过程是减分的。

Q3:加分区对顺序消息有什么影响?

hash(key) % n 的 n 变了,存量 key 的路由被打散,新老消息可能不同分区。顺序 topic 扩分区要停写迁移或用固定映射的自定义分区器。

Q4:RocketMQ 和 Kafka 的顺序消息差在哪?

粒度:Kafka 分区 vs RocketMQ MessageQueue(等价)。差异在消费端:RocketMQ 原生 MessageListenerOrderly 提供队列级锁 + 串行消费 + 失败阻塞,Kafka 需要 consumers 自己实现(方案二)。RocketMQ 还有 broker 端 sendMessage 时可指定 queueId 的细粒度控制。

Q5:热点 key 把一个分区打满了怎么办?

先确认是否真的要单 key 串行(订单内多个不相关事件可拆)。再考虑 key 加盐拆子流(子流内有序,合并时按序号归并)。最后才考虑扩分区(注意 Q3 的迁移代价)。

面试官视角小结 ​

  • 开口先分「全局有序 / 分区有序」,并指出业务真正需要的是实体内有序——这一句就把讨论从背参数拉到设计层面。
  • 乱序根源要能拆成生产端重试 + 消费端多线程两条线,各给方案:幂等/单 in-flight、单线程或内存 key 分发。能白板写出内存队列分发结构的属于强候选人。
  • 主动讲取舍(顺序 vs 吞吐、保序 vs 毒药消息阻塞)是高级感来源。知道 MessageListenerOrderly 失败会阻塞整队列的候选人,明显踩过坑。
  • 减分项:说「Kafka 天然有序」不提「仅限分区内」;只说「用 key」不提 null key、扩分区、热点三个坑。

2026-10 新增(P0 补全)。