顺序性与分区设计
Kafka 只保证分区内有序。所以"顺序设计"本质上是"分区路由设计":把需要保序的消息用同一个 Key 钉进同一个分区,再把分区内的串行消费贯彻到底。本章讲 Key 的选法、franz-go 分区器与混部协议差异、分区数的估法,以及三个最常见的乱序事故(扩分区、重试、消费端并发)。
前置阅读:04-生产者(分区路由在发送流程里的位置)、05-消费者与消费者组。
顺序到底在哪个环节成立
一条消息从发出到处理,全链路是这样"排队"的:
相同 Key ──▶ 同一分区 ──▶ 分区内按 Offset 严格追加 ──▶ 同一消费者串行拉取处理三个环节,任何一个被破坏,顺序就没了:
- 路由破坏:相同 Key 被分到了不同分区(换分区器、分区扩容、Key 传了 nil);
- 写入破坏:生产者对同一分区并发乱序发(同一
ProduceSync内部按传入顺序成批,风险主要在多实例/多连接并发写同分区); - 消费破坏:消费者拉到消息后丢进协程池并发处理——拉取有序,处理乱序。
注意:
acks≠0+ 重试也可能引入"逻辑乱序":第一条超时重发,结果第二条先到。franz-go 默认幂等生产恰好治这个——幂等模式下最多 5 个 in-flight 请求且 Broker 按序号保序(KIP-214),重试与有序可以兼得,什么都不用配。kafka-go 无幂等生产,对顺序敏感的业务宁可不重试、直接走失败兜底 + 死信,也别靠重试保序。
用 Key 把消息钉进同一分区
以订单为例,订单生命周期事件(创建→支付→发货)必须按序到达处理方:
// 延续 04 的 cl(DefaultProduceTopic("order-events"));所有订单事件用 order_no 作 Key
for _, evt := range []struct{ no, kind string }{
{"SO20261013001", "created"},
{"SO20261013001", "paid"},
{"SO20261013001", "shipped"},
} {
rec := &kgo.Record{
Key: []byte(evt.no), // 关键:Key 决定分区
Value: []byte(`{"kind":"` + evt.kind + `"}`),
}
if err := cl.ProduceSync(ctx, rec).FirstErr(); err != nil {
// 分类处理见 08 publish()
}
}分区器选型(多语言混部时别踩协议差异)
franz-go 侧:
| 需求 | 选择 | 说明 |
|---|---|---|
| 默认(含与 Java 共存) | 什么都不配 | UniformBytesPartitioner(KIP-794):有 Key 走 murmur2,与 Java 默认分区器逐字节一致;无 Key 按字节自适应轮转 |
| 严格复刻 Java 2.4~3.2 行为 | kgo.StickyKeyPartitioner(nil) |
有 Key murmur2、无 Key 按批粘性;仅在对齐老 Java 语义时用,新集群直接吃默认 |
| 钉死分区(回放/回写) | kgo.ManualPartitioner() + Record.Partition |
显式指定才生效,见事故二 |
kafka-go 存量(默认 RoundRobin,与 Java 不兼容,混部必换):
| 需求 | kafka-go 选择 | 说明 |
|---|---|---|
| Go 服务之间 | &kafka.Hash{} |
对 Key 做 FNV 哈希,简单稳定 |
| 与 Java 客户端共存 | &kafka.Murmur2Balancer{} |
对齐 Java 默认分区器(Murmur2),迁移/混部必选 |
| 与 librdkafka / confluent-kafka-go 共存 | kafka.CRC32Balancer{} |
对齐 librdkafka 的 consistent_random |
| 从 Sarama 迁移 | &kafka.ReferenceHash{} |
对齐 Sarama ReferenceHashPartitioner |
Key 为 nil 时:franz 默认分区器会转向"自适应轮转"(KIP-794,按字节摊平且避开高积压分区,比朴素轮询更聪明);kafka-go 多数 Balancer 直接轮询——同一业务实体的消息会散开。所以"要保序的消息必须有 Key“是前提,不是可选优化;kafka-go 的 CRC32Balancer{AllowKeylessGoRound: false} 会让无 Key 消息报错,用它来强制团队规范——franz 没有对应的"报错"开关,靠 review 约束。
注意:换分区器等于重新洗牌。线上从
Hash切到Murmur2Balancer(或改 franz 自定义RecordPartitioner)的那一刻,同一 Key 的新老消息就去了不同分区,存量未消费完的消息顺序不再成立。切换要安排在"分区消费排空"的时间窗内做,或者干脆新建 Topic 迁移。
分区数怎么定
分区数是不可逆决策(只能加不能减,加了还破坏顺序),值得在建模时想清楚。
估算思路:
所需分区数 = max(
生产峰值 TPS / 单分区生产吞吐, // 经验值:普通消息体单分区可撑 ~5000-15000 TPS
消费峰值处理耗时需求 / 单消费者能力, // 单分区只能被组内 1 个消费者串行消费
消费者目标并行度 // 分区数 < 消费者数会有实例闲置(05 章)
) × 1.5~2 倍冗余举例:订单事件峰值 3000 TPS,消费者单实例串行处理实测 800 TPS,组内最多部署 8 个 Pod:
- 按生产:3000 / 800 ≈ 4 个分区起步;
- 按消费并行:需要 ≥ 4 才能把 8 Pod 用起来一半以上;
- 加冗余:6~8 个分区,留足三年增长。
配套建议:
- 高基数 Key 才摊得平:
user_id、order_no这类几千万基数的 Key 经过哈希会均匀铺满分区;status、city这种低基数 Key 会造成分区倾斜(“支付"状态占 90% 流量的 Key 只有一个)。低基数场景在 Key 后拼随机后缀会破坏顺序——此时要接受单分区上限。 - 不要按 Topic 数量省分区:一个集群几千个"1 分区小 Topic"比十几个"16 分区大 Topic"更难运维(每个分区都有 segment 文件、复制、controller 元数据开销;ZK 时代经验值单 Broker 4000 分区,KRaft 已大幅放宽,但文件句柄和恢复时间仍随分区数线性涨)。
- 建好 Topic 后想验证 Key 分布是否均匀,用各分区 Lag/吞吐对比(02 章的
kafka-get-offsets):分区 offset 增长差一个量级就是倾斜。
事故一:扩分区打乱存量顺序
分区从 4 扩到 8 的那一刻,hash(key) % 8 变了:同一订单的新事件可能进 7 号分区,而它的旧事件还排在 3 号分区里没消费完。分区内仍然有序,分区之间没人管你——于是消费端看到"发货"先于"支付”。
扩分区没有安全的热办法,实践上三条路:
- 前置足分区 + 接受单分区扩容量(推荐):如上文估算一步到位。Kafka 的吞吐瓶颈通常在 Broker/磁盘/网络而非分区数,前期多给比事后迁移便宜。
- 新建 Topic 迁移:
order-events-v2(新分区数),生产者双写一段时间 → 消费者从 v2 的起点AtStart起新组消费 → 校验后切读、下线 v1。顺序在新 Topic 内重新成立,业务用时间戳或版本号兜住双写窗口内的重复。 - 停写窗口迁移:低峰期停止生产 → 等所有组 Lag 归零 → 扩分区 → 恢复。适合能接受分钟级停写的中小流量;Lag 不归零就不要动分区数。
注意:
kafka-topics.bat --alter --partitions执行成功不代表安全——它不会给你任何"顺序会被打乱"的提示。
# 扩分区命令(三思而后行)
.\bin\windows\kafka-topics.bat --alter --topic order-events \
--bootstrap-server localhost:9092 --partitions 8事故二:重试引入乱序
失败消息进独立 retry topic(10 章的标准做法)时,它绕过了原分区队列,等重试成功再写回业务流,顺序必然错位。解法按强度排序:
- 原分区原队列重试:在消费者内部同步重试 N 次(阻塞该分区进度),适合"顺序比吞吐重要"且失败短暂的场景;
- 重试后回写同一分区:franz 用
ManualPartitioner()+ 把原消息的Partition抄进回写Record,至少不跨分区乱——但分区内它已经插到更晚的位置,中间成功过的消息仍可能被"旧事件迟到"击穿(kafka-go 存量等价写法:Message带Topic/Partition+ 不带 Writer.Topic 的 Writer); - 业务版本号兜底(最稳):消费端状态机(08 章方案③)拒绝"倒着走"的更新——迟到的
created遇到已paid的订单直接丢弃。顺序性问题的终极解法往往不在 Kafka,在业务。
事故三:消费端并发处理
PollRecords 拉到的是有序的,go handle(r) 进去就乱了。组内单分区已是最细的顺序粒度,消费协程模型按需求选:
// 模型 A:分区级并行 —— 每个分区一个 goroutine,分区内严格串行。
// 非组模式就是 03 章的 scan 客户端按分区拆:每分区一个 ConsumePartitions 客户端 + 独立消费循环
for _, p := range []int32{0, 1, 2, 3, 4, 5} {
go consumePartition(p) // 内部各自 PollFetches,只订这一个分区
}
// 组模式下同理可行:OnPartitionsAssigned 回调里为分到的每个分区起 worker,
// 用 ConsumePartitions 精确拉取 + 逐分区 CommitOffsetsSync。
// 模型 B:分区内再按 Key 提并行 —— 单分区流量仍是瓶颈,但同 Key 必须串行:
// 按 hash(key) 把消息投递到固定 N 个 worker channel 之一,同一订单永远落在同一 worker。
idx := int(fnv32(r.Key)) % workerNum
workers[idx] <- r // worker 内串行处理模型 B 处理成功后提交 Offset 要小心:同分区乱序完成、按"最大 Offset"累积提交(06 章的累积确认,CommitRecords 取每分区最大值)会把还没处理完的小 Offset 消息标记成已消费。稳妥做法是把提交粒度退回分区级(等该分区在途消息全部完成再提交),或干脆接受"重启后重复一批”,靠幂等消化。
实战建议
- 顺序是分区属性,不是 Topic 属性——评审设计时只问一句:“需要互斥有序的消息,它们的 Key 会不会路由到同一个分区?”
- Key 用业务实体 ID(订单号/用户 ID),一次建模全程贯彻;中途换 Key 规则(从
user_id切到device_id)等同一次隐性扩分区,照样乱序。 - 混部/迁移场景优先 franz-go:默认 murmur2 与 Java 分区结果逐字节一致,不用像 kafka-go 那样记得手换
Murmur2Balancer——“忘了换 Balancer"这个存量事故类别在 franz 主线上不存在。 - 单分区扛不住 + 又要全局有序:没有银弹。要么接受"分区内有序 + 业务版本号收敛”,要么把全局串行需求外包给一个专职排序组件(本质是回到事故三模型 A 的极端形态:分区数=1)。