顺序发出,为什么读出来是乱的
先看一个实验。建一个 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
和实测落点一一对应。这里有两个细节值得记住:
- 不是
String.hashCode()。很多人用key.hashCode() % partitions估算落点,算出来对不上,就以为分区器有问题。Kafka 用的是 murmur2,且先& 0x7fffffff再取模。 - 分区数变了,落点会变。同一个 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 节会把这件事展开。
分区数的第一笔代价:并行度有上限
分区数的第一个作用,是决定「一个消费组最多能有多少个消费者同时工作」。
规则很简单:一个分区在同一时刻只能分配给同一个消费组里的一个消费者。所以:
| 分区数 | 消费者数 | 实际工作 | 空闲 |
|---|---|---|---|
| 3 | 3 | 3 个各读一个分区 | 0 |
| 3 | 6 | 3 个各读一个分区 | 3 个 |
| 6 | 3 | 3 个各读两个分区 | 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 节会用实测数据讨论「上千分区为什么是坏主意」,本节只需要建立一个判断:分区数不是越大越好,它是并行度、顺序范围和运维成本三者的交点。
怎么定分区数
给一个可操作的顺序,而不是一个神奇公式:
- 先算目标吞吐。用业务峰值消息速率乘以单条大小,得到字节速率。
- 再算单分区可持续吞吐。这个数必须来自你自己的压测,不能抄别人的。用 12.1 节的
kafka-producer-perf-test.sh,在同一批机器上测。 - 相除,向上取整,得到「吞吐下限」。
- 再算消费并行度上限。你希望最多有多少个消费者同时工作?这个数就是「并行度下限」。
- 取两者较大值,再留一点余量,但不要为了「以后可能扩容」一次开到几百个。分区可以 later 增加,但增加会改变 key 的落点,不能随便加。
一个常见的误区是「按消费者数量定分区数」。如果消费者是 10 个,但每个消费者的处理能力只够读 2 个分区,那么 10 个分区里有一半会积压。分区数应该由「吞吐」和「并行度」共同决定,而不是由「现在有几台机器」决定。
还有一个反向误区:「分区多了总能并行」。如果 key 分布严重倾斜,90% 的消息落在一个 key 上,那么加多少分区都没用,热点分区仍然是瓶颈。这种情况下要先解决 key 设计,再谈分区数。
本节判据
试着回答:
- 5 条消息按
book-0到book-4的顺序发出,为什么消费者看到的第一条是book-0,第二条却是book-4?——因为它们同属 p=1,而 p=1 的两条在同一个 fetch 批次里被连续返回;跨分区之间没有顺序定义。 - 如果业务要求「同一本书的借阅、归还、逾期严格有序」,key 应该用什么?——
bookId,且三个事件必须用同一个 key。 - 30 个分区、3 副本,至少有多少个段文件?——30 × 3 × 3 = 270 个段文件(
.log、.index、.timeindex各一),还没算 checkpoint 和 metadata。 - 消费者从 3 个加到 6 个,为什么吞吐可能不变?——分区只有 3 个,多出来的 3 个消费者分不到分区。
下一节把「顺序」换成「交付」,看 at-most-once、at-least-once、exactly-once 三档语义各自要付什么配置成本,以及为什么 exactly-once 只在 Kafka 读写链路内成立。
阅读导航:上一节 1.1 把消息系统看成日志 | 下一节 1.3 交付语义的真实成本
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。