《Java 消息中间件·Kafka 内核与客户端》12.3 容量规划

容量规划的输入是业务事件率,不是 Kafka 参数。本节用 libraryd 六个事件主题的假设流量,把一个 Java 程序跑出磁盘、分区、内存、网络四笔账,并实测了 retention.bytes 与 retention.ms 同时设置时谁先触发,以及为什么磁盘占用会超出 retention.bytes 这个「上限」。

规划从哪一头开始

大多数容量规划是这样开始的:先看别人写的「每分区 10 MB/s」,再猜一个分区数,然后乘上保留天数。

这个顺序是反的。分区的吞吐上限取决于磁盘、页缓存、副本数、消息大小、压缩比和消费并行度,是一个结果,不是输入。真正的输入只有三样:

  1. 业务每秒产生多少事件(以及峰值是均值的几倍);
  2. 一条事件平均多少字节;
  3. 这些事件要留多久、留几份。

这三样定了,磁盘、分区、内存、网络都能推出来。

这一节用本书贯穿项目 libraryd-mq 的六个主题做一遍完整推算。先明确声明:下面的每秒事件数是本书构造的假设流量,不是任何真实系统的数据;单分区吞吐、记录开销、峰值系数也都是假设。 结论只说明推算方法,不构成对本机或任何生产集群能力的断言。

libraryd 的假设流量

主题日均事件/秒平均消息大小峰值系数
library.loan.created35620 B3×
library.loan.returned32560 B3×
library.loan.overdue4480 B3×
library.hold.placed18520 B3×
library.hold.fulfilled12540 B3×
library.inventory.adjusted220300 B3×

几个建模决定值得说明,因为它们比数字本身更重要:

日均事件数不是峰值。 图书馆业务有明显的日周期:开馆时段借还集中,凌晨几乎为零。用日均值直接算磁盘是对的(磁盘看总量),但算分区数必须乘峰值系数,否则高峰会打满。这里取 3 倍,实际应该从历史曲线取 p99 的分钟级速率。

平均消息大小要含 key。 表里给的是「value + key」的平均字节数;loan.* 主题的 key 是 bookId,约 12 字节,已经含在里面。

库存调整是典型的「小消息、大总量」。 220 条每秒只有借阅事件的六倍,但 300 字节乘回去,它每天产生 20,121.5 MB,比其余五个主题加起来还多。按条数排序和按字节排序必须都算:这个例子里两者同向,换个业务就可能反向。

磁盘账

单个主题在保留期内的磁盘占用:

磁盘 = 日均事件数 × 86400 × (消息大小 + 记录开销) × 副本数 × 保留天数

三个因子容易被漏掉。记录开销(约 70 字节/条)指批次头、记录头、时间戳、CRC:批次越大摊得越薄,规划时按最坏情况取固定值更安全。副本数直接乘进公式,RF=3 就是三份。保留期按 retention.ms 算;如果同时配了 retention.bytes,取两者中先触发的那个。

下面这个程序把六主题的账一次算完,只依赖 JDK 标准库:

import java.util.List;

/** libraryd-mq 六主题容量估算:全部基于假设流量,不是本机实测吞吐。 */
public class CapacityPlan {

    record Topic(String name, double avgEps, int avgBytes) {}

    static final long DAY = 86_400L;
    static final double PEAK_FACTOR = 3.0;
    static final int RF = 3, BROKERS = 3, RETAIN_DAYS = 7, OVERHEAD = 70, CONSUMERS = 6;
    static final double PARTITION_MBPS = 2.0, HEADROOM = 1.3;

    static final List<Topic> TOPICS = List.of(
        new Topic("library.loan.created",        35, 620),
        new Topic("library.loan.returned",       32, 560),
        new Topic("library.loan.overdue",         4, 480),
        new Topic("library.hold.placed",         18, 520),
        new Topic("library.hold.fulfilled",      12, 540),
        new Topic("library.inventory.adjusted", 220, 300)
    );

    static String mb(long b) { return String.format("%.1f", b / 1048576.0); }
    static String gb(long b) { return String.format("%.2f", b / 1073741824.0); }

    static long dailyBytes(Topic t, double growth) {
        return (long) (t.avgEps() * growth * DAY) * (t.avgBytes() + OVERHEAD) * RF;
    }

    static double peakMBps(Topic t, double growth) {
        return t.avgEps() * growth * PEAK_FACTOR * (t.avgBytes() + OVERHEAD) / 1048576.0;
    }

    static int partitions(Topic t, double growth) {
        int byThroughput = (int) Math.ceil(peakMBps(t, growth) / PARTITION_MBPS);
        return Math.max(CONSUMERS, ((byThroughput + CONSUMERS - 1) / CONSUMERS) * CONSUMERS);
    }

    public static void main(String[] args) {
        System.out.printf("== 1. 每主题:假设事件率、消息大小、日增与保留期占用 ==%n");
        System.out.printf("%-30s %7s %6s %8s %12s %11s%n",
                "topic", "eps", "bytes", "peak eps", "日增(RF=3)", "7天(RF=3)");
        long totalDaily = 0;
        for (Topic t : TOPICS) {
            long d = dailyBytes(t, 1.0);
            totalDaily += d;
            System.out.printf("%-30s %7.0f %6d %8.0f %11sMB %10sGB%n",
                    t.name(), t.avgEps(), t.avgBytes(), t.avgEps() * PEAK_FACTOR, mb(d), gb(d * RETAIN_DAYS));
        }
        long total7d = totalDaily * RETAIN_DAYS;
        System.out.printf("%-30s %7s %6s %8s %11sMB %10sGB%n", "TOTAL", "", "", "", mb(totalDaily), gb(total7d));

        System.out.printf("%n== 2. 磁盘账:每 broker 与含余量 ==%n");
        long perBroker = total7d / BROKERS;
        System.out.printf("集群 7 天总量          : %s GB%n", gb(total7d));
        System.out.printf("每 broker(3 台均分)  : %s GB%n", gb(perBroker));
        System.out.printf("含 30%% 余量后每 broker : %s GB%n", gb((long) (perBroker * HEADROOM)));

        System.out.printf("%n== 3. 分区账:峰值吞吐 / 单分区 2 MB/s,向上取整到消费者数的倍数 ==%n");
        System.out.printf("%-30s %11s %10s %8s %10s%n", "topic", "peak MB/s", "吞吐所需", "最终", "约束来自");
        int totalParts = 0;
        for (Topic t : TOPICS) {
            int byT = (int) Math.ceil(peakMBps(t, 1.0) / PARTITION_MBPS);
            int p = partitions(t, 1.0);
            totalParts += p;
            System.out.printf("%-30s %11.2f %10d %8d %10s%n",
                    t.name(), peakMBps(t, 1.0), byT, p, byT > CONSUMERS ? "吞吐" : "消费并行度");
        }
        System.out.printf("六主题分区合计 %d,每 broker 平均 %d 个 leader 分区%n", totalParts, totalParts / BROKERS);

        System.out.printf("%n== 4. 网络账:峰值入向、复制出向、消费出向 ==%n");
        double peakIn = 0;
        for (Topic t : TOPICS) peakIn += peakMBps(t, 1.0);
        double perBrokerNet = (peakIn * RF + peakIn) / BROKERS;
        System.out.printf("峰值生产入向        : %.2f MB/s(%.0f Mbps)%n", peakIn, peakIn * 8);
        System.out.printf("副本复制出向        : %.2f MB/s(RF-1 = %d 份)%n", peakIn * (RF - 1), RF - 1);
        System.out.printf("消费出向(假设实时): %.2f MB/s%n", peakIn);
        System.out.printf("每 broker 合计      : %.2f MB/s ≈ %.0f Mbps%n", perBrokerNet, perBrokerNet * 8);

        System.out.printf("%n== 5. 内存账:页缓存窗口 ==%n");
        double hot6h = peakIn * 3600 * 6 / 1024;
        System.out.printf("峰值 6 小时写入量   : %.1f GB%n", hot6h);
        System.out.printf("每 broker 页缓存建议: %.1f GB(读热尾 + 吸收写脏页)%n", hot6h / BROKERS);

        System.out.printf("%n== 6. 敏感性:流量涨 5 倍 / 10 倍 ==%n");
        System.out.printf("%6s %12s %14s %8s %10s%n", "增长", "集群 7 天", "每 broker+30%", "分区数", "约束转为");
        for (double g : new double[]{1.0, 5.0, 10.0}) {
            long t7 = 0;
            int parts = 0;
            boolean throughputBound = false;
            for (Topic t : TOPICS) {
                t7 += dailyBytes(t, g) * RETAIN_DAYS;
                parts += partitions(t, g);
                if (Math.ceil(peakMBps(t, g) / PARTITION_MBPS) > CONSUMERS) throughputBound = true;
            }
            System.out.printf("%5.0fx %11sGB %13sGB %8d %10s%n",
                    g, gb(t7), gb((long) (t7 / BROKERS * HEADROOM)), parts, throughputBound ? "吞吐" : "消费并行度");
        }
        System.out.println();
        System.out.println("注:单分区 2 MB/s、记录开销 70 B、峰值系数 3 均为假设;磁盘与分区随假设线性变化。");
    }
}

编译运行:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
$JAVA_HOME/bin/java CapacityPlan.java

输出

本机实测(Temurin 25.0.5+8 / 2026-10-29,单文件源码启动,无 Kafka 参与):

== 1. 每主题:假设事件率、消息大小、日增与保留期占用 ==
topic                              eps  bytes peak eps     日增(RF=3)    7天(RF=3)
library.loan.created                35    620      105      5969.7MB      40.81GB
library.loan.returned               32    560       96      4983.4MB      34.07GB
library.loan.overdue                 4    480       12       543.8MB       3.72GB
library.hold.placed                 18    520       54      2625.2MB      17.95GB
library.hold.fulfilled              12    540       36      1809.4MB      12.37GB
library.inventory.adjusted         220    300      660     20121.5MB     137.55GB
TOTAL                                                      36053.0MB     246.46GB

== 2. 磁盘账:每 broker 与含余量 ==
集群 7 天总量          : 246.46 GB
每 broker(3 台均分)  : 82.15 GB
含 30% 余量后每 broker : 106.80 GB

== 3. 分区账:峰值吞吐 / 单分区 2 MB/s,向上取整到消费者数的倍数 ==
topic                            peak MB/s       吞吐所需       最终       约束来自
library.loan.created                  0.07          1        6      消费并行度
library.loan.returned                 0.06          1        6      消费并行度
library.loan.overdue                  0.01          1        6      消费并行度
library.hold.placed                   0.03          1        6      消费并行度
library.hold.fulfilled                0.02          1        6      消费并行度
library.inventory.adjusted            0.23          1        6      消费并行度
六主题分区合计 36,每 broker 平均 12 个 leader 分区

== 4. 网络账:峰值入向、复制出向、消费出向 ==
峰值生产入向        : 0.42 MB/s(3 Mbps)
副本复制出向        : 0.83 MB/s(RF-1 = 2 份)
消费出向(假设实时): 0.42 MB/s
每 broker 合计      : 0.56 MB/s ≈ 4 Mbps

== 5. 内存账:页缓存窗口 ==
峰值 6 小时写入量   : 8.8 GB
每 broker 页缓存建议: 2.9 GB(读热尾 + 吸收写脏页)

== 6. 敏感性:流量涨 5 倍 / 10 倍 ==
    增长       集群 7 天   每 broker+30%      分区数       约束转为
    1x      246.46GB        106.80GB       36      消费并行度
    5x     1232.28GB        533.99GB       36      消费并行度
   10x     2464.56GB       1067.98GB       36      消费并行度

注:单分区 2 MB/s、记录开销 70 B、峰值系数 3 均为假设;磁盘与分区随假设线性变化。

这不是本机实测的吞吐,是一次纯算术推算。 唯一经过本机执行的是这段计算本身(JDK 25 跑通,输出如上)。

读这张表:三个反直觉的结论

一、磁盘被库存主题吃掉一半

library.inventory.adjusted 每天只有 220 条每秒,却占了 7 天总量的 137.55 / 246.46 ≈ 56%。借阅相关的五个主题加起来才 109 GB。

如果按「主题重要性」分配磁盘,库存调整大概排最后;按字节算,它是第一位。规划时按主题列表拍脑袋分配配额,很容易把大头留给错的对象。

二、分区数完全由消费并行度决定

单分区吞吐按 2 MB/s 保守估,六个主题的峰值吞吐都在 0.01 到 0.23 MB/s 之间,一个分区就够。最终每个主题 6 个分区,全部来自「消费并行度」这一条约束。

程序还算了一个倍数:要让吞吐成为约束,library.inventory.adjusted 的流量要涨 51.5 倍,其余主题要涨几百到近两千倍。

这不是说分区数可以随便定。它的意思是:在这个量级上,分区数的意义是「留给消费者几个线程」,不是「扛多少吞吐」。 11.3 节讲过分区数的非线性成本(fd、内存、选举时间),既然用不到吞吐,就没有理由开更多分区。

一条经验:分区数 = max(消费并行度, ⌈峰值吞吐 / 单分区吞吐⌉),然后向上取整到消费并行度的倍数——取整是为了让每个消费者分到的分区数尽量均匀,避免再平衡后有人 5 个有人 6 个。

三、网络根本不是瓶颈

峰值入向 0.42 MB/s,加上副本复制与消费出向,每 broker 约 0.56 MB/s,不到 5 Mbps。千兆网卡的千分之一。

于是「Kafka 很吃网络」这句话在这种量级上完全不成立。容量评估里最贵的资源是磁盘和人的运维注意力,不是带宽。等到单 broker 峰值超过 200 MB/s 再谈网卡选型。

保留期:两个上限谁先触发

磁盘公式里的「保留天数」对应 retention.ms。很多团队还会同时配 retention.bytes 兜底:怕流量暴涨把盘写满。

两个都配时,行为是什么?很多人以为是「先到先删」这种模糊说法,实际规则很硬:每轮清理时,两条规则各自独立计算自己该删哪些段,结果取并集。任何一个条件被满足,对应的段就会被删。

源码层面可以直接看到。kafka-storage-4.3.0.jar 里 UnifiedLog.deleteOldSegments() 的字节码是三个方法返回值相加:

invokevirtual  deleteLogStartOffsetBreachedSegments:()I
invokevirtual  deleteRetentionSizeBreachedSegments:()I
iadd
invokevirtual  deleteRetentionMsBreachedSegments:()I
iadd

deleteRetentionMsBreachedSegments 判定时取的是 LogSegment.largestTimestamp()——段内最大的记录时间戳,不是文件修改时间。所以把一个旧段拷进目录不会让它被保留,把系统时间调快也不会立刻清空数据。

建两个 topic,都同时设置两个上限,只让其中一个先被突破。

K="docker exec mq-c3 /opt/kafka/bin/kafka-topics.sh --bootstrap-server 127.0.0.1:9092"
$K --create --topic c3.ret-bytes --partitions 1 --replication-factor 1 \
  --config segment.bytes=1048576 --config retention.bytes=4194304 --config retention.ms=86400000
$K --create --topic c3.ret-ms --partitions 1 --replication-factor 1 \
  --config segment.bytes=1048576 --config retention.bytes=1073741824 --config retention.ms=60000

各写入 6 万条 512 字节记录(约 30.7 MB 载荷),清理前共 30 个段:

c3.ret-bytes-0  files= 30  sum= 31319280
c3.ret-ms-0     files= 31  sum= 31319328

重启 broker 触发一轮清理后,本机实测(Kafka 4.3.0 / 2026-10-29):

topic触发的规则清理日志原文结果
c3.ret-bytes容量due to log retention size 4194304 breach30 段 → 5 段,5,191,170 字节
c3.ret-ms时间due to log retention time 60000ms breach31 段 → 1 个空段
Deleting segment LogSegment(baseOffset=0, size=949496, ...) due to log retention time 60000ms breach based o...
Deleting segment LogSegment(baseOffset=0, size=972462, ...) due to log retention size 4194304 breach. Log size after deletion will be 30346818.

两个 topic 都同时配了 retention.ms 和 retention.bytes,但各自只有一条规则被触发:c3.ret-bytes 的 24 小时没到、4 MiB 超了;c3.ret-ms 的 1 GB 没超、60 秒到了。谁先满足谁删,互不等待。 有三件事必须一起记住:

第一,retention.bytes 不是硬上限。 配 4 MiB,实际剩 5,191,170 字节,超了 24%。原因有两条:删除以整个段为单位;活动段(正在写的那个)永远不删。所以每个分区最多会超出「一个段 + 一个活动段」。段越小,超出得越少;segment.bytes 是 1 GB 时,一个分区就可能超出 2 GB。

第二,时间规则会先把活动段滚出去。 c3.ret-ms 最后只剩一个空段(baseOffset=60000),因为活动段不能删,Kafka 会先滚动出一个新段,让旧的都变成可删。这解释了为什么清理之后目录里总还有一个文件。

第三,数据没了,位移还在。 查清理后的首末位移:

docker exec mq-c3 /opt/kafka/bin/kafka-get-offsets.sh \
  --bootstrap-server 127.0.0.1:9092 --topic c3.ret-ms --time -2
c3.ret-ms:0:60000
c3.ret-ms:0:60000

起始位移等于末端位移,都是 60000。消费组如果停在这个位置之前,再启动时会发现「位移小于日志起始位移」,按 auto.offset.reset 的策略跳走。保留期删除的是日志,不是位移记录——这两件事经常被混为一谈。

内存账:页缓存不是堆

Kafka 不把消息缓存在 JVM 堆里,它依赖操作系统的页缓存。所以内存规划分两块:

用途建议说明
JVM 堆4–8 GB,不超过物理内存的 1/4堆越大,GC 停顿越长;Kafka 的堆主要放请求对象和索引,不放大消息体
页缓存(堆外)剩余内存的绝大部分读热尾数据命中页缓存,写脏页由内核批量刷盘

本机那个 -Xmx384m 的 broker 就是证据:384 MB 堆照样能写 20 万条每秒(12.1 节的数据),因为消息体走的是页缓存和 sendfile,不经过堆。页缓存只需要估一个窗口:消费者通常读最近的数据,所以「峰值 6 小时的写入量」是合理下限——本机估算每 broker 2.9 GB,消费者要回溯历史就按回溯深度放大。

副本数怎么放大一切

RF 从 1 变 3,放大的不只是磁盘:

资源RF=1RF=3倍数
磁盘1 份3 份3×
broker 间复制入向02 份新增
broker 间复制出向02 份(leader 发出)新增
写延迟本地写盘等最慢的 ISR 副本取决于副本分布
故障窗口单点可挂 1 台(min.insync.replicas=2)—

跨机架或跨 AZ 时,复制流量还要过交换机,跨 AZ 的出网流量通常要单独计费。13.1 节讲机架感知时会把副本分布与流量代价一起算。

一个常被忽略的点:扩容磁盘时不能只加一台。RF=3 意味着新数据要落到 3 台机器上,集群总容量是「最小那台的空闲磁盘 × 副本能落的机器数」,不是所有磁盘之和。

一张可以抄走的规划表

填进自己的数字即可:

项公式libraryd 的结果
日增字节事件数 × 86400 × (消息大小 + 70)35.2 GB(含 RF=3)
保留期总量日增 × 保留天数246.46 GB
每 broker 磁盘总量 / broker 数 × 1.3106.80 GB
分区数max(消费并行度, ⌈峰值 / 单分区⌉) 取整每主题 6,共 36
每 broker 分区总分区 / broker 数12
页缓存峰值 × 6 小时 / broker 数2.9 GB
堆固定 4–8 GB—
网络峰值 × (RF + 1) / broker 数0.56 MB/s

三个常见错误

按条数而不是字节数估容量。 libraryd 里条数最多的主题(220/s)和字节数最多的主题是同一个,但如果消息大小差一个数量级,排序就会反过来。

忘记乘副本数。 算出 82 GB 就买 82 GB 的盘,上集群时发现只剩三分之一可用。

把 retention.bytes 当硬配额。 它按段删除、保留活动段,实际占用会超出,超出的量级与 segment.bytes 同阶。要做硬配额得靠监控和告警,不能靠这个参数。

本节判据

  1. 容量规划的输入是哪三样? 答:事件率(含峰值系数)、平均消息大小、保留期与副本数。
  2. retention.ms 和 retention.bytes 同时设置时谁先触发? 答:各自独立判定、取并集,任何一个满足就删对应的段。
  3. 为什么 retention.bytes 会超标? 答:按整段删除,且活动段永不删除。
  4. 本机这个 workload 的分区数由什么决定? 答:消费并行度;吞吐要涨 51.5 倍才会成为约束。

如果这四个问题答得上来,你已经能独立做一次容量评估了。下一章从「买多少」转到「怎么摆」:集群拓扑、机架感知与跨 AZ 的流量代价。

阅读导航:上一节 12.2 吞吐、延迟与持久性的三角 | 下一节 13.1 集群拓扑与机架感知

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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