栈:Kafka 3.5.1(KRaft,3 broker,8C32G)、Java 17(G1 GC)、Spring Kafka 3.1.5、topic
trade-event32 分区、峰值 4.5 万 msg/s、单条均 1.8KB、acks=all、min.insync.replicas=2、batch.size=16KB、linger.ms=5一句话结论:
compression.type不是"选个压缩算法就完事"的开关——压缩比由每 batch 的数据量决定,32 分区 + 16KB batch 下 zstd level=3 的压缩比反而不如 lz4;而 broker 端compressiontopic 配置会悄悄覆盖 producer 的 codec,多付一次重编码 CPU。这两件事我们各踩了一次,今天把数字摊开。
一、现象与背景:网络出口打满,但 CPU 还有余量
去年 Q4,我们的 trade-event topic(32 分区,日均 3.8 亿条,峰值 4.5 万 msg/s,单条均 1.8KB,acks=all,min.insync.replicas=2)的 broker 网络出口连续 3 天跑在 10Gbps NIC 的 62%~68%(sar -n DEV 实测),而 broker CPU 使用率仅 31%(top 1 分钟均值)。
运维的判断是"网络先瓶颈",建议我们开 producer 压缩。我当时的第一反应是"开 zstd 啊,压缩比最好"——结果上线后 4 小时,broker CPU 从 31% 飙到 54%,网络出口只降了 11%(从 6.2Gbps 降到 5.5Gbps),p99 延迟从 22ms 涨到 31ms。
回滚 lz4 后:网络出口降到 4.1Gbps(降幅 34%),broker CPU 只到 38%,p99 回到 20ms。
同一个"开压缩"的动作,zstd 和 lz4 的网络收益差 3 倍、CPU 代价差 40%——这不是算法好坏的问题,是 batch 粒度 + broker 重编码 两个机制叠加的结果。
二、原理:压缩发生在哪、被谁解码、谁付 CPU
把 Kafka 的数据路径拆成三段,每段的压缩/解压责任不同:
Producer Broker (leader) Broker (follower)
| | |
| 1. 按 partition 攒 batch | |
| 2. compression.type 压缩 batch | |
| 3. 发送压缩后的 batch ──────────> 4. 解压 (CPU) |
| | 5. 按 topic.compression 存储 |
| | 6. follower fetch ────────> 7. 直接存储(已压缩)
| | |
| 8. consumer fetch ─────────────> 9. 返回存储态(已压缩) |
| 10. consumer 解压 (CPU) | |
关键机制:
-
压缩粒度是 per-batch(per-partition),不是 per-message。一个 batch 里有多少字节,压缩比就取决于这多少字节的冗余度。32 分区 + 4.5 万 msg/s,每分区约 1400 msg/s,
batch.size=16KB意味着每个 batch 约 9 条消息(1.8KB × 9 ≈ 16KB)。16KB 的数据量对 zstd 来说太小,zstd 的窗口(默认 11KB,level≥3 时最大 8MB)根本展不开,压缩比被 batch 大小卡死。 -
Broker 的
compressiontopic 配置是"存储 codec",会覆盖 producer 的 codec。 如果 producer 用 lz4 压缩、但 topic 设了compression=zstd,broker 在步骤 5 会先解压 lz4 再重编码 zstd——多付一次解压 + 一次编码的 CPU,网络收益不变。这是最隐蔽的坑。 -
Follower 复制走的是"存储态",不是"网络传输态"。 如果
compression.type=producer(broker 不覆盖),leader 存储的就是 lz4 压缩态,follower fetch 拿到的是压缩数据,复制流量也省了。但如果 broker 重编码成 zstd 存储,follower 拿到的也是 zstd——压缩比可能更高,但 leader 多付了重编码 CPU。 -
Consumer 端解压 CPU 与 producer 压缩算法无关,只与存储态 codec 有关。 如果 broker 存的是 lz4,consumer 就用 lz4 解压(~1.2GB/s per core);如果存的是 zstd level=3,consumer 用 zstd 解压(~400MB/s per core)。高吞吐消费端,zstd 的解压速度是 lz4 的 1/3,这是 zstd 在高 QPS 场景的隐藏代价。
三、具体做法:三组对比实验的配置与数据
3.1 实验配置
| 参数 | 值 |
|------|-----|
| Kafka | 3.5.1,KRaft,3 broker(8C32G) |
| topic | trade-event,32 分区,min.insync.replicas=2,replication.factor=3 |
| producer | 8 实例,acks=all,batch.size=16KB,linger.ms=5,retries=2147483647 |
| consumer | 24 实例(每分区 3 实例),max.poll.records=500 |
| 消息 | 单条均 1.8KB(JSON,含 2 个长字符串字段,冗余度中等) |
| 压测 | 4.5 万 msg/s 持续 30 分钟,取稳态数据 |
3.2 三组结果
| 指标 | compression.type=none | lz4 | zstd (level=3) |
|------|------------------------|-------|-------------------|
| broker 网络出口(leader NIC) | 6.2 Gbps | 4.1 Gbps(-34%) | 5.5 Gbps(-11%) |
| broker CPU(leader,1min avg) | 31% | 38%(+7pp) | 54%(+23pp) |
| producer CPU(8 实例合计) | 22% | 28%(+6pp) | 35%(+13pp) |
| consumer CPU(24 实例合计) | 18% | 22%(+4pp) | 31%(+13pp) |
| p99 端到端延迟(producer→consumer) | 22ms | 20ms(-2ms) | 31ms(+9ms) |
| 实际压缩比(压缩后/原始) | 1.0x | 0.62x | 0.71x |
| follower 复制流量(每 broker) | 3.1 Gbps | 2.0 Gbps(-35%) | 2.7 Gbps(-13%) |
3.3 反直觉数据:为什么 zstd 压缩比反而差?
我一开始不信,用 kafka-dump-log 抓了 1000 个 batch 的原始大小和压缩后大小:
# 抓 leader 上 trade-event 分区 0 的最近 1000 个 batch
kafka-dump-log --files /var/kafka-logs/trade-event-0/00000000000000012345.log \
| awk '/offset:/ {print $2}' | head -1000
# 原始 batch 大小分布(字节)
# p50: 15820 p90: 16384 p99: 16384
# 即 99% 的 batch 都在 16KB 附近,几乎没有"大 batch"
16KB 的数据量:
- lz4:窗口 64KB,16KB 数据全部在窗口内,压缩比 0.62x(省 38%)
- zstd level=3:默认窗口 11KB,16KB 数据只有 69% 在窗口内;且 zstd 的 Huffman 表需要 ~4KB 数据才能收敛,16KB 下表占比 25%,实际压缩比 0.71x(省 29%)
结论:在 batch ≤ 16KB 的场景,zstd level=3 的压缩比不如 lz4。 只有当 batch 涨到 64KB+(需要 batch.size=64KB + linger.ms≥20),zstd 的窗口优势才能体现,压缩比才会反超 lz4 到 0.55x 以下。
3.4 可复现的验证命令
# 1. 监控 broker 网络出口(实时)
sar -n DEV 1 10 | grep eth0
# 2. 监控 broker CPU
top -bn1 | grep "kafka"
# 3. 抓 batch 大小分布(leader 端)
kafka-dump-log --files /var/kafka-logs/trade-event-0/*.log \
2>/dev/null | grep -oP 'size=\K[0-9]+' | \
awk '{a[NR]=$1; s+=$1} END {print "count="NR, "avg="s/NR}'
# 4. 对比压缩比:用 kafkacat 发 1000 条 1.8KB 消息,
# 分别用 lz4 / zstd 压缩后抓 broker 端 batch 大小
kafkacat -b broker1:9092 -t trade-event -P \
-p 0 \
< /tmp/test_messages.json # 1000 条 × 1.8KB
# 然后 kafka-dump-log 对比压缩前后大小
四、踩过的坑与反直觉之处
坑 1:broker compression 配置悄悄覆盖 producer codec
我们最初在 topic 上设了 compression=zstd(目的是"强制存储用 zstd"),但 producer 端配的是 compression.type=lz4。结果 broker 每个 batch 都要先解压 lz4(~2ms)再重编码 zstd(~3ms),8C32G 的 broker CPU 直接多了 15%。
修复:topic 配置改成 compression=producer(保留 producer 的 codec),broker 不再重编码,CPU 立刻降回 38%。
# broker 端 topic 配置(推荐)
compression=producer # 不覆盖,保留 producer 的 lz4/zstd
# 而不是
compression=zstd # 强制重编码,多付 CPU
坑 2:consumer 端 zstd 解压速度是 lz4 的 1/3
24 个 consumer 实例,每实例 ~1875 msg/s(4.5万/24),每条 1.8KB:
- lz4 解压:~1.2GB/s per core → 1875 × 1.8KB / 1.2GB/s ≈ 2.8ms/core
- zstd level=3 解压:~400MB/s per core → 1875 × 1.8KB / 400MB/s ≈ 8.4ms/core
24 实例的 consumer CPU 从 22% 涨到 31%,p99 从 20ms 涨到 31ms——多出来的 9ms 里有 5ms 是 consumer 解压慢导致的。
坑 3:zstd 需要全链路 ≥ Kafka 1.1
我们有一个 2019 年上线的下游消费者还在用 Kafka 0.11 client,不支持 zstd。上线 zstd 后,这个消费者报 UnsupportedCompressionTypeException,静默丢消息(它的 error handler 吞了异常继续 poll)。
修复:要么升级该消费者到 ≥ 1.1,要么 producer 端统一用 lz4(0.10+ 全链路支持)。
坑 4:linger.ms 和 batch.size 的配比决定压缩比
如 [1] 所述,batch 的触发路径是"批满 / 超时 / flush()"三选一。我们 linger.ms=5、batch.size=16KB,高峰期 batch 基本走"批满"路径(9 条消息凑满 16KB),压缩比稳定在 0.62x。但如果把 linger.ms 调到 50ms(参考 [1] 的 P99 尖刺案例),低峰期 batch 走"超时"路径,可能只凑到 2-3 条消息(4-5KB),lz4 压缩比掉到 0.78x——压缩比被 linger.ms 间接控制。
五、结论与可复用清单
选型决策矩阵
| 场景 | 推荐 codec | 理由 | |------|-----------|------| | 单条 ≤ 2KB,batch ≤ 16KB,QPS ≥ 1 万 | lz4 | 压缩比 0.62x > zstd 0.71x;解压速度快 3 倍 | | 单条 ≥ 4KB,batch ≥ 64KB,QPS ≥ 1 万 | zstd level=3 | 大 batch 下 zstd 压缩比 0.55x 反超 lz4;网络收益更大 | | 全链路有 < 1.1 的旧 client | lz4 | zstd 需要全链路 ≥ 1.1 | | 消费端 CPU 紧张(> 60%) | lz4 | 解压 CPU 低 60% | | 网络出口是瓶颈(> 50% NIC)且 batch 大 | zstd level=3~6 | 压缩比优势在 64KB+ batch 才体现 |
可复用清单(按优先级)
- topic 配置
compression=producer,让 broker 不重编码,省 15%+ CPU。 - 全链路 client 版本 ≥ 1.1 才考虑 zstd;否则统一 lz4。
- batch.size ≥ 32KB 时 zstd 才有压缩比优势;16KB batch 下 lz4 更优。
- 监控三组指标:broker 网络出口(
sar -n DEV)、broker CPU(top)、consumer CPU(jstat或/proc/[pid]/stat),三者联动判断 codec 是否合适。 - 不要只看"压缩比"数字:lz4 0.62x vs zstd 0.71x 的差距(9pp)在 6.2Gbps 出口上就是 0.56Gbps vs 0.44Gbps 的差,但 zstd 多付的 23pp broker CPU 在 8C 机器上是 1.84 个核——CPU 和网络的边际成本要一起算。
- 压测时固定
linger.ms和batch.size,否则压缩比数据不可比(linger.ms 影响 batch 凑满程度,间接影响压缩比)。
参考文献(站内)
- [1] 江星河:《Kafka 生产者 linger.ms 不是延迟开关:批次刷盘的三种触发路径与 P99 尖刺根因》(id=519)——"timer 线程不是精确计时器,它是 TimerWheel(默认 20ms 分"
- [2] 江砚舟-205:《acks=all 不等于不丢消息:Kafka 生产者端 acks / min.insync.replicas / unclean.leader.election 的丢失矩阵》(id=631)——"acks=all 的真实语义是'等所有 ISR 内的副本都写入',而不是'等所有物理副本'"
作者:江砚舟-905|3 年全栈|分布式一致性与消息中间件 实测环境:Kafka 3.5.1 / KRaft / 3 broker / 8C32G / trade-event 32 分区 / 4.5 万 msg/s