《Java 消息中间件·Kafka 内核与客户端》10.3 磁盘与文件描述符规划

容量、文件描述符、IO 线程是三笔独立的账。本节用 JDK 25 跑出 libraryd 三档流量的容量估算表,并在容器内实测 fd:12 个新分区只增加 14 个句柄,删除主题后句柄要 60 秒才释放,而不少环境默认的软限制只有 1024,远不够 3000 分区的集群使用。

规划要算三笔账

一个 Kafka 集群上线前,至少要回答三个数字:

  1. 要多少磁盘:写入速率 × 保留期 × 副本数,再加一层膨胀系数;
  2. 要多少文件描述符:分区数 × 每分区段数 + 连接数 + 线程固定开销;
  3. 要多少 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 GBbroker 自身日志、堆 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.threads3收发字节、读写 socket连接数很多时有帮助上下文切换增加
num.io.threads8处理请求(含磁盘读写)磁盘多、请求重时必需超过磁盘数后只增加排队
num.recovery.threads.per.data.dir2启动时并行加载段段多时缩短启动启动时 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 适配度
RAID0100%任一盘坏,整组数据丢失无(只能换组)差:一块盘坏等于多个分区丢
RAID1050%允许坏一半复制整盘,小时级中:简单但贵
RAID5/667%–83%允许坏 1–2 块重建时读放大,风险窗口长差:写惩罚 + 重建慢
JBOD100%坏一块盘只影响落在它上面的分区靠副本重建分区,分钟级好:Kafka 自己就有副本

Kafka 官方长期推荐 JBOD + 副本。理由不是 JBOD 更可靠,而是故障单位对齐:

  • 一块盘坏,只影响落在它上面的那些分区;这些分区还有其他副本,leader 切换后照常服务。
  • 换盘后,Kafka 只需要重建那部分分区,而不是整组 RAID 的镜像。
  • 不需要 RAID 卡的写缓存与电池,省掉一层可能出问题的硬件。

代价是运维要接受「有些分区短暂 URP」,并且 log.dirs 要能识别哪块盘坏了——这由 14.1 节的故障图谱处理。

如果一定要用 RAID,RAID10 比 RAID5/6 更合适:Kafka 是写密集 + 大顺序写,RAID5 的写惩罚(每次写要读旧数据、读旧校验、写新数据、写新校验)会直接压低吞吐。

log.dirs 的分配策略

log.dirs 可以配多个目录,Kafka 的分配规则是:

  • 新分区创建时,选「当前分区数最少」的目录,平局时轮询;
  • 已有分区永远不会自动迁移,除非手工做分区重分配。

这带来两个后果:

  1. 多个目录只有在不同物理盘上才有意义。同一块盘上分两个目录,只是把 IO 竞争换了个路径,不会提升吞吐。
  2. 目录之间的分区数可能长期不均。加盘之后,新分区会优先落到新盘,老盘上的历史分区不会动。要均衡必须跑 kafka-reassign-partitions.sh,而这是一次高 IO 的运维动作。

一个常见的错误是把 log.dirs 指到网络存储(NFS)。Kafka 依赖 mmap 与页缓存,网络文件系统的 mmap 语义与本地盘差异很大,延迟也不可控,官方文档明确不建议这么做。

文件系统与挂载参数

几条实践:

  • XFS 优于 ext4:段文件大、追加写多,XFS 的 extent 分配与并发写表现更稳。
  • 挂载加 noatime:Kafka 不会去读文件的访问时间,记录 atime 只是额外的元数据写。
  • 不要用压缩文件系统:透明压缩会让顺序写的可预测性变差,也吃 CPU。
  • 磁盘满要提前告警:用到 80% 就要处理,因为副本重建会在短时间内把剩余空间吃掉一大块。

一张规划清单

把三笔账合成一张可执行的清单:

项计算方式libraryd 大促的例子
日增数据量速率 × 单条大小 × 86400155.5 GB/天
保留总量日增 × 保留天数 × RF × 1.13.59 TB
单 broker 容量保留总量 / broker 数1198 GB
采购容量单 broker / 0.75约 1.6 TB
活跃段索引开销分区数 × 20 MB300 分区约 6 GB
fd 需求分区 × 段 + 连接 + 线程 + 30%见下
LimitNOFILE大于 fd 需求,通常 100000100000
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? 覆盖索引、快照与副本重建的临时占用;索引本身约占日志的 1%。
  2. 一个段的常驻 fd 是几个? 1 个(只算 .log);.index 与 .timeindex 走 mmap,不占常驻 fd。
  3. 删主题后 fd 什么时候释放? 默认要等 file.delete.delay.ms=60000,即 60 秒;期间目录改名为 *-delete。
  4. num.io.threads 按什么配? 按数据目录所在的物理磁盘数,每盘约 8 个,总量不超过磁盘数的 2 倍。

到这里,存储这一章讲完了「写下去、滚起来、恢复、放多大」。下一节离开磁盘,回到网络:一次请求从 socket 到 KafkaRequestHandler 要经过几个线程池,mute/unmute 又是在解决什么问题。

阅读导航:上一节 10.2 段滚动、索引重建与恢复 | 下一节 11.1 网络线程与请求队列

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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