分区既是并行单位,也是成本单位
分区是 Kafka 里最小的并行与顺序单位:
- 并行度:一个分区同一时刻只能被同组的一个消费者处理,所以分区数决定了消费并行度的上限;
- 顺序性:Kafka 只保证分区内有序,跨分区没有全局顺序;
- 放置:副本以分区为单位分配到 broker,leader 也以分区为单位选举。
正因为它是「并行单位」,扩容时最直接的想法就是多加分区。但分区不是免费的。每多一个分区,broker 要多开文件、多占内存、多维护一份元数据;controller 要多记一条状态;消费者组要多算一次分配;生产者与消费者的批次也可能被切得更碎。
这一节不讨论「应该有多少分区」这种没有标准答案的问题,而是把成本逐项量出来,让你在自己的环境下算账。
先把账单分类
| 成本项 | 计量单位 | 本节是否实测 |
|---|---|---|
| 文件描述符 | 每个分区每 broker 约 1 个 | 实测 |
| mmap 区域 | 每个分区每 broker 2 个 | 实测 |
| controller 元数据 | 每分区约 105 字节日志 | 实测 |
| 启动加载时间 | 随分区数超线性增长 | 实测(空日志) |
| leader 选举 | 每次故障影响一批分区 | 未测,见文末 |
| 消费者再平衡 | 分配计算与元数据随分区数增长 | 未测,见文末 |
| 端到端延迟 | 批次变小、请求变多 | 未测,见文末 |
先说清楚「未测」的原因,免得把后面的数字误当成全部答案:本机的两节点 KRaft 集群,两个 voter 的多数派是 2,停掉一个就失去 controller quorum,无法演示不干净选举;消费者再平衡需要单独的消费组实验,属于第 6 章的范围。
实测一:fd 与 mmap 是两笔账
先明确测法。fd 用 /proc/<pid>/fd 数,mmap 用 /proc/<pid>/maps 数,两个口径不能混:
docker exec mq-c1 sh -c 'ls /proc/1/fd | wc -l'
docker exec mq-c1 sh -c 'wc -l < /proc/1/maps'
在 201 个分区和 862 个分区两个时点各取一次快照。这里的「分区数」是 broker 上实际承载的副本数,RF=2 时每个 topic 的每个分区在两个 broker 上各有一份:
| 时点 | broker 上分区数 | fd 总数 | maps 总数 |
|---|---|---|---|
| 快照 A | 201 | 380 | 771 |
| 快照 B | 862 | 1,044 | 2,095 |
| 增量 | +661 | +664 | +1,324 |
| 每分区 | +1 | +1.005 个 fd | +2.003 个 mmap |
两个数字都非常干净:每个分区稳定增加 1 个 fd 和 2 个 mmap 区域。按这个比例外推,可以先把账算出来(基线约 180 个 fd、370 个映射要另加):
| 每 broker 分区数 | .log fd | mmap 区域 | 10 MB 映射的虚拟总量 | 元数据增量估算 |
|---|---|---|---|---|
| 200 | 约 200 | 约 400 | 约 4 GB | 约 21 KB |
| 1,000 | 约 1,000 | 约 2,000 | 约 20 GB | 约 105 KB |
| 10,000 | 约 10,000 | 约 20,000 | 约 200 GB | 约 1 MB |
这张表也提前说明了后文的结论:fd 与映射随分区数线性增长,元数据日志却几乎可以忽略。线性增长不可怕,可怕的是基线之上没有余量。
打开 fd 指向哪里,可以直接看:
$ docker exec mq-c1 sh -c 'ls -l /proc/1/fd | grep -c "c1.p200.*\.log$"'
200
$ docker exec mq-c1 sh -c 'grep -c "c1.p200.*\.index$" /proc/1/maps'
200
$ docker exec mq-c1 sh -c 'grep -c "c1.p200.*\.timeindex$" /proc/1/maps'
200
$ docker exec mq-c1 sh -c 'grep -c "c1.p200.*\.log$" /proc/1/maps'
0
结论是:每个分区的活动 .log 文件占 1 个 fd;.index 与 .timeindex 被 mmap 之后 fd 就关掉了,所以不出现在 /proc/<pid>/fd 里,但会各留一个 mmap 区域。 这就是「fd 是文件与 mmap 的混合」的实际含义。只看 fd 会漏掉一半的地址空间成本;只看 maps 又会把普通文件的 fd 算漏。
索引映射的虚拟大小是 10 MB,来自默认配置:
$ docker exec mq-c1 grep -n 'log.index.size.max.bytes' /opt/kafka/logs/server.log | head -1
334: log.index.size.max.bytes = 10485760
/proc/1/maps 里每条 index/timeindex 映射的长度正好是 0xA00000(10 MB)。注意这是虚拟地址,不是常驻内存;只有真正读写到的页才会占用物理内存。所以「1000 个分区会占 20 GB 内存」的说法是错的,正确说法是「1000 个分区会占 2000 个 10 MB 的虚拟映射,实际常驻取决于访问」。
fd 上限也要看对进程。容器里 docker exec 打开的 shell 显示 ulimit -n = 1024,但 broker 进程自己的限制在 /proc/1/limits:
$ docker exec mq-c1 sh -c 'ulimit -n'
1024
$ docker exec mq-c1 sh -c 'grep "Max open files" /proc/1/limits'
Max open files 524288 524288 files
两个数字不是同一个东西:exec 会话的限制来自镜像默认值,broker 的限制是容器启动时设的。查 fd 上限必须查目标进程,不能查你随手打开的 shell。本机 broker 的上限是 524288,所以 862 个分区(1044 个 fd)还远没到顶;如果换成默认 1024 的裸机部署,同样的分区数已经越线了。
实测二:建 topic 与 describe 的真实耗时
用 CLI 的 time 测建 topic 或 describe 是常见错误:每次调用都要启动一个 JVM,光启动就 1 秒以上。本机用 CLI 做一次 200 分区的 describe,墙钟时间是:
real 1.42
real 1.47
real 1.42
但这 1.4 秒里绝大部分是 JVM 启动和 docker exec 的开销,不是 broker 的响应时间。要测服务端成本,应该在同一个 JVM 里用 AdminClient 连续调用,先预热再计时。
本机实测(Temurin 25.0.5+8 客户端 / Kafka 4.3.0 KRaft 两节点 / 2026-10-29,单次运行,随调度波动,不作基准):
| 操作 | 分区数 | 耗时 |
|---|---|---|
| createTopics | 1 | 48 ms |
| createTopics | 10 | 35 ms |
| createTopics | 50 | 44 ms |
| createTopics | 200 | 44 ms |
| createTopics | 200(第二次) | 51 ms |
| createTopics | 200(第三次) | 41 ms |
| describeTopics(中位数) | 1 | 5.6 ms |
| describeTopics(中位数) | 10 | 5.6 ms |
| describeTopics(中位数) | 50 | 5.4 ms |
| describeTopics(中位数) | 200 | 5.9 ms |
| describeTopics(中位数) | 200(新 topic) | 9.2 ms |
两个反直觉的结果:
- 建 200 个分区和建 1 个分区,controller 侧的提交时间在同一量级,都是几十毫秒。分区分配、元数据写入是批量完成的,不是逐个串行。
- describe 200 个分区也没有明显变慢,中位数仍在 10 ms 以内。响应体只是几百行文本,不是瓶颈。
但「create 返回」不等于「分区立刻可用」。本机第一次建完 200 分区后立即 describe,拿到了:
org.apache.kafka.common.errors.UnknownTopicOrPartitionException:
This server does not host this topic-partition.
等分区在 broker 上真正可见再 describe,两次测量的就绪时间是 11 ms 和 844 ms。也就是说:createTopics 的 future 完成,只代表 controller 提交了元数据;broker 侧的分区创建与元数据传播是异步的。 客户端如果建完立刻生产,可能短暂拿到「topic 不存在」或「分区没有 leader」,需要重试。
这个异步窗口在分区多、broker 忙的时候会更长。844 ms 不是上限,只是本机两次采样中的较大值。
实测三:启动时加载分区日志
broker 启动时要把每个分区的日志目录打开、恢复索引、重放未刷盘的数据。Kafka 的 LogManager 会打印一条总账:
Loaded 261 logs in 780ms
本机在同一台 broker 上,用不同分区数重启,读到三组(单次运行,随调度波动):
| broker 上分区数 | Loaded N logs in | 说明 |
|---|---|---|
| 1 | 32 ms | 一个空 topic |
| 61 | 102 ms | 1 + 10 + 50 分区 |
| 261 | 780 ms | 再加 200 分区 |
从 61 到 261,分区数约 4.3 倍,加载时间约 7.6 倍,呈超线性。原因不难理解:加载不是简单的「打开 N 个文件」,它要为每个分区建索引、读 recovery point、检查 cleaner checkpoint,固定开销会叠加。
必须强调:这三组都是空日志。真实集群的分区里有数据,加载还要读索引、恢复未刷盘的段,时间会显著更长。所以 780 ms 只能当「文件数量本身」的成本下界,不能当生产环境的启动时间。
实测四:controller 元数据约 105 字节/分区
controller 的元数据存放在 __cluster_metadata-0 这个内部 topic 里,本质是一个日志。数它的目录大小:
| 时点 | 分区数 | __cluster_metadata-0 大小 |
|---|---|---|
| 快照 A | 201 | 260 KB |
| 快照 B | 862 | 328 KB |
| 增量 | +661 | +68 KB |
68 KB ÷ 661 ≈ 105 字节/分区。这个数字很小,小到可以下一个明确结论:元数据日志的磁盘占用不是分区数的瓶颈。 1000 个分区的元数据日志也就百 KB 级,1 万个分区也不过 MB 级。
真正的成本不在这块磁盘上,而在:
- controller 内存里的元数据镜像(每个分区、每个副本都是一个对象);
- 元数据变更的传播(每次变更要通知所有 broker 和客户端);
- 每个分区在 broker 上的副本状态机、指标对象、fetcher 状态。
所以「分区多了元数据会爆炸」这句话要修正:爆炸的不是元数据日志大小,而是围绕每个分区建立起来的运行时对象与传播成本。
为什么上千分区会出问题
把上面的数字外推,再加上没有实测的部分,可以得到一张风险清单:
文件描述符。 每个分区每 broker 1 个 .log fd。1000 分区就是 1000 个 fd,加上 JVM、socket、元数据,单 broker 约 1200 个。本机 broker 上限 524288 没问题,但很多系统的默认 ulimit -n 是 1024 或 65536,先撞线的往往是这里。
虚拟地址与页缓存。 每个分区 2 个 10 MB 映射。1000 分区是 2000 个映射、20 GB 虚拟地址;10000 分区是 200 GB 虚拟地址。64 位地址空间够用,但每个映射本身有内核对象开销,页表也会变厚。
启动与恢复。 空日志 261 个要 780 ms;分区里有数据时,每个分区还要读索引、恢复未刷盘的段,时间会更长。本机没有万级分区且带数据的实验,不给具体数字。滚动重启的窗口会随之变长,13.2 节的最小可用计算要把这一段算进去。
leader 选举与分布。 故障时 controller 要为受影响的分区选 leader。分区越多,一轮选举涉及的记录与元数据传播越多。本机还观察到一个相关现象:mq-c2 恢复后,c1.p200 的 200 个 leader 全部留在 broker 1,没有自动迁回。auto.leader.rebalance.enable=true、检查间隔 300 秒是从 server.log 读到的,但在我们的观察窗口内没有发生迁移。分区越多,这种「leadership 堆在一个 broker 上」的放大效应越明显。
消费者再平衡。 分区是分配的单位。分区越多,分配计算、成员收到的元数据、再平衡期间的停顿都越长。协作式再平衡能减少停顿,但不能消除「分区多」带来的分配成本。第 6 章有再平衡的机制与实测方法。
端到端延迟。 同样的总吞吐下,分区越多,每个分区分到的批次越小。批次小意味着压缩率下降、请求数上升、linger.ms 更难等满。生产者的批次是按分区攒的,1000 个分区、每个分区每秒几条消息时,批次根本攒不大。
内存。 每个分区都有副本 fetcher 状态、延迟操作、指标对象。单看一项都不大,乘上几千就不可忽略。JVM 堆只有 384 MB 的本机集群,分区数上去之后 GC 压力会先显现。
分区数与消费者的配比
最硬的一条约束:同一消费组内,一个分区同一时刻只能被一个消费者处理。 所以:
- 消费者数 ≤ 分区数;多出来的消费者会空闲;
- 分区数是消费并行度的上限,不是建议值;
- 想让 20 个消费者都忙,至少要有 20 个分区。
但反过来不成立:分区多不等于消费快。常见的错误配比是「消费者 10 个,分区 1000 个」,结果每个消费者分到 100 个分区,批次变小、拉取请求变多,单条处理可能更慢。
一个可操作的推算顺序:
- 先算目标消费吞吐:每秒多少条、每条处理多久;
- 算单个消费者能处理多少(实测,不要拍脑袋);
- 消费者数 = 目标吞吐 ÷ 单消费者能力;
- 分区数取消费者数的 1 到 2 倍,留出扩容与故障余量;
- 再检查这个分区数在每 broker 上是否落在「几百」的量级,而不是「几万」。
具体的每 broker 分区上限没有统一答案,取决于 fd 上限、堆大小、磁盘数量、消息大小与副本数。可以确定的是:上千分区不是不能跑,而是每个环节的余量都变薄了;上万分区则要求非常克制的运维与非常明确的收益。
还有一点常被忽略:分区数不能解决热点 key。如果 80% 的流量打在同一个 key 上,加分区只会让那个分区更显眼,因为同一个 key 永远进同一个分区。热点要靠拆 key 或业务层分流,不是加分区。
本机没有测到的:选举与再平衡
前面标了「未测」的两项,这里说清楚原因和复现方式。
leader 选举时间。 本机是两节点 KRaft,两个 voter 的多数派是 2。停掉一个节点,controller quorum 就没了,元数据操作会超时,无法完成一轮正常的 leader 选举。docker stop 会走 controlled shutdown,在退出前尝试把 leadership 迁走,所以也测不到「leader 突然消失后选多久」。
要测真正的选举时间,至少需要三个 voter。复现命令的形态是:
# 三节点集群,RF=3
docker pause mq-c2 # 冻结而非优雅关闭
# 轮询 describe,记录 leader 缺失的分区数从 N 回到 0 的时间
预期形态是:docker pause 后先等 broker.session.timeout.ms(默认 9 秒)触发,再由 controller 选举,分区越多,--describe 里 leader 恢复的批次越多、总时间越长。本节不编具体数字。
消费者再平衡时间。 需要建消费组、让成员进出、观察 rebalance 时长与分区分配。方法在第 6 章,本节没有跑。能确定的定性结论是:分区越多,一次全量分配的元数据与计算量越大,再平衡的尾延迟越长。
端到端延迟随分区数的变化。 需要固定总吞吐、只改分区数做对照,本机没有做。定性上,分区越多批次越小,延迟与吞吐都会受影响,但影响幅度依赖消息大小与生产者配置,不能凭感觉给系数。
本节判据
- 一个分区在 broker 上占多少 fd、多少 mmap? 答:1 个
.logfd,.index与.timeindex各 1 个 mmap 区域;fd 与 mmap 要分开数。 - 建 200 个分区为什么只要几十毫秒? 答:controller 批量提交元数据;但分区在 broker 上真正可用是异步的,本机观察到 11 ms 到 844 ms 的就绪窗口。
- 上千分区最先撞到什么? 答:通常是 fd 上限与启动/恢复时间,然后是选举、再平衡与批次变小带来的延迟;元数据日志的磁盘占用反而是最小的一项。
下一章进入压测与容量:官方的 kafka-producer-perf-test.sh 与 kafka-consumer-perf-test.sh 怎么用,在本机这种高负载环境下哪些数字可信、哪些不可信。
阅读导航:上一节 11.2 副本同步的吞吐与延迟 | 下一节 12.1 官方压测工具与方法论
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。