规划从哪一头开始
大多数容量规划是这样开始的:先看别人写的「每分区 10 MB/s」,再猜一个分区数,然后乘上保留天数。
这个顺序是反的。分区的吞吐上限取决于磁盘、页缓存、副本数、消息大小、压缩比和消费并行度,是一个结果,不是输入。真正的输入只有三样:
- 业务每秒产生多少事件(以及峰值是均值的几倍);
- 一条事件平均多少字节;
- 这些事件要留多久、留几份。
这三样定了,磁盘、分区、内存、网络都能推出来。
这一节用本书贯穿项目 libraryd-mq 的六个主题做一遍完整推算。先明确声明:下面的每秒事件数是本书构造的假设流量,不是任何真实系统的数据;单分区吞吐、记录开销、峰值系数也都是假设。 结论只说明推算方法,不构成对本机或任何生产集群能力的断言。
libraryd 的假设流量
| 主题 | 日均事件/秒 | 平均消息大小 | 峰值系数 |
|---|---|---|---|
library.loan.created | 35 | 620 B | 3× |
library.loan.returned | 32 | 560 B | 3× |
library.loan.overdue | 4 | 480 B | 3× |
library.hold.placed | 18 | 520 B | 3× |
library.hold.fulfilled | 12 | 540 B | 3× |
library.inventory.adjusted | 220 | 300 B | 3× |
几个建模决定值得说明,因为它们比数字本身更重要:
日均事件数不是峰值。 图书馆业务有明显的日周期:开馆时段借还集中,凌晨几乎为零。用日均值直接算磁盘是对的(磁盘看总量),但算分区数必须乘峰值系数,否则高峰会打满。这里取 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 breach | 30 段 → 5 段,5,191,170 字节 |
c3.ret-ms | 时间 | due to log retention time 60000ms breach | 31 段 → 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=1 | RF=3 | 倍数 |
|---|---|---|---|
| 磁盘 | 1 份 | 3 份 | 3× |
| broker 间复制入向 | 0 | 2 份 | 新增 |
| broker 间复制出向 | 0 | 2 份(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.3 | 106.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 同阶。要做硬配额得靠监控和告警,不能靠这个参数。
本节判据
- 容量规划的输入是哪三样? 答:事件率(含峰值系数)、平均消息大小、保留期与副本数。
retention.ms和retention.bytes同时设置时谁先触发? 答:各自独立判定、取并集,任何一个满足就删对应的段。- 为什么
retention.bytes会超标? 答:按整段删除,且活动段永不删除。 - 本机这个 workload 的分区数由什么决定? 答:消费并行度;吞吐要涨 51.5 倍才会成为约束。
如果这四个问题答得上来,你已经能独立做一次容量评估了。下一章从「买多少」转到「怎么摆」:集群拓扑、机架感知与跨 AZ 的流量代价。
阅读导航:上一节 12.2 吞吐、延迟与持久性的三角 | 下一节 13.1 集群拓扑与机架感知
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。