规划要算三笔账
一个 Kafka 集群上线前,至少要回答三个数字:
- 要多少磁盘:写入速率 × 保留期 × 副本数,再加一层膨胀系数;
- 要多少文件描述符:分区数 × 每分区段数 + 连接数 + 线程固定开销;
- 要多少 IO 线程:由磁盘数量决定,不是由 CPU 核数决定。
这三笔账各自独立,任何一笔低估都会在生产上以不同方式爆出来:磁盘不够表现为写入失败或分区离线,fd 不够表现为「新连接建立不了、已有连接还能用」这种诡异现象,IO 线程配错表现为磁盘闲着但吞吐上不去。
这一节按顺序算三遍,每个数字都尽量落到本机的实测输出上。
第一笔账:容量
公式
每日增量(GB) = 事件速率(条/s) × 单条字节数 × 86400 / 10^9
保留总量(TB) = 每日增量 × 保留天数 × 副本数 × 膨胀系数 / 1000
单 broker(GB) = 每日增量 × 保留天数 × 副本数 × 膨胀系数 / broker 数
膨胀系数的经验值是 1.1,覆盖三样东西:
- 索引文件:上限约日志大小的 1%(10 MB 索引对应 1 GB 段);
- 生产者状态快照
.snapshot:每个段一个,很小但存在; - 副本重建、迁移期间的临时双份占用。
本机实测:用 JDK 25 跑出来的估算表
写一个小程序把三档流量算出来,避免手算错位:
public class CapacityPlan {
record Profile(String name, long eventsPerSec, int bytesPerEvent) {}
public static void main(String[] args) {
double retentionDays = 7.0;
int rf = 3;
double overhead = 1.10; // 索引 + 快照 + 元数据的膨胀系数
int brokers = 3;
Profile[] profiles = {
new Profile("libraryd 基线", 200, 1200),
new Profile("libraryd 大促", 1500, 1200),
new Profile("审计流", 5000, 800)
};
System.out.printf("%-14s %8s %10s %12s %12s%n",
"场景", "事件/s", "日增(GB)", "保留总量(TB)", "每broker(GB)");
for (Profile p : profiles) {
double perDayGb = p.eventsPerSec() * (double) p.bytesPerEvent() * 86400 / 1e9;
double totalTb = perDayGb * retentionDays * rf * overhead / 1000.0;
double perBrokerGb = perDayGb * retentionDays * rf * overhead / brokers;
System.out.printf("%-14s %8d %10.1f %12.2f %12.0f%n",
p.name(), p.eventsPerSec(), perDayGb, totalTb, perBrokerGb);
}
}
}
本机实测(Temurin 25.0.5+8 / 2026-10-29,java CapacityPlan.java 直接运行单文件源码):
openjdk 25.0.5 2026-10-20 LTS
场景 事件/s 日增(GB) 保留总量(TB) 每broker(GB)
libraryd 基线 200 20.7 0.48 160
libraryd 大促 1500 155.5 3.59 1198
审计流 5000 345.6 7.98 2661
这张表最值得看的不是绝对值,而是量级差:基线和大促之间是 7.5 倍,审计流再翻一倍多。容量规划如果用平均值打天下,大促当天必然写满。
三档场景都是本书构造的合成数据(libraryd 图书馆系统),不是任何真实公司的线上流量。
从「每 broker GB」到「买多少盘」
算出每 broker 需要 1198 GB,不代表买 1.2 TB 盘就够了。至少还要加三项:
| 项 | 建议 | 原因 |
|---|---|---|
| 水位线 | 用到 70%–80% 就要扩容 | 磁盘写满后 broker 会拒绝写入并可能离线;还要给副本重建留空间 |
| 活跃段索引预分配 | 每个活跃段约 20 MB | .index + .timeindex 各预分配 10 MB(10.2 节实测) |
| 系统与日志 | 50–100 GB | broker 自身日志、堆 dump、系统文件 |
按 75% 水位反推,1198 GB 需要约 1.6 TB 可用空间。三台 broker 各挂两块 1 TB 做 RAID0 或 JBOD,才够。
本机实测:索引预分配到底占多少
10.2 节的 1 MB 段实验里,目录里 15 个文件的分布是:
4 个封口段: 每个 1,034,348 (log) + 536 (index) + 84~468 (timeindex)
1 个活跃段: 425,908 (log) + 10,485,760 (index) + 10,485,756 (timeindex)
活跃段的两份索引加起来 20,971,516 字节,约 20 MB,比活跃段里的业务数据还多。这个比例在 1 MB 段的实验里很夸张,在生产 1 GB 段下就是 2%,可以接受——但它说明一件事:分区数越多,活跃段越多,这 20 MB × 分区数 的固定开销越大。
1000 个分区的集群,光活跃段的索引预分配就是 20 GB。这也是「分区数不是免费」的一个具体体现。
第二笔账:文件描述符
fd 从哪来
Kafka 的常驻 fd 大致分四类:
| 类别 | 数量级 | 说明 |
|---|---|---|
段文件的 .log | 每段 1 个 | 索引走 mmap,映射后不占常驻 fd |
| 网络连接 | 每连接 1 个 | 生产者、消费者、副本同步、控制器 |
| 线程内部 | 每网络线程 2 个左右 | eventpoll + eventfd |
| 目录与其他 | 每日志目录 1 个 | .lock 文件等 |
第一行是本节最反直觉的一条,值得展开:Kafka 只给每个段的 .log 文件保留一个常驻 fd,.index 与 .timeindex 是 mmap 的。我在容器里按主题统计过:
1 .lock
1 __cluster_metadata-0
5 b5.roll-0 ← 5 个段,正好 5 个 fd
1 b5.seqio-0 ← 1 个段,1 个 fd
5 个段就是 5 个 fd,不是 15 个。所以 fd 估算里,每段按 1 个算,不要按 3 个算。
本机实测:一个 12 分区主题增加了多少 fd
ls /proc/1/fd | wc -l # 179(已有 2 个业务主题 + 内部主题)
建一个 12 分区的空主题,等 3 秒再数:
kafka-topics.sh --bootstrap-server 127.0.0.1:9092 \
--create --topic b5.fdtest --partitions 12 --replication-factor 1
sleep 3
ls /proc/1/fd | wc -l # 193
+14。其中 12 个是 12 个分区的 00000000000000000000.log,另外 2 个是本次创建过程中打开后未立即关闭的临时 fd(检查点、目录句柄)。也就是说,空分区的边际成本约 1 个 fd/分区,而不是 3 个。
再往这个主题写 2000 条消息(粘性分区器把它们全放进一个分区),fd 数仍是 193:
写入后 fd_now=193
因为索引是 mmap 的,映射建立后 fd 就被关掉了。这条结论对估算很重要:fd 不随写入量增长,只随段数增长。
fd 释放不是即时的
删主题之后,fd 并不会立刻回来。本机删掉 b5.fdtest 后,日志里出现:
Log for partition b5.fdtest-0 is renamed to /tmp/kafka-logs/b5.fdtest-0.72ce2ed7...-delete
and is scheduled for deletion (kafka.log.LogManager)
目录被重命名成 *-delete,但要等 file.delete.delay.ms(默认 60000,即 60 秒)之后才真正删除。这段时间里,12 个 .log 的 fd 仍然开着。本机采样:
删除后 6 秒: fd = 193,12 个 *-delete 目录
删除后 18 秒: fd = 193,目录仍在
删除后约 73 秒: fd = 181,*-delete 目录数 = 0
回落的 12 个句柄与 12 个分区一一对应,说明这段时间 fd 一直被占着。60 秒是默认值,不是即时释放。
所以「删主题释放 fd」是一个分钟级的动作。批量删主题时,如果 fd 已经接近上限,这个 60 秒窗口足以把集群推到临界点。
本机实测:限制到底是多少
ulimit -n # 1024 (软限制)
ulimit -Hn # 524288 (硬限制)
cat /proc/sys/fs/nr_open # 1048576
cat /proc/sys/fs/file-max# 9223372036854775807
cat /proc/sys/fs/file-nr # 4192 0 9223372036854775807
三行数字要一起读:
- 软限制 1024 是这个容器进程的实际可用上限,硬限制 524288 只是「允许提到多少」。如果 broker 以 1024 运行,1000 个分区就已经到顶了。
nr_open= 1048576 是单个进程理论上能打开的上限。file-max打印的是9223372036854775807(即Long.MAX_VALUE),这是本机内核(6.8)对「无上限」的表达方式,不是真的能开 9×10^18 个文件。不要拿这个数字做规划。file-nr第一列 4192 是当前系统已分配的文件句柄数。它比ls /proc/*/fd加起来大,因为还包括 socket、管道等。
规划上给一个可执行的口径:
fd 需求 ≈ 分区数 × 平均每分区段数 × 1
+ 连接数(生产者 + 消费者 + 副本 + 控制器)
+ 网络线程数 × 2
+ 保留 30% 余量
一个 3000 分区、每分区 20 段、200 个客户端的集群,大约需要 60000 + 200 + 几十个 ≈ 61k,加上余量按 80000 配。所以生产上的 LimitNOFILE 一般直接给 100000,硬限制同值,不要停留在默认的 1024 或 4096。
第三笔账:num.io.threads
本机容器里的生效值:
num.io.threads = 8
num.network.threads = 3
num.recovery.threads.per.data.dir = 2
三个参数的分工:
| 参数 | 默认 | 干什么 | 调大的收益 | 调大的代价 |
|---|---|---|---|---|
num.network.threads | 3 | 收发字节、读写 socket | 连接数很多时有帮助 | 上下文切换增加 |
num.io.threads | 8 | 处理请求(含磁盘读写) | 磁盘多、请求重时必需 | 超过磁盘数后只增加排队 |
num.recovery.threads.per.data.dir | 2 | 启动时并行加载段 | 段多时缩短启动 | 启动时 IO 打满 |
num.io.threads 的定法有一条简单规则:按数据目录所在的物理磁盘数来配,每个数据目录 8 个左右,总量不要超过磁盘数的 2 倍。
原因在于这些线程做的是阻塞 IO。线程数少于磁盘数,有的盘闲着;线程数远多于磁盘数,请求在 mq-deadline 队列里排队,延迟上升而吞吐不变。本机 VM 的调度器就是 mq-deadline:
cat /sys/block/vdb/queue/scheduler
# none [mq-deadline]
mq-deadline 会对请求做排序与合并,给每个请求一个截止时间。对这种调度器,少量并发的大顺序 IO 远好于大量并发的小随机 IO。这也是为什么 Kafka 的追加写与它配合得好。
num.recovery.threads.per.data.dir 容易被忽略:它只在启动时用,但如果一个 broker 有 10 个数据目录、每个目录几千个段,把它从 2 调到 8 能把启动时间缩短几倍。代价是启动瞬间的 IO 峰值更高。
JBOD 与 RAID 的取舍
| 方案 | 容量利用 | 故障域 | 重建成本 | Kafka 适配度 |
|---|---|---|---|---|
| RAID0 | 100% | 任一盘坏,整组数据丢失 | 无(只能换组) | 差:一块盘坏等于多个分区丢 |
| RAID10 | 50% | 允许坏一半 | 复制整盘,小时级 | 中:简单但贵 |
| RAID5/6 | 67%–83% | 允许坏 1–2 块 | 重建时读放大,风险窗口长 | 差:写惩罚 + 重建慢 |
| JBOD | 100% | 坏一块盘只影响落在它上面的分区 | 靠副本重建分区,分钟级 | 好:Kafka 自己就有副本 |
Kafka 官方长期推荐 JBOD + 副本。理由不是 JBOD 更可靠,而是故障单位对齐:
- 一块盘坏,只影响落在它上面的那些分区;这些分区还有其他副本,leader 切换后照常服务。
- 换盘后,Kafka 只需要重建那部分分区,而不是整组 RAID 的镜像。
- 不需要 RAID 卡的写缓存与电池,省掉一层可能出问题的硬件。
代价是运维要接受「有些分区短暂 URP」,并且 log.dirs 要能识别哪块盘坏了——这由 14.1 节的故障图谱处理。
如果一定要用 RAID,RAID10 比 RAID5/6 更合适:Kafka 是写密集 + 大顺序写,RAID5 的写惩罚(每次写要读旧数据、读旧校验、写新数据、写新校验)会直接压低吞吐。
log.dirs 的分配策略
log.dirs 可以配多个目录,Kafka 的分配规则是:
- 新分区创建时,选「当前分区数最少」的目录,平局时轮询;
- 已有分区永远不会自动迁移,除非手工做分区重分配。
这带来两个后果:
- 多个目录只有在不同物理盘上才有意义。同一块盘上分两个目录,只是把 IO 竞争换了个路径,不会提升吞吐。
- 目录之间的分区数可能长期不均。加盘之后,新分区会优先落到新盘,老盘上的历史分区不会动。要均衡必须跑
kafka-reassign-partitions.sh,而这是一次高 IO 的运维动作。
一个常见的错误是把 log.dirs 指到网络存储(NFS)。Kafka 依赖 mmap 与页缓存,网络文件系统的 mmap 语义与本地盘差异很大,延迟也不可控,官方文档明确不建议这么做。
文件系统与挂载参数
几条实践:
- XFS 优于 ext4:段文件大、追加写多,XFS 的 extent 分配与并发写表现更稳。
- 挂载加
noatime:Kafka 不会去读文件的访问时间,记录 atime 只是额外的元数据写。 - 不要用压缩文件系统:透明压缩会让顺序写的可预测性变差,也吃 CPU。
- 磁盘满要提前告警:用到 80% 就要处理,因为副本重建会在短时间内把剩余空间吃掉一大块。
一张规划清单
把三笔账合成一张可执行的清单:
| 项 | 计算方式 | libraryd 大促的例子 |
|---|---|---|
| 日增数据量 | 速率 × 单条大小 × 86400 | 155.5 GB/天 |
| 保留总量 | 日增 × 保留天数 × RF × 1.1 | 3.59 TB |
| 单 broker 容量 | 保留总量 / broker 数 | 1198 GB |
| 采购容量 | 单 broker / 0.75 | 约 1.6 TB |
| 活跃段索引开销 | 分区数 × 20 MB | 300 分区约 6 GB |
| fd 需求 | 分区 × 段 + 连接 + 线程 + 30% | 见下 |
LimitNOFILE | 大于 fd 需求,通常 100000 | 100000 |
num.io.threads | 每数据盘 8 个,总量 ≤ 2× 盘数 | 3 块盘 → 24 |
| 磁盘水位告警 | 80% | 80% |
把这张表填完,才算把「Kafka 要多大机器」回答清楚。只回答「三台 16C64G」而不给磁盘和 fd,等于没回答。
三个常见错误
错误一:按平均流量买盘。 大促是基线的 7.5 倍,磁盘按平均买,活动当天写满。容量要按峰值 + 保留期算,或者接受大促前临时扩容。
错误二:LimitNOFILE 用默认值。 很多发行版的 systemd 默认 LimitNOFILE=1024 或 4096。分区数一多,症状是「老连接能用、新连接建不上」,因为 fd 已经耗尽。这个故障很隐蔽,因为 broker 日志里不一定有明确的「too many open files」。
错误三:把 log.dirs 当成「多目录就能提速」。 同一块盘分成四个目录,吞吐一点不会变;真正的提速来自多块物理盘,以及与之匹配的 num.io.threads。
本节判据
回答四个问题:
- 容量公式里为什么要乘 1.1? 覆盖索引、快照与副本重建的临时占用;索引本身约占日志的 1%。
- 一个段的常驻 fd 是几个? 1 个(只算
.log);.index与.timeindex走 mmap,不占常驻 fd。 - 删主题后 fd 什么时候释放? 默认要等
file.delete.delay.ms=60000,即 60 秒;期间目录改名为*-delete。 num.io.threads按什么配? 按数据目录所在的物理磁盘数,每盘约 8 个,总量不超过磁盘数的 2 倍。
到这里,存储这一章讲完了「写下去、滚起来、恢复、放多大」。下一节离开磁盘,回到网络:一次请求从 socket 到 KafkaRequestHandler 要经过几个线程池,mute/unmute 又是在解决什么问题。
阅读导航:上一节 10.2 段滚动、索引重建与恢复 | 下一节 11.1 网络线程与请求队列
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。