分区数写进了 key 的映射公式
生产者给一条带 key 的记录选分区,走的是同一个公式:
// BuiltInPartitioner.java:329-331
public static int partitionForKey(final byte[] serializedKey, final int numPartitions) {
return Utils.toPositive(Utils.murmur2(serializedKey)) % numPartitions;
}
注意最后一个运算是取模。模数从 4 改成 8,同一个 key 的落点不一定还是原来的编号;改分区数不是「把新分区挂上去」,而是改写了所有 key 的定位规则。
kafka-topics.sh --alter --partitions 只改元数据:已有日志段留在原分区,不会自动搬。改完之后,同一个 key 的旧消息在旧分区、新消息按新模数落在新分区,Kafka 不会替你把两边连起来。
先算清楚多少 key 要搬家
用 20 000 个 book-B<i> 的 key,分别按两种分区数算落点,再比较编号是否相同:
本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29;纯计算,不发消息):
| 从 | 到 | 换了分区的 key | 比例 | 说明 |
|---|---|---|---|---|
| 4 | 8 | 9 837 / 20 000 | 49.19% | 新编号模 4 一定等于旧编号,一半留在原编号 |
| 8 | 16 | 10 044 / 20 000 | 50.22% | 同上,翻倍场景稳定是一半 |
| 4 | 5 | 16 034 / 20 000 | 80.17% | 互质,只有五分之一的 key 碰巧不动 |
| 8 | 12 | 13 309 / 20 000 | 66.55% | 最大公约数 4,约三分之一不动 |
| 12 | 6 | 9 990 / 20 000 | 49.95% | Kafka 不允许缩容,这里只用来说明公式 |
原始输出:
MAP from=4 to=8 keys=20000 changed=9837 ratio=0.4919 moduloStable=20000 example=book-B1: p0 -> p4
MAP from=4 to=5 keys=20000 changed=16034 ratio=0.8017 moduloStable=5001 example=book-B0: p0 -> p2
MAP from=8 to=16 keys=20000 changed=10044 ratio=0.5022 moduloStable=20000 example=book-B3: p5 -> p13
MAP from=8 to=12 keys=20000 changed=13309 ratio=0.6655 moduloStable=10062 example=book-B0: p0 -> p8
MAP from=12 to=6 keys=20000 changed=9990 ratio=0.4995 moduloStable=10010 example=book-B0: p8 -> p2
规律可以写成一句话:不动的比例约等于 gcd(旧, 新) / max(旧, 新)。
- 4→8:
gcd=4,不动 4/8 = 50%,换走一半。 - 4→5:
gcd=1,不动 1/5 = 20%,换走 80%。 - 8→12:
gcd=4,不动 4/12 = 33%,换走 66.7%。 - 12→6:
gcd=6,不动 6/12 = 50%。
翻倍是最「便宜」的改法,仍然有一半 key 换分区。想让大多数 key 不动,只能选旧分区数的整数倍,而且新分区数越大,不动的那部分比例越低(8→24 是 33%,8→16 是 50%)。
为什么 4→8 恰好是一半
对任意 hash 值 h,h % 8 一定满足 (h % 8) % 4 == h % 4。也就是说,新分区编号对 4 取模,必然回到旧编号。
h % 8 < 4:新编号等于旧编号,没换。h % 8 >= 4:新编号落在新挂上去的 4 个分区里,换走了。
均匀哈希下两种各占一半,实测 49.19% 与理论值吻合。前 6 个 key 的落点:
| key | 4 分区 | 8 分区 | 结果 |
|---|---|---|---|
book-B0 | p0 | p0 | 不动 |
book-B1 | p0 | p4 | 搬到新分区 |
book-B2 | p0 | p0 | 不动 |
book-B3 | p1 | p5 | 搬到新分区 |
book-B4 | p3 | p3 | 不动 |
book-B5 | p2 | p6 | 搬到新分区 |
能看到「不动的 key 仍留在原编号,搬走的 key 全部落到 4–7」。这也解释了为什么迁移不能按 key 顺序做:新旧编号之间没有单调关系,只能逐条重算。
「改分区数」到底改了什么
--alter --partitions 是一次元数据变更,但影响面不止元数据。
- 历史消息不动。 旧分区里的段文件原地保留,新分区从位移 0 开始。改完那一刻,主题的「key→分区」关系对历史与未来是两套。
- 新消息按新模数走,但有生效延迟。 生产者要等元数据刷新才会看到新分区数。
metadata.max.age.ms默认 300 000(5 分钟),期间元数据也可能因为其他错误被提前刷新。更新之前还在途的消息仍按旧模数落盘,形成一个短暂的双规则窗口。 - 同一个 key 的顺序出现断层。 旧分区里的历史消息与新分区里的新消息之间没有任何顺序保证。要保序,必须停写或做迁移切换。
- 消费组整体再平衡。 分区数变化会触发组内重新分配,所有成员先撤销再拿回;位移按分区保存,旧分区的已提交位移仍在,新分区没有位移。
- 只能增不能减。 缩容不是「不支持配置」,是明确报错:
Error while executing topic command : The topic v2c10.shrink currently has 8 partition(s); 4 would not be an increase.
[2026-10-29 06:09:19,547] ERROR org.apache.kafka.common.errors.InvalidPartitionsException: The topic v2c10.shrink currently has 8 partition(s); 4 would not be an increase.
(org.apache.kafka.tools.TopicCommand)
- 不是没有成本。 分区越多,每个 broker 的文件句柄、段文件、副本复制流、元数据体积、再平衡时长都在涨。没有统一的「每 broker 最多多少分区」的数字,要按文件句柄上限、副本流量和再平衡耗时实测。
历史数据的三种处理
方案 A:停写、重建、重放
最稳也最贵:
- 停掉所有生产者(或切到只读降级)。
- 建新主题,分区数取目标值。
- 用桥接程序把旧主题从头读到尾,按原 key 写新主题,直到 lag 为 0。
- 校验条数、每 key 顺序、抽样内容。
- 切换消费者到新主题,重置位移。
- 恢复生产。
RTO 约等于「重放耗时 + 校验耗时」,期间业务停写。重放必须保序:一个 key 只在一个旧分区里,单线程按 poll 顺序转发就能保持该 key 的顺序;如果多线程转发,要按 key 路由到固定线程。
方案 B:双写、校验、切换
不停机的常见做法是让生产者在切换窗口内同时写新旧两个主题:
// 双写骨架:同一条业务事件写两个主题
producer.send(new ProducerRecord<>(OLD_TOPIC, key, event));
producer.send(new ProducerRecord<>(NEW_TOPIC, key, event));
本机实测(同上环境;单次运行,不作基准):
DUALWRITE keys=1500 oldRecords=3000 newRecords=3000 ms=105
DUALWRITE orderViolationsOld=0 orderViolationsNew=0
1 500 个 key、每个 2 条事件,两个主题各 3 000 条,逐 key 检查事件序号没有逆序。
双写的坑在「不原子」:两次 send 不是事务,一边成功一边失败就会造成两个主题不一致。要么接受短暂不一致并用对账兜底,要么把双写放进一个 Kafka 事务(只适用于两个主题都在 Kafka 内),要么用 7.1 节的 outbox 保证「至少写一次」。此外,双写期间的重复消息要靠消费端幂等吸收。
方案 C:桥接迁移,再切读
先建新主题,用桥接程序回放历史,同时生产端继续写旧主题;等历史追平后,把生产切到新主题,消费端短暂双读或按时间点切换。本机把这条路径完整跑了一遍:
MIGRATED records=6000 keys=3000 migrateMs=555
VERIFY newRecords=6000 keys=3000 changedPartition=1479 ratio=0.4930
VERIFY orderViolations=0 keysWithBothEvents=3000
SAMPLE book-B0 old=p0 new=p0
STABLE sameKeyAlwaysSameNewPartition=true
解读:
- 3 000 个 key、6 000 条记录,桥接耗时 555 ms(本地单 broker,单次运行,不作基准)。
- 1 479 个 key 换了分区,占 49.30%,与前面纯计算的 49.19% 吻合。
- 新主题 6 000 条,每个 key 的两条事件都在,逐 key 检查 0 次顺序违规。
- 同一个 key 在新主题里始终落同一个分区(
STABLE=true),迁移结果可复现。
桥接程序的核心只有两步:从旧主题读到记录,用同一个 key 写到新主题。骨架如下:
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps);
KafkaProducer<String, String> producer = new KafkaProducer<>(producerProps)) {
consumer.assign(oldPartitions); // 手工分配,不加入消费组
consumer.seekToBeginning(oldPartitions);
long total = oldPartitions.stream()
.mapToLong(tp -> consumer.endOffsets(List.of(tp)).get(tp)).sum();
long migrated = 0;
while (migrated < total) {
for (ConsumerRecord<String, String> r : consumer.poll(Duration.ofMillis(300))) {
// 同一个 key:value 原样转发,落点由新主题的分区数决定
producer.send(new ProducerRecord<>(NEW_TOPIC, r.key(), r.value()));
migrated++;
}
producer.flush(); // 小批量 flush,便于观察进度
}
}
真正要小心的是迁移期间还有新写入:如果生产端不停,桥接追平后又会落后。要么先停写,要么让生产端在迁移窗口内双写。
三种方案怎么选:
| 方案 | 停机 | 一致性 | 适用 |
|---|---|---|---|
| A 停写重放 | 有,等于重放时长 | 最简单,易验证 | 允许停机的小主题、一次性切换 |
| B 双写 | 无 | 依赖消费端幂等与对账 | 不能停写、两个主题都能写 |
| C 桥接 + 切读 | 无或很短 | 需要处理迁移窗写入 | 历史数据大、希望渐进切换 |
一次重分区的操作顺序
把上面的取舍落成命令,顺序大致如下(容器内执行,先 unset JMX_PORT):
# 1. 记录改前状态:分区数、每个分区的末端位移
/opt/kafka/bin/kafka-topics.sh --bootstrap-server 127.0.0.1:9092 \
--describe --topic library.loan.created
/opt/kafka/bin/kafka-get-offsets.sh --bootstrap-server 127.0.0.1:9092 \
--topic library.loan.created
# 2. 只增不减;4 改 8
/opt/kafka/bin/kafka-topics.sh --bootstrap-server 127.0.0.1:9092 \
--alter --topic library.loan.created --partitions 8
# 3. 确认新分区挂上,旧分区段文件仍在
/opt/kafka/bin/kafka-topics.sh --bootstrap-server 127.0.0.1:9092 \
--describe --topic library.loan.created
# 4. 桥接历史数据到新主题(或用双写切换),再校验条数与每 key 顺序
# 5. 消费组位移先 dry-run,再执行
/opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server 127.0.0.1:9092 \
--group libraryd-loan --topic library.loan.created \
--reset-offsets --to-earliest --dry-run
第 3 步之后主题就已经「改完了」,但数据并没有搬。把「改元数据」和「迁数据」分成两个独立步骤,是重分区最容易出错的地方:很多人看到 --describe 显示 8 个分区,就以为迁移结束了。
切换时最容易漏的两件事
消费端位移要重置
新主题是独立的一套分区与位移,消费者组在新主题上没有已提交位移。默认策略 auto.offset.reset 如果是 latest,切换后从最新开始读,历史消息会被静默跳过,而且 lag 显示为 0,看不出问题。
切换前显式重置:
docker exec mq-ord-lab sh -c 'unset JMX_PORT; /opt/kafka/bin/kafka-consumer-groups.sh \
--bootstrap-server 127.0.0.1:9092 --group libraryd-loan \
--topic v2c10.rep.new --reset-offsets --to-earliest --dry-run'
先 --dry-run 看计划,确认后去掉该参数执行。要按时间点切换就用 --to-datetime 2026-10-29T00:00:00.000。
旧主题要留够回滚窗口
切换后旧主题不能立刻删。至少要覆盖「发现问题并切回」的时间窗,并确认旧主题的保留期没有把切换点的数据清掉。回滚的代价是:切换窗口内的新消息只在新主题里,切回旧主题会丢这一段,所以要提前决定「回滚时这一段怎么补」。
迁移验收清单
| 检查项 | 判据 | 命令或方法 |
|---|---|---|
| 条数一致 | 旧主题总条数 = 新主题总条数 | 两边 kafka-get-offsets.sh 求和 |
| 每 key 顺序 | 每个 key 的事件序号不逆序 | 桥接程序或校验程序逐 key 检查 |
| 落点稳定 | 同一 key 多次计算落点相同 | 用 partitionForKey 复算 |
| 消费 lag | 切换后 lag 归零且不再增长 | kafka-consumer-groups.sh --describe |
| 位移已重置 | 新组的 committed offset 符合预期 | --reset-offsets --dry-run |
| 回滚窗口 | 旧主题保留期覆盖回滚时间 | kafka-configs.sh --describe --entity-type topics |
条数与顺序都通过,仍然要抽几条业务上可核对的消息(比如某个 bookId 的借出与归还)比对内容,防止序列化或字段映射在桥接时被改坏。
什么时候不该改分区数
- 热点来自单个 key。 10.2 节已经量过:单 key 永远落一个分区,加分区只是换一个分区热。
- 只想提高消费并行度,但 key 很集中。 消费者最多按分区数并行,key 集中时新分区没有消息可分。
- 主题很小、消费者很少。 分区带来的文件与元数据开销大于收益。
- 迁移成本高于收益。 一次 4→8 的重分区要搬一半 key;如果只是为了把某个消费者从 3 个分区摊到 4 个,先看看 4.2 节的分配策略和消费端调优是否够用。
替代手段:把热点业务拆成独立主题、提高单分区吞吐(批次、压缩)、优化消费端处理速度。这些都比改分区数便宜,而且不动 key 的映射。
常见误解
- 「改分区数会把历史数据自动重分布」:错。
--alter只改元数据,历史段不动。 - 「分区数翻倍,key 的落点只是多一个偏移」:错。一半 key 会换到新分区,顺序窗口因此断开。
- 「改完立刻生效」:生产者要等元数据刷新;在途消息可能仍按旧模数落盘。
- 「新主题从最新开始读就行」:错。
latest会跳过历史,lag 还是 0,最难发现。 - 「分区越多吞吐越高」:错。吞吐受 broker、副本、消费端限制;分区数超过实际并行需求只会增加再平衡与文件开销。
本节判据
- 4→8 为什么恰好一半 key 换分区? 答:
(h%8)%4 == h%4,新编号小于 4 的留在原编号,大于等于 4 的落到新分区,均匀哈希下各半。 - 4→5 为什么换走 80%? 答:4 与 5 互质,只有 1/5 的 hash 值碰巧满足
h%5 == h%4。 --alter --partitions会搬历史数据吗? 答:不会。只改元数据,旧段留在原分区,新消息按新模数落盘。- 桥接迁移怎么保证同一个 key 的顺序? 答:一个 key 只在一个旧分区里,按 poll 顺序单线程转发;多线程时要按 key 路由到固定线程。
- 切换后 lag 为 0 为什么仍可能丢历史? 答:新主题没有已提交位移,
auto.offset.reset=latest会从末尾开始读,历史被跳过且 lag 仍显示 0。
阅读导航:上一节 10.2 热点分区与 key 设计 | 下一节 11.1 消息大小与批量参数
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。