一次 send 不等于一次网络请求
调用 producer.send() 时,记录并没有立刻上网。它先被序列化,算出分区,然后追加到 RecordAccumulator 里该分区对应的队列。真正把队列里的数据变成请求的是另一个线程 Sender。这两个线程之间隔着的就是「批次」。
理解这一层,才能理解三个参数:
batch.size:一个分区的一个批次最多攒多少字节;linger.ms:一个批次最多等多久;compression.type:这一整批数据用什么算法压缩。
三者不是并列关系,而是「谁先满足谁触发」的关系。批次攒满 batch.size 就立刻发,不管 linger.ms 还剩多少;等满 linger.ms 还没攒满,也发。压缩则发生在这两者之后、上网之前,作用对象是整个批次。
这一节要回答的不是「参数怎么配」,而是「配置改了以后,请求数、批次大小、磁盘字节数分别怎么变」。这三个量都能测。
三个参数的准确定义
先看 4.3.0 的 ProducerConfig 源码。batch.size 的说明里有一句关键的:
Note: This setting gives the upper bound of the batch size to be sent. If we have fewer than this many bytes accumulated
for this partition, we will 'linger' for the <code>linger.ms</code> time waiting for more records to show up.
翻译过来:batch.size 是上限,不是目标值。没攒满就等 linger.ms,谁先到算谁。同一段文档还说明,Kafka 4.0 把 linger.ms 的默认值从 0 改成了 5:
The default changed from 0 to 5 in Apache Kafka 4.0 as the efficiency gains from larger batches typically result in
similar or lower producer latency despite the increased linger.
compression.type 的说明更短,但信息量很大:
Compression is of full batches of data, so the efficacy of batching will also impact the compression ratio
(more batching means better compression).
这句话是本节后半部分的全部依据:压缩的对象是整批,不是单条消息。批次越小,压缩算法能利用的重复模式越少,压缩率越差。
4.3.0 的默认值可以直接从 ProducerConfig 的 define 段读出来:
| 参数 | 默认值 | 位置 |
|---|---|---|
batch.size | 16384 | ProducerConfig 第 401 行 |
linger.ms | 5 | 第 405 行 |
compression.type | none | 第 397 行 |
buffer.memory | 33554432(32 MiB) | 第 390 行 |
delivery.timeout.ms | 120000 | 第 406 行 |
注意 buffer.memory。批次不是凭空存在的,每个批次都要从这块缓冲里分配空间。batch.size 调大,单个批次占的内存也变大;在低流量、多分区的场景里,这会放大内存占用。
实验设计
用一个单分区 topic,发送 6000 条 200 字节的 value,key 为 null,只改三个参数。程序在 /tmp/mq-a2-work/src/main/java/lab/BatchLab.java,核心是同步发完再 flush(),最后打印客户端指标:
p.put(ProducerConfig.LINGER_MS_CONFIG, Long.toString(lingerMs));
p.put(ProducerConfig.BATCH_SIZE_CONFIG, Integer.toString(batchSize));
p.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, compression);
// 发 count 条后 flush,读 batch-size-avg / compression-rate-avg / records-per-request-avg
运行方式:
java -cp "target/classes:$(cat cp.txt)" lab.BatchLab a2.batch.a 6000 200 0 16384 none
五组参数分别是:
| 组 | linger.ms | batch.size | compression |
|---|---|---|---|
| A | 0 | 16384 | none |
| B | 50 | 16384 | none |
| C | 0 | 262144 | none |
| D | 50 | 65536 | gzip |
| E | 50 | 65536 | zstd |
几个控制变量需要说明。用单分区是为了排除「多个分区的批次交错」对 batch-size-avg 的干扰:多分区时这个指标是所有分区批次的平均,看不出单个分区的行为。key 用 null 是为了让默认分区器把数据粘在一个分区上,避免 key 哈希把流量分散。value 用重复字符是为了让压缩效果可观察,不代表真实业务数据。
批次头、CRC、时间戳这些开销都计入 batch-size-avg 和落盘字节,所以「200 字节 × 77.9 条 = 15580 字节」和 16360.6 之间差的约 780 字节,就是批次元数据和记录头的固定成本。批次越小,这部分占比越高,这也是小批次压缩率差的原因之一。
本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29):
| 组 | 耗时 ms | batch-size-avg | records-per-request | compression-rate | 落盘字节 |
|---|---|---|---|---|---|
| A | 1030 | 16360.6 | 77.9 | 1.0 | 1259769 |
| B | 670 | 16360.6 | 77.9 | 1.0 | 1259769 |
| C | 648 | 251997.0 | 1200.0 | 1.0 | 1259985 |
| D | 794 | 922.7 | 285.7 | 0.0145 | 19377 |
| E | 1122 | 670.3 | 285.7 | 0.0103 | 14076 |
所有耗时数字都是单次运行,随调度波动,不作基准。本机 load average 常年在十几到二十几之间,A 和 B 的耗时差不能读成「linger 提高了 35% 吞吐」。
判读一:linger 在批次攒满时不起作用
A 和 B 的 batch-size-avg 完全一样,都是 16360.6 字节。原因很简单:6000 条 200 字节的数据在很短时间里涌入,16 KB 的批次几乎立刻被填满,linger.ms 的 50 毫秒根本没有机会生效。
这说明一件常被误解的事:linger.ms 只在「批次没攒满」时才是延迟来源。高吞吐场景下,决定请求数的是 batch.size,不是 linger.ms。想减少请求数,先看 batch-size-avg 离 batch.size 有多远;如果已经很接近,调大 linger 没有意义。
反过来,低流量场景下 linger.ms 是主导。一条一条慢慢发的业务,批次永远攒不满,请求数约等于消息数,此时把 linger 从 0 调到 5 或 10 才能把零星消息合并。
B 组和 A 组的 record-queue-time-avg 分别是 63.8 ms 和 229.3 ms,这个指标也不是 linger 的直接读数,它包含排队等待发送的时间,受 broker 响应速度和本机调度共同影响。不要用单个指标反推配置。
判读二:batch.size 直接决定每请求记录数
C 组把 batch.size 调到 256 KB,records-per-request 从 77.9 跳到 1200,请求数从约 77 降到 5。批次平均 251997 字节,接近上限但没顶满,因为最后一批是剩余数据。
代价在这里:
- 单个批次占用 256 KB 缓冲,32 MiB 的
buffer.memory只够 128 个这样的批次; - 一个批次没发出去之前,里面的记录都走不了,尾延迟被拉长;
- broker 端单次请求更大,网络线程处理单个请求的时间变长。
C 组耗时 648 ms 比 A 组 1030 ms 短,但这是单次运行的数字,只能说明「批次大了请求少了」这个方向,不能当成吞吐结论。真正要判断,得看第 12 章的压测方法。
判读三:压缩率是批次级的
D 组和 E 组用了 64 KB 的 batch.size,每批约 285 条记录,未压缩约 71 KB。压缩后 batch-size-avg 只有 922 字节(gzip)和 670 字节(zstd),compression-rate-avg 分别是 0.0145 和 0.0103。
这个压缩率低得夸张,因为测试 value 是 200 个重复的 x。它证明的是机制,不是效果:同一批里重复模式越多,压缩率越好。真实业务事件是 JSON,字段名重复、结构相似,压缩率通常在 0.2–0.6 之间,但具体值必须用自己的数据测。
落盘字节是最硬的证据:
a2.batch.a 1259769 bytes # none, batch 16 KB
a2.batch.b 1259769 bytes # none, batch 16 KB, linger 50
a2.batch.c 1259985 bytes # none, batch 256 KB
a2.batch.d 19377 bytes # gzip, batch 64 KB
a2.batch.e 14076 bytes # zstd, batch 64 KB
未压缩的三组几乎一样大,因为记录数相同、每条记录的开销相同;压缩的两组小了两个数量级。这个对比只有在「批次足够大」时才成立。如果把 D 组的 batch.size 降到 1024,每批只有几条记录,gzip 的头开销会让压缩率迅速变差,甚至可能比不压缩更大。
压缩发生在哪一层
kafka-dump-log.sh 能直接看到存储层的批次头。对 D 组的分区日志执行:
docker exec mq-a2 /opt/kafka/bin/kafka-dump-log.sh \
--files /tmp/kafka-logs/a2.batch.d-0/00000000000000000000.log
输出里第一批是:
baseOffset: 0 lastOffset: 296 count: 297 baseSequence: 0 lastSequence: 296
producerId: 3 producerEpoch: 0 partitionLeaderEpoch: 0
isTransactional: false isControl: false
size: 960 magic: 2 compresscodec: gzip crc: 1331528628 isvalid: true
三个信息值得记住:
count: 297—— 一个批次里有 297 条记录;compresscodec: gzip—— 压缩标记在批次头上;producerId: 3、baseSequence: 0 lastSequence: 296—— 幂等生产者给这个批次分配的序列号区间(下一节展开)。
broker 收到的是已经压缩好的批次,默认原样落盘,不做解压再压缩。消费者拉取后解压。所以压缩的 CPU 成本主要在生产者(压缩)和消费者(解压),broker 侧主要是网络和磁盘的节省。这个分工决定了压缩算法的选择:如果消费端 CPU 紧张,别选解压慢的算法。
CPU 与带宽的交换
压缩算法没有绝对优劣,只有取舍:
| 算法 | 压缩比(本机重复数据) | CPU 方向 | 适用 |
|---|---|---|---|
| none | 1.0 | 最低 | 内网带宽充足、CPU 紧张 |
| lz4 | 未测 | 低 | 低延迟优先 |
| snappy | 未测 | 低 | 兼容老客户端 |
| gzip | 0.0145 | 中 | 带宽紧张、吞吐优先 |
| zstd | 0.0103 | 中高 | 压缩比与速度折中 |
本机只在重复数据上测了 gzip 和 zstd,lz4 与 snappy 未在本机执行,不给数字。D 组 794 ms、E 组 1122 ms 的差异也不能读成「gzip 比 zstd 快」,因为这是单次运行,且 zstd 的默认压缩级别、JNI 初始化都受影响。
一个容易忽略的依赖问题:kafka-clients 的 pom 会带入 zstd-jni、lz4-java、snappy-java 三个本地库。只把 kafka-clients 一个 jar 拷进容器、不带这三个依赖时,选 zstd/lz4/snappy 会在运行时报找不到实现类。用 Maven 或 Gradle 管理依赖不会遇到,手工拼 classpath 会。
delivery.timeout.ms 与 linger 的关系
delivery.timeout.ms 是 send() 返回后到成功或失败的总时限。源码文档写得很清楚:
The value of this config should be greater than or equal to the sum of request.timeout.ms and linger.ms.
也就是说,linger.ms 增加的是「消息在客户端等待」的时间,这段时间算在 delivery.timeout.ms 里面。把 linger 调到 500 ms、delivery.timeout 却只给 1000 ms,重试一次就可能超时。
本机的五组实验都用默认的 120000 ms,没有触发超时,所以表里没有失败。这不是说默认值永远安全,而是说在这组数据量下没碰到边界。
批次与缓冲:内存是被批次占住的
RecordAccumulator 为每个分区维护一个双端队列,队列里是待发送的批次。批次一旦分配了缓冲,在发送完成或过期之前不会归还。这带来一个不那么直观的后果:限制并发的不是消息数,是批次数。
32 MiB 的 buffer.memory、16 KB 的 batch.size,理论上能同时容纳约 2048 个未完成批次。如果 batch.size 调到 256 KB,同样的内存只够 128 个批次;此时分区数一多,某些分区的批次就会排队等缓冲,bufferpool-wait-ratio 开始大于 0,send() 也可能在 max.block.ms 到期后抛异常。
这不是说大批次一定不好,而是说批次大小要和分区数、内存一起算。一个粗略的估算:
可同时容纳的批次数 ≈ buffer.memory / batch.size
每分区平均批次数 ≈ 可同时容纳的批次数 / 活跃分区数
如果「每分区平均批次数」小于 2,说明一个分区连「正在发」和「下一个」都排不上,吞吐会被缓冲卡住。这个估算不含消息体大小和压缩,只用来判断量级。
topic 级的 compression.type 是另一回事
生产者端的 compression.type 决定「发出去时用什么压缩」。topic 上也可以配 compression.type,它决定 broker 落盘时用什么压缩。两者不一致时,broker 可能要把批次解压再按 topic 配置重压。
本节所有实验都没有改 topic 配置,broker 原样保存生产者的压缩批次,所以磁盘字节数就是生产者压缩后的字节数。如果 topic 配了 compression.type=gzip 而生产者发的是 zstd,落盘字节会变,broker 的 CPU 也会增加。这个组合未在本机实测,不给数字;配置前用 kafka-topics.sh --describe 确认 topic 级配置,不要假设它和生产端一致。
该看哪些指标
调这三个参数时,客户端 metrics() 里有五个值最有用:
| 指标 | 含义 | 怎么读 |
|---|---|---|
batch-size-avg | 平均批次字节 | 远小于 batch.size → 批次没攒满,linger 或流量是瓶颈 |
records-per-request-avg | 每请求记录数 | 直接反映合并效果 |
compression-rate-avg | 压缩后/压缩前 | 接近 1 → 压缩没效果,检查批次大小和数据形态 |
record-queue-time-avg | 记录在队列里的时间 | 包含 linger、等待 broker、重试,不能单独归因 |
bufferpool-wait-ratio | 等待缓冲的比例 | 大于 0 → buffer.memory 不足或发送受阻 |
本机五组的 bufferpool-wait-ratio 都是 0,因为数据量远小于 32 MiB 缓冲。生产环境如果这个值持续大于 0,先查 broker 是否变慢,再考虑加内存;盲目调大 buffer.memory 只是把问题推后。
常见误配与对应症状
| 症状 | 最可能的原因 | 先改什么 |
|---|---|---|
请求数高、batch-size-avg 很小 | 流量低且 linger.ms=0 | 把 linger 调到 5–20 |
| 压缩率接近 1 | 批次太小或数据本身不可压 | 先看 batch-size-avg,再换算法 |
| 发送延迟高、队列时间长 | linger.ms 过大或 broker 慢 | 降 linger,查 broker |
bufferpool-wait-ratio>0 | 缓冲被慢 broker 占满 | 查 broker,再评估 buffer.memory |
| 单条大消息频繁超时 | batch.size 小于单条消息 | 调大 batch.size 或 max.request.size |
最后一行值得展开:batch.size 小于单条记录时,这条记录会单独成一个批次直接发,不会因为「超过 batch.size」被拒绝。真正会拒绝的是 max.request.size。这两个上限管的是不同的东西。
回到 libraryd
library.loan.created 的 key 是 bookId,单本书的借阅事件量不大,属于典型的低流量多分区场景。按本节的结论:
linger.ms保持 5–10,让零星事件能合并;batch.size用默认 16 KB 就够,调到 256 KB 只会增加尾延迟;- 压缩选 lz4 或 zstd,但必须先用自己的事件 JSON 测压缩率,不能照搬本节的重复字符串数据;
- 监控
batch-size-avg和bufferpool-wait-ratio,前者告诉你批次有没有攒起来,后者告诉你缓冲有没有成为瓶颈。
下一节处理另一个方向的问题:批次发出去了,broker 到底确认到什么程度才算成功。acks 的三个取值对应三种不同的失败窗口,而 retries 和幂等生产者决定了这些窗口会不会变成重复消息。
阅读导航:上一节 2.3 保留与清理 | 下一节 3.2 acks、重试与幂等
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。