三角不是修辞
「吞吐、延迟、持久性」被叫作三角,不是因为它听起来平衡,而是因为它们在实现上抢同一批资源。
- 要持久,就得等副本确认,等待需要时间;
- 要吞吐,就得攒大批次、压缩,攒批需要时间,压缩需要 CPU;
- 要低延迟,就得马上发、小批次,小批次会放大请求数和开销。
三个目标各自对应一份成本,没有人能同时取消三份成本。所谓「调优」,本质是决定把成本记在哪个账上。
这一节把 acks、批次、压缩这三组开关拆开,用四组配置各跑两轮,把账算给你看。
三组开关分别动什么
acks:等待的位置
| 值 | 何时返回成功 | 代价 |
|---|---|---|
0 | 写进 socket 就返回 | 不知道 broker 收没收到,丢了你也不知道 |
1 | leader 写入本地日志后返回 | leader 挂了、副本没跟上,消息丢 |
all | ISR 中所有副本都写入后返回 | 多一次(或多次)网络往返与落盘 |
acks=all 的代价取决于有几个副本要等。RF=3 时要等两个 follower 确认,RF=1 时 ISR 里只有 leader 自己,acks=all 和 acks=1 在协议上几乎没有区别。这一点对理解下面的实测很重要:单节点集群测不出 acks 的持久性代价。
batch.size 与 linger.ms:攒批的两个闸门
batch.size(默认 16384):字节上限,不是条数上限。linger.ms(默认 0):时间上限,攒够这么久就发,哪怕没攒满。
哪个先到按哪个发。所以:
- 载荷 512 字节、
batch.size=1024时,一个批次装不下两条(512 + 记录头 + 61 字节批次头 > 1024),等于没有攒批。这是本节实验里「低延迟配置」反而最慢的直接原因。 batch.size=131072时,一批能装 200 多条,批次头的开销被摊薄。
攒批换来的是吞吐:请求数下降、顺序写更连续、压缩的输入更大。付出的是延迟:每条消息平均要多等半个 linger 窗口。
compression.type:用 CPU 换字节
压缩是批次级的,不是消息级。批次越大,压缩比越好;批次只有一条记录时,压缩甚至是负收益(多了一个压缩头)。
压缩省下的是网络与磁盘,付出的是客户端和 broker 的 CPU。3.1 节讲过这条链路的细节,这里只看它对三角的影响:压缩让吞吐上去、磁盘下来,但对延迟是中性偏负——多了一次编解码。
为什么不能三个都要
把三个目标的最优配置放在一起,会得到一份自相矛盾的配置:
- 低延迟要
linger.ms=0+ 小批次 → 请求数爆炸 → 吞吐下降 - 高吞吐要
linger.ms=50+ 大批次 → 每条多等 25 ms → 延迟上升 - 高持久要
acks=all+min.insync.replicas=2→ 等副本 → 延迟上升,集群降级时直接拒写
这不是「配置没调好」,这是三个目标共用同一份时间预算。你能做的是:先确定哪个目标是硬约束(SLO),另外两个让路。
四组对照配置
| 组 | 定位 | 关键参数 |
|---|---|---|
| A | 低延迟玩法 | acks=1 linger.ms=0 batch.size=1024 compression.type=none |
| B | 高吞吐玩法 | acks=1 linger.ms=50 batch.size=131072 compression.type=lz4 |
| C | 高持久玩法 | acks=all enable.idempotence=true linger.ms=10 batch.size=32768 compression.type=lz4 |
| D | 隔离 acks 的对照组 | 与 C 完全相同,只把 acks 改成 1、去掉幂等 |
D 组存在的唯一目的是把 acks 这一个变量单独拎出来——否则 C 与 B 差在四个参数上,什么也说明不了。
每组写入 30 万条 512 字节记录到独立 topic(c3.tri-a 到 c3.tri-d,均 1 分区 RF=1),先不限速跑两轮,再限速 2000 条每秒跑一轮。
实测一:不限速吞吐
本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29)。命令以 A 组为例:
docker exec mq-c3 /opt/kafka/bin/kafka-producer-perf-test.sh \
--bootstrap-server 127.0.0.1:9092 --topic c3.tri-a \
--num-records 300000 --record-size 512 --throughput -1 \
--command-property acks=1 linger.ms=0 batch.size=1024 compression.type=none
四组的汇总行原文:
# A
300000 records sent, 6065.875407 records/sec (2.96 MB/sec), 4819.77 ms avg latency, 9009.00 ms max latency, 4481 ms 50th, 7404 ms 95th, 8897 ms 99th, 8994 ms 99.9th.
# B
300000 records sent, 80342.795929 records/sec (39.23 MB/sec), 491.70 ms avg latency, 798.00 ms max latency, 506 ms 50th, 738 ms 95th, 791 ms 99th, 794 ms 99.9th.
# C
300000 records sent, 54565.296471 records/sec (26.64 MB/sec), 889.76 ms avg latency, 1464.00 ms max latency, 861 ms 50th, 1417 ms 95th, 1448 ms 99th, 1460 ms 99.9th.
# D
300000 records sent, 157894.736842 records/sec (77.10 MB/sec), 243.15 ms avg latency, 317.00 ms max latency, 277 ms 50th, 308 ms 95th, 314 ms 99th, 316 ms 99.9th.
同参数再来一轮:
# A 第二轮
300000 records sent, 11299.860635 records/sec (5.52 MB/sec), 2723.95 ms avg latency, 3076.00 ms max latency, 2827 ms 50th, 3002 ms 95th, 3032 ms 99th, 3059 ms 99.9th.
# B 第二轮
300000 records sent, 118764.845606 records/sec (57.99 MB/sec), 133.51 ms avg latency, 325.00 ms max latency, 119 ms 50th, 284 ms 95th, 294 ms 99th, 301 ms 99.9th.
# C 第二轮
300000 records sent, 165107.319758 records/sec (80.62 MB/sec), 225.73 ms avg latency, 356.00 ms max latency, 234 ms 50th, 345 ms 95th, 353 ms 99th, 355 ms 99.9th.
# D 第二轮
300000 records sent, 195312.500000 records/sec (95.37 MB/sec), 170.09 ms avg latency, 273.00 ms max latency, 194 ms 50th, 260 ms 95th, 269 ms 99th, 272 ms 99.9th.
并排:
| 组 | 第一轮 records/sec | 第二轮 records/sec | 两轮差 |
|---|---|---|---|
| A | 6,066 | 11,300 | 1.86 倍 |
| B | 80,343 | 118,765 | 1.48 倍 |
| C | 54,565 | 165,107 | 3.03 倍 |
| D | 157,895 | 195,313 | 1.24 倍 |
单次运行,随调度波动,不作基准。
这张表最该被记住的不是绝对值,是 C 那一行的 3 倍差:C 的第一轮(54,565)比 D 的第一轮(157,895)慢三倍,看上去「acks=all 让吞吐掉了三分之二」;可 C 的第二轮(165,107)几乎追平 D 的第一轮。如果你只跑一轮,你会得出一个完全错误、而且看起来很有说服力的结论。
能稳定看出来的相对关系只有一条:A 组比其余三组慢一个数量级。这一条在两轮里都成立,且原因可以从参数直接推出(每批一条记录)。
实测二:限速 2000 条每秒
不限速测的是「压满时会发生什么」,延迟数字被排队污染。要测配置本身的延迟,必须限速。
同四组配置,--throughput 2000,各 2 万条:
docker exec mq-c3 /opt/kafka/bin/kafka-producer-perf-test.sh \
--bootstrap-server 127.0.0.1:9092 --topic c3.tri-b \
--num-records 20000 --record-size 512 --throughput 2000 \
--command-property acks=1 linger.ms=50 batch.size=131072 compression.type=lz4
本机实测汇总行:
# A
20000 records sent, 1998.201619 records/sec (0.98 MB/sec), 305.22 ms avg latency, 1227.00 ms max latency, 112 ms 50th, 1117 ms 95th, 1204 ms 99th, 1223 ms 99.9th.
# B
20000 records sent, 1997.203915 records/sec (0.98 MB/sec), 27.10 ms avg latency, 219.00 ms max latency, 27 ms 50th, 51 ms 95th, 53 ms 99th, 58 ms 99.9th.
# C
20000 records sent, 1998.800720 records/sec (0.98 MB/sec), 6.84 ms avg latency, 206.00 ms max latency, 7 ms 50th, 12 ms 95th, 14 ms 99th, 17 ms 99.9th.
# D
20000 records sent, 1992.428771 records/sec (0.97 MB/sec), 15.24 ms avg latency, 634.00 ms max latency, 8 ms 50th, 56 ms 95th, 188 ms 99th, 207 ms 99.9th.
| 组 | avg | p50 | p95 | p99 | 延迟主要由什么决定 |
|---|---|---|---|---|---|
| A | 305.22 ms | 112 ms | 1117 ms | 1204 ms | 请求排队(每批一条) |
| B | 27.10 ms | 27 ms | 51 ms | 53 ms | linger.ms=50 的一半 |
| C | 6.84 ms | 7 ms | 12 ms | 14 ms | linger.ms=10 的一半 |
| D | 15.24 ms | 8 ms | 56 ms | 188 ms | 同 C,尾部受抖动影响 |
三个观察:
p50 基本等于 linger 窗口的一半。 限速 2000 条每秒时,10 ms 只攒到 20 条(约 10 KB),远小于 32 KB 的批次上限,所以是时间闸门先到;B 组 50 ms 攒 100 条约 52 KB,同样没到 128 KB 的上限。均匀到达下平均等待是窗口的一半——B 的 27 ms 和 C 的 7 ms 都落在这个预期上。
A 组的 p50 是 112 ms,比 B 还差。 它 linger.ms=0,理论上应该最快。真实原因是每批只能装一条记录,请求数变成 1:1,客户端与 broker 的请求管线被打满,延迟转移到排队上。这条观察支持一个反直觉的结论:「低延迟配置」只在速率远低于容量时低延迟,一旦接近容量,它比大批次配置更早崩溃。
C 与 D 的 p50 是 7 ms 和 8 ms,无法区分。 它们只差 acks 与幂等,在单节点 RF=1 上 acks=all 不需要等任何其他副本。这就是前面说的:acks 的代价是「等副本」,不是「写 all 这两个字母」。
把两种模式合成一张总表(同一组数据,两个切面):
| 组 | records/sec(两轮) | 不限速 avg | 不限速 p99 | 限速 avg | 限速 p99 |
|---|---|---|---|---|---|
| A | 6,066 / 11,300 | 4819.77 ms | 8897 ms | 305.22 ms | 1204 ms |
| B | 80,343 / 118,765 | 491.70 ms | 791 ms | 27.10 ms | 53 ms |
| C | 54,565 / 165,107 | 889.76 ms | 1448 ms | 6.84 ms | 14 ms |
| D | 157,895 / 195,313 | 243.15 ms | 314 ms | 15.24 ms | 188 ms |
这张表只能看相对关系,不能看绝对值。 绝对值属于「4 vCPU / 7.7 GiB、同时跑 21 个容器、broker 堆 384 MB、单分区、RF=1、客户端与服务端同机」这一组前提,换一台机器就全变。能看到的是方向:攒批把吞吐拉高一个数量级;小批次一旦接近容量,延迟反而最先崩;压缩在磁盘上几乎没有收益,因为它压不动随机载荷。
acks 的真实代价要在多副本上测
4.2 节的三节点实测给了另一面:3 副本、min.insync.replicas=2 时,停掉一个 broker 仍可写(ISR=2),停掉第二个就报 NOT_ENOUGH_REPLICAS。acks 的代价在那里表现为两种形态:
- 延迟:
acks=all要等最慢的那个 ISR 副本确认,等于把 follower 的写盘时间加到每次请求上; - 可用性:ISR 不够时不是变慢,是直接拒写。
本机单节点测不出第一条,也测不出第二条。任何「acks=all 只慢 5%」的结论,都必须写明副本数;RF=1 上测出来的 5%,是噪声。
压缩:工具测不出来的那一半
四组里 B、C、D 用了 lz4,A 用 none。写入 30 万条后看分区目录的实际大小:
docker exec mq-c3 /opt/kafka/bin/kafka-log-dirs.sh \
--bootstrap-server 127.0.0.1:9092 --describe \
--topic-list c3.tri-a,c3.tri-b,c3.tri-c
本机实测(写入 30 万条 512 字节记录后):
| 组 | 分区目录字节数 | 相对载荷 153,600,000 B |
|---|---|---|
| A(none,1 KB 批) | 174,600,000 | +13.7% |
| B(lz4,128 KB 批) | 155,936,221 | +1.5% |
| C(lz4,32 KB 批) | 156,572,993 | +1.9% |
压缩确实生效了。看 B 组的第一个批次:
docker exec mq-c3 /opt/kafka/bin/kafka-dump-log.sh \
--files /tmp/kafka-logs/c3.tri-b-0/00000000000000000000.log | head -3
baseOffset: 0 lastOffset: 238 count: 239 baseSequence: -1 lastSequence: -1 producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0 isTransactional: false isControl: false deleteHorizonMs: OptionalLong.empty position: 0 CreateTime: 1793248657185 size: 124241 magic: 2 compresscodec: lz4 crc: 2763587106 isvalid: true
compresscodec: lz4 说明压缩真的开了;一个批次装了 239 条记录,压缩后 124,241 字节。239 × 512 = 122,368 字节载荷,也就是说压缩后反而比原始载荷大了约 1.5%——这是压缩头加批次头的开销,压缩比约等于 1。
原因在载荷本身。把 A 组的一条记录打出来:
baseOffset: 0 lastOffset: 0 count: 1 ... size: 582 magic: 2 compresscodec: none ... keySize: -1 valueSize: 512 ... payload: YQHHNEBSEPDNSEIFGAMSUJXKOLTXSPLGHDIOYZJFNIDSPWHZMKVJAXDBZFCOXYKYRJOGYKDESSJMOIIOWVKYUAVWJLXSEPPFEILVBASHCGRHSYGIFSYLVGRXCDVABWWTRQZTMMPBAXGHEPHTASSORYKGVPFGQYJKINSZUJLXQUUDVALUSBFRSXNQHSDFDBAKQZZNTYXFHYGDPYGNRETYAXIXXYQKMKURDSJYIZNEDAHVIVHCJAPGOBQLHUZTKIWTVFEHVYPNGHIDSERMARFXCPYFEPQMFDOTDPWNKMYRMFIABIQAWWOIFIAKNYFEPTPMIXPQAXFEIKUFFXIDHILPPCBTHWDRMALHFNDCRHAYVLLMRCKJIPNPKGWCIWQCHNHSFSCTYSAKSLVZCC
valueSize: 512,内容是一串随机字符。--record-size 生成的就是随机字节,熵接近满值,lz4 没有任何可压缩的冗余。
这条实测的意义是方法论层面的:用 kafka-producer-perf-test.sh --record-size 评估压缩收益,一定会得出「压缩没用」。 真实业务消息是 JSON 或 Avro,字段名重复、数字密集,压缩比通常在 3 到 10 倍之间。要测这个,必须用 --payload-file 喂真实样本。
顺带还能读出一个数字:A 组的 size: 582 说明 512 字节载荷单独成批时线上占 582 字节(批次头约 61 字节 + 记录头)。30 万条 × 582 = 174,600,000,与磁盘占用完全对上。小批次不仅慢,还多占 13.7% 的磁盘。
三组典型配置的取舍
把上面的数据收成一张决策表:
| 低延迟玩法 | 高吞吐玩法 | 高持久玩法 | |
|---|---|---|---|
| 典型参数 | linger.ms=0~5 batch.size=16384 acks=1 compression=none | linger.ms=20~50 batch.size=131072 acks=1 compression=lz4 | acks=all enable.idempotence=true min.insync.replicas=2 linger.ms=5~10 batch.size=32768 compression=lz4 |
| 本机不限速 records/sec | 6 千 ~ 1.1 万 | 8 万 ~ 11.9 万 | 5.5 万 ~ 16.5 万 |
| 本机限速 2000/s 的 p50 | 112 ms | 27 ms | 7 ms |
| 磁盘开销 | +13.7%(小批次) | +1.5% | +1.9% |
| 丢消息窗口 | leader 挂即丢 | leader 挂即丢 | ISR 不足时拒写 |
| 适合 | 交互式通知、单条 RPC 式投递 | 日志、埋点、指标汇聚 | 订单、账务、库存变更 |
注意「低延迟玩法」那一列里,本机的 p50 反而是三组里最差的。这不是说小批次不能低延迟,而是说小批次留给容量的余量最小:在 2000 条每秒这个并不高的速率下它已经开始排队了。要用小批次,前提是你的峰值速率离容量上限很远。
「高持久玩法」在同一台机器上的吞吐并不比高吞吐玩法差多少,因为 RF=1 时 acks=all 没有等待对象。一旦上了 3 副本,这组数字会明显下滑,下滑多少取决于副本所在机器的磁盘和网络。这也是为什么生产环境的容量评估必须用与生产同构的集群做,单节点数据只能用来比参数方向。
从 SLO 反推配置
不要从参数出发,从 SLO 出发:
- 先写下延迟目标:p99 必须小于多少毫秒?这决定
linger.ms的上限——p99 大致不会低于一个 linger 窗口。 - 再写下持久性目标:允许丢多少?不允许丢就必须
acks=all+min.insync.replicas=2,没有折中。 - 最后看吞吐够不够:用限速模式在目标速率下跑,看延迟是否达标;不够就加大批次、加分区、加客户端实例。
- 压缩按带宽和磁盘决定:网络或磁盘是瓶颈时才值得付 CPU;否则
none更省心。
按这个顺序,绝大多数团队会发现:真正需要调的只有 linger.ms(延迟换吞吐)和分区数(并行度),acks 与压缩早就被 SLO 定死了。
在线上确认你到底在付哪笔账
压测只能回答「配置会怎样」,线上要回答「现在正在怎样」。四条客户端指标就够判断三角里哪一边正在被挤压:
| 指标 | 读法 |
|---|---|
batch-size-avg | 远小于 batch.size,说明时间闸门先到,批次没攒满 |
record-queue-time-avg | 记录在缓冲区里的等待,这一项涨就是生产端在积压 |
request-latency-avg | 一次请求的往返,这一项涨才是 broker 或网络慢 |
buffer-available-bytes | 接近 0 时 send() 会阻塞,max.block.ms 到期就抛异常 |
record-queue-time-avg 与 request-latency-avg 的分工,正好对应本节的两个实验:不限速时前者暴涨(排队),限速时后者稳定(真实往返)。看到延迟报警先看这两个值,能立刻分清是「生产太快」还是「集群变慢」。9.3 节有完整的指标清单。
三个常见误配
batch.size 当条数用。 写成 batch.size=1000 以为「一批 1000 条」,实际是 1000 字节,装两条 512 字节的消息都不够。
linger.ms=0 配 batch.size=1MB。 时间闸门永远先到,批次永远攒不满,大批次形同虚设。这两个参数必须一起看。
单节点上调 acks 找性能差异。 RF=1 时 ISR 只有 leader,acks=all 没有等待对象,测出来的差异全是噪声——本节 C 与 D 的 p50 只差 1 ms,而它们的单次吞吐能差 3 倍。
本节判据
- 为什么
acks=all在单节点上没有延迟代价? 答:ISR 里只有 leader,不需要等任何其他副本。 batch.size=1024+ 512 字节载荷会发生什么? 答:一批只装得下一条,请求数 1:1,吞吐塌一个数量级,磁盘还多占 13.7%。- 限速与不限速测的是同一件事吗? 答:不是。不限速测极限吞吐,延迟被排队主导;限速才测得到配置本身的延迟。
- 为什么本机测不出压缩收益? 答:
--record-size的载荷是随机字节,不可压缩。
这四个问题对应本节全部实验。下一节换一个方向:不再问「能跑多快」,而是问「业务需要多快,据此要买多少机器」。
阅读导航:上一节 12.1 官方压测工具与方法论 | 下一节 12.3 容量规划
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。