《Java 消息中间件·可靠消息与消费治理》10.3 重分区与历史数据

分区数是 key 映射公式里的模数,改一次就有近一半的 key 要换分区。本篇用 20 000 个 key 算出 4→8、4→5、8→12 的落点变化比例,再跑一遍 6 000 条、3 000 个 key 的桥接迁移与双写切换,验证顺序不丢、条数对得上,并给出位移重置与回滚的检查清单。

分区数写进了 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比例说明
489 837 / 20 00049.19%新编号模 4 一定等于旧编号,一半留在原编号
81610 044 / 20 00050.22%同上,翻倍场景稳定是一半
4516 034 / 20 00080.17%互质,只有五分之一的 key 碰巧不动
81213 309 / 20 00066.55%最大公约数 4,约三分之一不动
1269 990 / 20 00049.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 的落点:

key4 分区8 分区结果
book-B0p0p0不动
book-B1p0p4搬到新分区
book-B2p0p0不动
book-B3p1p5搬到新分区
book-B4p3p3不动
book-B5p2p6搬到新分区

能看到「不动的 key 仍留在原编号,搬走的 key 全部落到 4–7」。这也解释了为什么迁移不能按 key 顺序做:新旧编号之间没有单调关系,只能逐条重算。

「改分区数」到底改了什么

--alter --partitions 是一次元数据变更,但影响面不止元数据。

  1. 历史消息不动。 旧分区里的段文件原地保留,新分区从位移 0 开始。改完那一刻,主题的「key→分区」关系对历史与未来是两套。
  2. 新消息按新模数走,但有生效延迟。 生产者要等元数据刷新才会看到新分区数。metadata.max.age.ms 默认 300 000(5 分钟),期间元数据也可能因为其他错误被提前刷新。更新之前还在途的消息仍按旧模数落盘,形成一个短暂的双规则窗口。
  3. 同一个 key 的顺序出现断层。 旧分区里的历史消息与新分区里的新消息之间没有任何顺序保证。要保序,必须停写或做迁移切换。
  4. 消费组整体再平衡。 分区数变化会触发组内重新分配,所有成员先撤销再拿回;位移按分区保存,旧分区的已提交位移仍在,新分区没有位移。
  5. 只能增不能减。 缩容不是「不支持配置」,是明确报错:
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)
  1. 不是没有成本。 分区越多,每个 broker 的文件句柄、段文件、副本复制流、元数据体积、再平衡时长都在涨。没有统一的「每 broker 最多多少分区」的数字,要按文件句柄上限、副本流量和再平衡耗时实测。

历史数据的三种处理

方案 A:停写、重建、重放

最稳也最贵:

  1. 停掉所有生产者(或切到只读降级)。
  2. 建新主题,分区数取目标值。
  3. 用桥接程序把旧主题从头读到尾,按原 key 写新主题,直到 lag 为 0。
  4. 校验条数、每 key 顺序、抽样内容。
  5. 切换消费者到新主题,重置位移。
  6. 恢复生产。

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、副本、消费端限制;分区数超过实际并行需求只会增加再平衡与文件开销。

本节判据

  1. 4→8 为什么恰好一半 key 换分区? 答:(h%8)%4 == h%4,新编号小于 4 的留在原编号,大于等于 4 的落到新分区,均匀哈希下各半。
  2. 4→5 为什么换走 80%? 答:4 与 5 互质,只有 1/5 的 hash 值碰巧满足 h%5 == h%4。
  3. --alter --partitions 会搬历史数据吗? 答:不会。只改元数据,旧段留在原分区,新消息按新模数落盘。
  4. 桥接迁移怎么保证同一个 key 的顺序? 答:一个 key 只在一个旧分区里,按 poll 顺序单线程转发;多线程时要按 key 路由到固定线程。
  5. 切换后 lag 为 0 为什么仍可能丢历史? 答:新主题没有已提交位移,auto.offset.reset=latest 会从末尾开始读,历史被跳过且 lag 仍显示 0。

阅读导航:上一节 10.2 热点分区与 key 设计 | 下一节 11.1 消息大小与批量参数

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收