Skip to content
顺序性与分区设计

顺序性与分区设计

Kafka 只保证分区内有序。所以"顺序设计"本质上是"分区路由设计":把需要保序的消息用同一个 Key 钉进同一个分区,再把分区内的串行消费贯彻到底。本章讲 Key 的选法、franz-go 分区器与混部协议差异、分区数的估法,以及三个最常见的乱序事故(扩分区、重试、消费端并发)。

前置阅读:04-生产者(分区路由在发送流程里的位置)、05-消费者与消费者组。


顺序到底在哪个环节成立

一条消息从发出到处理,全链路是这样"排队"的:

相同 Key ──▶ 同一分区 ──▶ 分区内按 Offset 严格追加 ──▶ 同一消费者串行拉取处理

三个环节,任何一个被破坏,顺序就没了:

  1. 路由破坏:相同 Key 被分到了不同分区(换分区器、分区扩容、Key 传了 nil);
  2. 写入破坏:生产者对同一分区并发乱序发(同一 ProduceSync 内部按传入顺序成批,风险主要在多实例/多连接并发写同分区);
  3. 消费破坏:消费者拉到消息后丢进协程池并发处理——拉取有序,处理乱序。

注意: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 号分区里没消费完。分区内仍然有序,分区之间没人管你——于是消费端看到"发货"先于"支付”。

扩分区没有安全的热办法,实践上三条路:

  1. 前置足分区 + 接受单分区扩容量(推荐):如上文估算一步到位。Kafka 的吞吐瓶颈通常在 Broker/磁盘/网络而非分区数,前期多给比事后迁移便宜。
  2. 新建 Topic 迁移:order-events-v2(新分区数),生产者双写一段时间 → 消费者从 v2 的起点 AtStart 起新组消费 → 校验后切读、下线 v1。顺序在新 Topic 内重新成立,业务用时间戳或版本号兜住双写窗口内的重复。
  3. 停写窗口迁移:低峰期停止生产 → 等所有组 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 消息标记成已消费。稳妥做法是把提交粒度退回分区级(等该分区在途消息全部完成再提交),或干脆接受"重启后重复一批”,靠幂等消化。


实战建议

  1. 顺序是分区属性,不是 Topic 属性——评审设计时只问一句:“需要互斥有序的消息,它们的 Key 会不会路由到同一个分区?”
  2. Key 用业务实体 ID(订单号/用户 ID),一次建模全程贯彻;中途换 Key 规则(从 user_id 切到 device_id)等同一次隐性扩分区,照样乱序。
  3. 混部/迁移场景优先 franz-go:默认 murmur2 与 Java 分区结果逐字节一致,不用像 kafka-go 那样记得手换 Murmur2Balancer——“忘了换 Balancer"这个存量事故类别在 franz 主线上不存在。
  4. 单分区扛不住 + 又要全局有序:没有银弹。要么接受"分区内有序 + 业务版本号收敛”,要么把全局串行需求外包给一个专职排序组件(本质是回到事故三模型 A 的极端形态:分区数=1)。