Appearance
顺序消息
看在前面
顺序消息是 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 历史在新分区不连续必须注意的三个坑:
- null key 直接失去顺序:默认分区器对无 key 消息轮询。同一个订单的事件会被打散到不同分区。
- key 与分区数的关系:扩分区后
hash(key) % partitionCount的结果会变。老 key 的新消息可能与历史不在同一分区。扩分区前的存量顺序不保证延续。对顺序强依赖的 topic 要么一开始定足分区。要么自定义分区器固定映射(按 key 哈希环)。 - 热点问题:大客户 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 补全)。