《Java 消息中间件·Kafka 内核与客户端》1.2 分区:顺序与并行度的交换

5 条按顺序发出的消息,消费者看到的却是另一种顺序。本节用 3 个分区实测落点 1、2、2、0、1,从 murmur2 哈希与粘性分区讲到同 key 同分区,再把分区数的三笔代价——并行上限、文件与内存、故障恢复——逐条算清楚,并给出一个可操作的分区数估算顺序。

顺序发出,为什么读出来是乱的

先看一个实验。建一个 3 分区的 topic,按 book-0、book-1、book-2、book-3、book-4 的顺序发 5 条消息,每条都带 key。发送循环是严格串行的,上一条 get() 返回之后才发下一条。

然后从最早的位置消费,打印每条记录的分区和位移。本机实测的输出是:

Partition:1	Offset:0	value-book-0
Partition:1	Offset:1	value-book-4
Partition:2	Offset:0	value-book-1
Partition:2	Offset:1	value-book-2
Partition:0	Offset:0	value-book-3

按消费顺序排,是 book-0, book-4, book-1, book-2, book-3。发送顺序是 0,1,2,3,4。如果你期待「发出去什么顺序,读出来什么顺序」,这个结果看起来像 bug。

它不是 bug。它是分区这个设计的直接结果,也是本节要算清楚的那笔交换:你买到了并行度,付出的是顺序范围的收窄。

分区既是并行单位,也是顺序单位

Kafka 的顺序承诺只有一句话:同一个分区内,记录按位移递增的顺序排列;跨分区没有任何顺序承诺。

这句话有两面。

第一面是「顺序单位」。一个分区在物理上是一条追加日志,写入只能往后加,所以 offset=3 一定在 offset=4 之前。消费者读一个分区时,也是按 offset 递增读。只要两条消息进了同一个分区,它们的相对顺序就固定了。

第二面是「并行单位」。不同分区之间没有共享的写入位置,也没有共享的读取位置。三个分区可以同时写、同时读,三个消费者可以各读一个。并行度的上限就是分区数。

把这两面放在一起,就得到分区设计的核心取舍:

你想要的你要付出的
更高的读写并行度更多的分区,顺序只在分区内成立
更强的全局顺序更少的分区,并行度下降
两者都要只能把「需要一起保序」的消息放进同一个 key、同一个分区

这不是 Kafka 特有的。任何「分区日志」都会遇到同一个交换。区别在于 Kafka 把这个交换暴露得很直接:分区数是你建 topic 时定的,key 是你发消息时选的,两者共同决定了顺序边界。

落点是怎么算出来的

先把实验做完。建 topic:

docker exec mq-a1 /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server 127.0.0.1:9092 \
  --create --topic a1.partition-order --partitions 3 --replication-factor 1

--describe 的输出里能看到三个分区、各自的 leader 与 ISR:

Topic: a1.partition-order	TopicId: FqmYo65SSoSzQjHAWsDlUg	PartitionCount: 3	ReplicationFactor: 1	Configs: min.insync.replicas=1
	Topic: a1.partition-order	Partition: 0	Leader: 1	Replicas: 1	Isr: 1	Elr: 	LastKnownElr:
	Topic: a1.partition-order	Partition: 1	Leader: 1	Replicas: 1	Isr: 1	Elr: 	LastKnownElr:
	Topic: a1.partition-order	Partition: 2	Leader: 1	Replicas: 1	Isr: 1	Elr: 	LastKnownElr:

单节点下三个分区的 leader 都是 broker 1,Elr 与 LastKnownElr 为空。这两个字段是 4.x 新增的,4.3 节会解释;这里只要知道它们不影响本节结论。

发送 5 条带 key 的记录,打印 broker 返回的分区与位移:

sent key=book-0 p=1 off=0
sent key=book-1 p=2 off=0
sent key=book-2 p=2 off=1
sent key=book-3 p=0 off=0
sent key=book-4 p=1 off=1

本节涉及的等待时间与耗时都是单次运行,随机器调度波动,不作基准。

落点是 1, 2, 2, 0, 1。为什么是这几个数字?因为默认分区器对非空 key 做的是:

public static int partitionForKey(final byte[] serializedKey, final int numPartitions) {
    return Utils.toPositive(Utils.murmur2(serializedKey)) % numPartitions;
}

这段来自 kafka-clients-4.3.0-sources.jar 里的 BuiltInPartitioner.java,第 329–330 行。它做三件事:对序列化后的 key 字节算 murmur2,把结果转成非负数,再对分区数取模。

用同一个类算出来的中间值如下:

book-0 murmur2=-2108685004 toPositive=38798644 %3=1
book-1 murmur2=-928100454 toPositive=1219383194 %3=2
book-2 murmur2=-1365223455 toPositive=782260193 %3=2
book-3 murmur2=1793899242 toPositive=1793899242 %3=0
book-4 murmur2=-1799749327 toPositive=347734321 %3=1

和实测落点一一对应。这里有两个细节值得记住:

  1. 不是 String.hashCode()。很多人用 key.hashCode() % partitions 估算落点,算出来对不上,就以为分区器有问题。Kafka 用的是 murmur2,且先 & 0x7fffffff 再取模。
  2. 分区数变了,落点会变。同一个 key 在 3 分区和 30 分区下不是「同一个分区号」,只是「同一个函数值对不同的模取余」。扩容分区会打破原有的 key 到分区的映射,这一点在 11.3 节讲分区数成本时会再提。

如果 key 为 null,走的是另一条路。KafkaProducer.partition 在 key 为空时返回 RecordMetadata.UNKNOWN_PARTITION,由累加器里的内置分区器选一个「粘性」分区:

return RecordMetadata.UNKNOWN_PARTITION;

所谓粘性,是指它不会每条消息轮询一个分区,而是在积累到大约一个批次大小之前固定在一个分区,然后才切换。源码 BuiltInPartitioner.updatePartitionInfo 里的判断是累计字节达到 stickyBatchSize 后切换。这样做的目的是让无 key 的消息也能形成大批次,而不是被轮询打散成很多小批次。

这个行为解释了一个常见疑问:「我没设 key,为什么分区还是不均匀?」 因为在任意一个短窗口里,消息本来就集中在少数几个分区;只有把窗口拉长到很多个批次,分布才接近均匀。要严格均匀,得自己控制 key 或分区。

同 key 一定同分区,所以同 key 有序

现在把三件事放在一起看:

  • 同一个 key 经过同一个哈希函数,得到同一个分区号;
  • 同一个分区是一条追加日志;
  • 消费者按 offset 递增读一个分区。

结论就是:同一个 key 的消息,在同一个分区内,按发送顺序被读到。

本机把 book-0 再发三次,值分别是借阅创建、归还、逾期:

sent key=book-0 p=1 off=2 ts=1793247813598
sent key=book-0 p=1 off=3 ts=1793247813630
sent key=book-0 p=1 off=4 ts=1793247813640

三条都落在 p=1,位移连续。从 offset=2 开始读这个分区:

Offset:2	loan-created
Offset:3	loan-returned
Offset:4	loan-overdue

顺序和发送顺序一致。这就是「用 key 换顺序」的具体做法:把需要保序的事件放到同一个 key 下。

对 libraryd-mq 来说,借阅、归还、逾期都应该用 bookId 作 key。同一本书的事件进了同一个分区,业务上「先借后还」的因果顺序才有保障。如果借阅用 bookId、归还用 loanId,两条消息会落到不同分区,消费者看到的先后就可能颠倒。这不是理论风险,而是 1.1 节那 5 条消息已经演示过的现象。

跨分区没有全局序,这不是「通常没事」

上面的消费输出 book-0, book-4, book-1, book-2, book-3 值得再拆一次。它不是随机打乱,而是按分区成组出现的:

  • p=1 的两条:book-0、book-4,组内有序;
  • p=2 的两条:book-1、book-2,组内有序;
  • p=0 的一条:book-3。

组与组之间的先后,取决于客户端这一轮 poll() 先收到哪个分区的 fetch 响应。网络、broker 处理、批次大小都会影响它。所以「全局顺序」不是「偶尔乱」,而是根本没有定义。这一次 p=1 在前,下一次可能 p=0 在前;同一份数据重放,顺序也可能不同。

这对业务的影响可以用一个具体场景说明。假设图书馆系统这样发消息:

producer.send(new ProducerRecord<>("library.loan.created", loanId, createdEvent));
producer.send(new ProducerRecord<>("library.loan.returned", loanId, returnedEvent));

两条都用 loanId 作 key,落同一个分区,顺序有保障。但如果归还事件为了「按书统计」改用 bookId,而借阅事件仍用 loanId,两条就可能落到不同分区,归还先被处理。下游如果没有做「归还前必须有借阅」的校验,就会写出一个不存在的借阅记录。

解决办法不是「让 Kafka 保证全局有序」,而是在建模时决定哪些事件必须同分区。这属于卷三的事件建模范围;本卷只需要把机制说清楚:分区键就是顺序边界,选错了键,后面补不回来。

这里还有一个反直觉的点:增加分区不会让乱序更严重,但会让「原本碰巧同分区」的消息分开。3 分区时两个 key 可能哈希到同一个分区,30 分区时就不一定了。所以扩容分区是一次会改变顺序边界的事件,不能当成纯粹的水平扩容。11.3 节会把这件事展开。

分区数的第一笔代价:并行度有上限

分区数的第一个作用,是决定「一个消费组最多能有多少个消费者同时工作」。

规则很简单:一个分区在同一时刻只能分配给同一个消费组里的一个消费者。所以:

分区数消费者数实际工作空闲
333 个各读一个分区0
363 个各读一个分区3 个
633 个各读两个分区0

这意味着分区数是消费并行度的硬上限。生产端可以靠增加生产者、加大批次提高吞吐;消费端要提高并行度,只能加分区的数量(或者让一个消费者内部用线程池处理,但那不改变「一个分区一条消费线程」的读取模型)。

反过来说,分区数超过实际消费者数太多,也有代价:每个分区都要维护自己的日志、索引、副本状态、拉取状态。下一笔账就是这个。

分区数的第二笔代价:文件、内存与副本

建一个 30 分区的 topic,看容器里的实际文件数:

docker exec mq-a1 /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server 127.0.0.1:9092 \
  --create --topic a1.many-partitions --partitions 30 --replication-factor 1
Created topic a1.many-partitions.

分区目录数:

docker exec mq-a1 sh -c 'ls -d /tmp/kafka-logs/a1.many-partitions-* | wc -l'
30

文件总数:

docker exec mq-a1 sh -c 'find /tmp/kafka-logs/a1.many-partitions-* -type f | wc -l'
150

单个分区的目录内容:

00000000000000000000.index
00000000000000000000.log
00000000000000000000.timeindex
leader-epoch-checkpoint
partition.metadata

也就是说,每个分区至少 5 个文件,其中 3 个是段文件。150 个文件对应 30 个分区,这是刚建好、还没写数据的状态;随着段滚动,每个分区的文件数还会增加。文件描述符、目录项、页缓存元数据都随分区数线性增长。

内存侧同理。每个分区在 broker 上有一份日志对象、索引、副本拉取状态;在客户端有对应的队列与缓冲。分区数翻倍,这些「每分区固定开销」就翻倍。具体每个分区占多少,跟消息大小、段大小、索引大小有关,本书不给一个拍脑袋的数字,而是建议按「分区数 × 每分区基线」估算后,用真实压测校正(12 章)。

副本侧还要再乘一次。30 个分区、3 副本,就是 90 份日志;每份都有自己的段文件和索引。单节点实验里 ReplicationFactor: 1,所以只看到 30 份;生产环境三副本时,文件数是三倍。

分区数的第三笔代价:恢复与再平衡时间

前两笔账是静态的,第三笔是动态的:分区数越多,故障恢复和再平衡越慢。

原因不神秘:

  • leader 选举要处理的分区更多。一个 broker 挂掉,它上面的所有 leader 分区都要重新选,分区越多,选举与元数据变更的批次越多。
  • 消费者再平衡要重新分配的分区更多。每个分区都要走一次分配、同步、拉取启动,分区数上千时,再平衡可能从秒级变成分钟级。
  • 副本追赶要拉取的日志段更多。一个新 broker 加入,要同时追几十上百个分区的日志,磁盘和网络的压力是叠加的。

这三件事都不是线性的。分区数从 30 加到 300,文件数线性增长,但再平衡的协调开销、元数据的传播开销、控制器处理的批次都可能出现非线性。第 11.3 节会用实测数据讨论「上千分区为什么是坏主意」,本节只需要建立一个判断:分区数不是越大越好,它是并行度、顺序范围和运维成本三者的交点。

怎么定分区数

给一个可操作的顺序,而不是一个神奇公式:

  1. 先算目标吞吐。用业务峰值消息速率乘以单条大小,得到字节速率。
  2. 再算单分区可持续吞吐。这个数必须来自你自己的压测,不能抄别人的。用 12.1 节的 kafka-producer-perf-test.sh,在同一批机器上测。
  3. 相除,向上取整,得到「吞吐下限」。
  4. 再算消费并行度上限。你希望最多有多少个消费者同时工作?这个数就是「并行度下限」。
  5. 取两者较大值,再留一点余量,但不要为了「以后可能扩容」一次开到几百个。分区可以 later 增加,但增加会改变 key 的落点,不能随便加。

一个常见的误区是「按消费者数量定分区数」。如果消费者是 10 个,但每个消费者的处理能力只够读 2 个分区,那么 10 个分区里有一半会积压。分区数应该由「吞吐」和「并行度」共同决定,而不是由「现在有几台机器」决定。

还有一个反向误区:「分区多了总能并行」。如果 key 分布严重倾斜,90% 的消息落在一个 key 上,那么加多少分区都没用,热点分区仍然是瓶颈。这种情况下要先解决 key 设计,再谈分区数。

本节判据

试着回答:

  1. 5 条消息按 book-0 到 book-4 的顺序发出,为什么消费者看到的第一条是 book-0,第二条却是 book-4?——因为它们同属 p=1,而 p=1 的两条在同一个 fetch 批次里被连续返回;跨分区之间没有顺序定义。
  2. 如果业务要求「同一本书的借阅、归还、逾期严格有序」,key 应该用什么?——bookId,且三个事件必须用同一个 key。
  3. 30 个分区、3 副本,至少有多少个段文件?——30 × 3 × 3 = 270 个段文件(.log、.index、.timeindex 各一),还没算 checkpoint 和 metadata。
  4. 消费者从 3 个加到 6 个,为什么吞吐可能不变?——分区只有 3 个,多出来的 3 个消费者分不到分区。

下一节把「顺序」换成「交付」,看 at-most-once、at-least-once、exactly-once 三档语义各自要付什么配置成本,以及为什么 exactly-once 只在 Kafka 读写链路内成立。

阅读导航:上一节 1.1 把消息系统看成日志 | 下一节 1.3 交付语义的真实成本

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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