三个参数,三种直觉
消费端调优最常被一起改的三个参数是:
| 参数 | 默认值 | 作用点 |
|---|---|---|
fetch.min.bytes | 1 | broker 一次 fetch 至少要凑够多少字节才返回 |
fetch.max.wait.ms | 500 | 凑不够时最多等多久 |
max.poll.records | 500 | 一次 poll() 最多返回多少条 |
围绕它们有三种很常见的直觉:
- 「把
fetch.min.bytes调大,一次多拿点,吞吐肯定更高」; - 「把
max.poll.records调大,减少poll()次数,肯定更快」; - 「把
fetch.max.wait.ms调大,让 broker 多攒一会儿,网络利用率更高」。
这三种直觉在某些场景成立,在另一些场景会把吞吐打回原形。本篇用同一份固定积压跑四组参数,把「快」和「慢」的边界找出来,并解释慢的那一组到底慢在哪。
参数语义:先看官方文档的原话
fetch.min.bytes 的配置说明里有一句经常被跳过的限定:
The minimum amount of data the server should return for a fetch request. If insufficient data is available
the request will wait for that much data to accumulate before answering the request. ... Even if the total
data available in the broker exceeds fetch.min.bytes, the actual returned size may still be less than this
value due to per-partition limits max.partition.fetch.bytes and max returned limits fetch.max.bytes.
最后一句是重点:broker 里有足够的数据,不代表一次 fetch 能返回 fetch.min.bytes 那么多。单分区有 max.partition.fetch.bytes(默认 1MB)上限,整次响应还有 fetch.max.bytes(默认 50MB)上限。
fetch.max.wait.ms 则是「凑不够时最多等多久」:
The maximum amount of time the server will block before answering the fetch request there isn't sufficient
data to immediately satisfy the requirement given by fetch.min.bytes.
max.poll.records 只影响客户端一次交给应用多少条,不影响 broker 侧攒多少。三者一个在 broker 侧决定「什么时候回」,一个决定「最多等多久」,一个在客户端决定「一次交多少」。
实验装置
环境:Temurin 25.0.5+8、kafka-clients 4.3.0、apache/kafka:4.3.0 单节点 KRaft,宿主机 4 vCPU。以下数字均为单次运行,随调度波动,不作基准。
装置:
- 主题 4 个分区,每分区 50000 条,共 200000 条;
- 每条 value 约 170 字节(
book-k,seq,加 150 字节填充); - 先生产完再消费,形成固定积压;
- 消费者
assign四个分区,从最早开始,关闭自动提交; - 每条记录做同样的轻量处理(解析、累加),四组工作量一致;
- 显式设置
max.partition.fetch.bytes=1048576,让单次 fetch 的单分区上限固定为 1MB; - 指标:消费完全部 200000 条的耗时、吞吐、
poll()调用耗时 p99、记录在积压中停留时间的 p99。
poll() 耗时的 p99 直接反映「有多少次 poll 在等 broker」;记录年龄的 p99 包含预生产耗时,只能横向比较,不能当作端到端延迟。
JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
java -Xmx1g -cp "out:$CP" TuningLab A 1 0 100
java -Xmx1g -cp "out:$CP" TuningLab B 65536 50 500
java -Xmx1g -cp "out:$CP" TuningLab C 1048576 1000 5000
java -Xmx1g -cp "out:$CP" TuningLab D 8388608 1000 5000
测量循环本身很简单,但有两个细节决定了数字是否可比:
Map<TopicPartition, Long> ends = c.endOffsets(tps);
start = System.currentTimeMillis();
while (true) {
long t0 = System.nanoTime();
ConsumerRecords<String, String> rs = c.poll(Duration.ofMillis(1000));
pollNanos.add(System.nanoTime() - t0); // 每次 poll 的耗时
for (ConsumerRecord<String, String> r : rs) {
ages.add(System.currentTimeMillis() - r.timestamp()); // 记录等了多久
handle(r); // 四组完全相同的处理
}
boolean done = true;
for (TopicPartition tp : tps) {
if (c.position(tp) < ends.get(tp)) { done = false; break; }
}
if (done) break;
}
细节一:以 position 追上 endOffsets 作为结束条件,而不是等一次空 poll。空 poll 会额外等一轮(最多一个 fetch.max.wait.ms,没有数据时还会等到 poll 的超时时间),把耗时算进结果里,四组的差距会被这个固定尾巴污染。
细节二:记录年龄的起点是生产时间戳,不是消费开始时间。这批记录在消费开始前就已经生产完了,所以年龄里包含了几百毫秒的预生产时间。四组之间的相对差异有效,绝对值不能当端到端延迟读。
四组参数
| 组 | fetch.min.bytes | fetch.max.wait.ms | max.poll.records | 设计意图 |
|---|---|---|---|---|
| A | 1 | 0 | 100 | 小批次、低延迟 |
| B | 64 KB | 50 | 500 | 官方默认量级,均衡 |
| C | 1 MB | 1000 | 5000 | 大批次,尽量少 poll |
| D | 8 MB | 1000 | 5000 | 在 C 的基础上继续加大 |
D 只改了 fetch.min.bytes,从 1MB 变成 8MB。按「越大越快」的直觉,D 应该比 C 更好。结果相反。
实测结果(三轮取中位数)
| 组 | 耗时 | 吞吐 | poll 耗时 p99 | 记录年龄 p99 | poll 次数 |
|---|---|---|---|---|---|
| A | 542 ms | 369004 rec/s | 1.10 ms | 911 ms | 2006 |
| B | 454 ms | 440529 rec/s | 31.62 ms | 708 ms | 403 |
| C | 391 ms | 511509 rec/s | 56.84 ms | 661 ms | 43 |
| D | 9627 ms | 20775 rec/s | 1004.08 ms | 9826 ms | 52 |
原始三轮数据:
A: 536/542/779 ms throughput 373134/369004/256739 rec/s
B: 435/454/489 ms throughput 459770/440529/408998 rec/s
C: 391/386/410 ms throughput 511509/518135/487805 rec/s
D: 9627/9547/9648 ms throughput 20775/20949/20730 rec/s
先看前三组。A 反而最慢:100 条一批意味着 2006 次 poll(),固定开销最大。C 比 A 快 1.39 倍(511509 对 369004),比 B 快 16%。这和「批次大一点更快」的直觉一致:max.poll.records=5000 把 poll() 次数从 2006 压到 43,每次调用的固定开销被摊薄;fetch.min.bytes=1MB 让 broker 一次回更多数据,网络往返也少了。这两个因素各自贡献多少,本篇没有做单变量拆分,只能说「两者一起调大」在这份积压下拿到了这个结果。
代价在延迟一列:C 的 poll() p99 是 56.84 毫秒,A 只有 1.10 毫秒。这不是 C 出了问题,是它主动在等更大的批次。批次换的是延迟,不是白来的吞吐。
再看 D。同样的分区、同样的积压、同样 5000 的批次,只把 fetch.min.bytes 从 1MB 提到 8MB,耗时从 391 毫秒变成 9627 毫秒,慢了 24.6 倍;poll() p99 从 56.84 毫秒变成 1004.08 毫秒,正好卡在 fetch.max.wait.ms=1000 上。这就是「参数调大反而更慢」的那一组。
D 为什么慢:min.bytes 超过了单次 fetch 的上限
把文档里那句话和实验配置对上:
- 4 个分区,每个分区的单次返回上限
max.partition.fetch.bytes=1MB; - 一次 fetch 响应最多带回 4 × 1MB = 4MB;
fetch.min.bytes=8MB。
于是每一次 fetch 请求,broker 都凑不够 8MB——不是因为没有数据,而是因为单次协议上限决定了它永远凑不够。broker 只能等满 fetch.max.wait.ms=1000 毫秒,然后返回 4MB。
D 的 52 次 poll() 里,大约十次在等这个 1000 毫秒,其余都是拿缓冲里的数据快速返回。总耗时约等于:
200000 条 × 约 170 字节 ≈ 34 MB
每次 fetch 最多返回 4 MB → 约 8 到 9 轮
8~9 轮 × 1000 ms ≈ 8~9 秒
实测 9627 毫秒,量级吻合。D 的 poll() p99 = 1004.08 毫秒,几乎就是 fetch.max.wait.ms 本身;p50 只有 1.64 毫秒,说明少量「等满」的调用拉高了尾部。
一句话总结:fetch.min.bytes 必须小于等于「单次 fetch 在物理上能返回的上限」,否则每一次 fetch 都会退化成一次固定等待。
D 组还有 9 次空 poll。原因是 max.poll.records=5000 比一次 fetch 返回的 20000 条小,客户端把一次响应切成四次交付;当前面的缓冲被消费完、下一轮 fetch 还在等 1000 毫秒时,poll() 就会空手而归。空 poll 不是错误,但它让「消费者明明有积压、却在空转」这件事变得可见——看 lag 面板的人会以为消费者卡住了,实际上是参数让它等。
这也解释了为什么 D 组的记录年龄 p99 是 9826 毫秒:不是处理慢,是记录在 broker 侧等 fetch 窗口。吞吐下降和延迟上升是同一个原因的两个表现。
三组参数各自的边界
fetch.min.bytes
它不是「攒得越多越好」,而是「用延迟换请求次数」。可用的上限是:
fetch.min.bytes ≤ min(fetch.max.bytes, 分区数 × max.partition.fetch.bytes)
超过这个上限,多出来的部分永远不会被满足,只会让每次 fetch 等满 fetch.max.wait.ms。还有一个更隐蔽的下限问题:如果流量很低,fetch.min.bytes 设得比「一个等待窗口内到达的数据量」还大,同样会每次等满窗口。
一个实用的起点是:fetch.min.bytes ≈ 目标吞吐(字节/秒) × 可接受延迟(秒)。比如目标 10MB/s、可接受 50ms 延迟,就取 512KB 左右。再往上加之前,先确认上面的上限。
fetch.max.wait.ms
它决定「凑不够时最多等多久」。它同时也是延迟的下限:只要数据不足,每次 fetch 至少等这么久。
- 对延迟敏感(在线交易、实时通知):设小,比如 10 到 50 毫秒;
- 对吞吐敏感(离线投影、对账):可以设大,但要配合足够的流量,否则就是 D 组的下场;
- 它和
fetch.min.bytes要成对调:只调大 min.bytes 不调 wait,等待时间还是原来的;只调大 wait 不调 min.bytes,等于让 broker 无谓地多等。
max.poll.records
它只影响客户端一次交给应用多少条,但会间接影响两件事:
- 单次 poll 的处理耗时。5000 条 × 每条 1 毫秒的下游写入 = 5 秒,
max.poll.interval.ms一旦被超过,消费者就被踢出组(12.2 实测过这个过程)。 - 内存占用。一批 5000 条、每条反序列化后几百字节,就是几 MB 的临时对象;批次再大,GC 压力会先于吞吐收益出现。
它和 fetch.min.bytes 的关系是:broker 一次可能回 4MB,但客户端一次只交 5000 条,剩下的留在客户端缓冲里。如果 max.poll.records 远小于一次 fetch 的量,poll() 会连续快速返回多次,网络侧的收益被客户端切碎——C 组把两者都调大,拿到了 1.39 倍的提升;单变量各占多少,本篇没有拆开测。
三个常见误区
误区一:只调 max.poll.records,不动 fetch 参数。 客户端一次只交 100 条,但 broker 一次可能回 4MB——数据已经在客户端缓冲里,poll() 会连续快速返回很多次。这时候把 max.poll.records 从 100 调到 5000,只是把「多次快速返回」变成「一次慢返回」,网络侧没有任何变化。真正的批量收益在 broker 侧,得和 fetch.min.bytes 一起调。
误区二:只看吞吐,不看尾部延迟。 C 组比 A 组快 39%,但 poll() p99 从 1.10 毫秒涨到 56.84 毫秒。对一个「收到消息后要触发用户可见动作」的服务,这 55 毫秒会直接叠到端到端延迟上。调参的产出应该是一张「吞吐—延迟」曲线,而不是一个吞吐数字。
误区三:把 D 组的配置当成「再大一点」。 8MB 不是 1MB 的「更激进版本」,它跨过了一条协议上限,行为从「尽量多拿」变成了「每次都等满」。判断有没有跨线,用前面的公式算一下就知道:
4 个分区 × max.partition.fetch.bytes 1MB = 4MB < 8MB
跨线之后,fetch.max.wait.ms 设得越大,慢得越稳定——D 组的 p99 精确地贴在 1000 毫秒上,就是证据。
调参顺序
不要所有参数一起改。推荐这个顺序,每一步只动一个:
- 先测下游。单条记录的处理耗时是多少?是 CPU 还是 IO?这决定了
max.poll.records × 单条耗时的合理范围。 - 定
max.poll.records。让「一批的最坏处理时间」小于max.poll.interval.ms的三分之一,留出提交和抖动余量。 - 定
fetch.min.bytes。按「目标吞吐 × 可接受延迟」估算,再用上面的上限公式校验,确保不超过单次 fetch 能返回的量。 - 定
fetch.max.wait.ms。它应该略大于「攒够 min.bytes 所需的时间」;流量越低,这个值越要小,否则延迟会被白白拉长。 - 复测。用固定的消息总量、固定的处理逻辑、至少三轮,看中位数而不是单次值。
每一步都要回答三个问题:改之前多少、改之后多少、为什么。答不上来的参数,先别改。
一张决策表
| 场景 | fetch.min.bytes | fetch.max.wait.ms | max.poll.records |
|---|---|---|---|
| 在线、低延迟 | 1 到 1KB | 0 到 10 | 10 到 100 |
| 一般业务、均衡 | 64KB 左右 | 50 左右 | 500 左右 |
| 离线、吞吐优先 | 256KB 到 1MB | 100 到 500 | 1000 到 5000 |
| 低流量分区 | 1(不要攒) | 0 到 10 | 按下游定 |
| 下游慢(数据库) | 小 | 小 | 小,按连接池容量 |
最后一行的逻辑是:下游慢的时候,攒大批次只会让一批的处理时间更长,max.poll.interval.ms 更容易被超过,积压从 Kafka 转移到客户端内存。这时候该做的是限流或 pause/resume(5.3),不是调大批次。
低流量分区是另一套逻辑
上面四组实验都是「固定积压」,broker 侧永远有数据。真实系统里还有另一种分区:每分钟只有几十条消息。对这种分区,参数的方向完全相反。
设某分区每秒到达 20 条、每条 200 字节,即 4KB/s。如果 fetch.min.bytes=1MB:
攒够 1MB 需要 1024KB / 4KB/s = 256 秒
但 fetch.max.wait.ms 最多等 1000 毫秒
→ 每次 fetch 都等满 1000 毫秒,只带回约 4KB、20 条记录
吞吐不会低于到达速率(数据总共就这么多),但延迟被固定拉长了 1 秒,而且每次 fetch 都在做无用功。这种分区应该用 fetch.min.bytes=1、fetch.max.wait.ms=0 到 10 毫秒,让 broker 有数据就立刻返回。
判断标准不是「主题整体吞吐」,而是每个分区的到达速率。一个主题里同时有热分区和冷分区时,同一组参数会同时服务两种流量,只能按更保守的那一侧取。
怎么确认调参真的有效
调参实验最容易犯的错是「改完跑一次,数字好看就上线」。一个可复现的验证流程至少包含:
- 固定输入:同样的消息总量、同样的消息大小、同样的分区数;
- 固定处理逻辑:不要让「这次少做了一次字符串拼接」混进结果;
- 至少三轮:取中位数,把三轮的最大值和最小值一起贴出来,读者才能判断稳定性;
- 同时记录吞吐和 p99:只报一个数一定会有误导;
- 记录环境:核数、堆大小、broker 版本、是否单节点。
本篇的四组数据都满足这五条,所以 D 组的 24.6 倍差距才敢写成结论,而不是「某次跑出来很慢」。
回到 libraryd
借阅投影的消费组最后用的参数是 B 组量级:fetch.min.bytes=64KB、fetch.max.wait.ms=50、max.poll.records=500。理由是投影更新要写一次下游数据库,单条约 0.3 毫秒,500 条一批约 150 毫秒,距离 max.poll.interval.ms 的默认值很远,同时也留出了足够的批次摊薄开销。
对账任务用的是 C 组量级:纯内存聚合,没有下游阻塞,批次越大越好,延迟不敏感。
两组参数都不是「越大越好」推出来的,而是先量了下游、再按上限公式校验、最后跑三轮确认的。D 组那种配置在生产上不会有人主动写,但它离「把 min.bytes 调到 8MB 试试」只有一步——参数的物理上限不会因为你的期望而改变。
阅读导航:上一节 12.2 异步提交的陷阱 | 下一节 13.1 多租户与配额
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。