压缩发生在批次上,不在单条消息上
Kafka 的压缩单位是 RecordBatch,不是单条 record。生产者在发送前把一个批次里的多条记录一起压缩,broker 原样落盘,消费者按批次解压。这个事实决定了两件事:
- 压缩比取决于批次里有多少数据。 同一份 JSON,单条压缩可能变大,几百条一起压可能小一个数量级。
batch.size和linger.ms会直接影响压缩收益。 批次攒不大,再好的算法也没有上下文可用。
所以「选哪个压缩算法」不能脱离「批次多大、消息多长、数据像什么」单独回答。本篇的实验固定前三者,只改算法,观察压缩后字节、发送耗时和 broker 落盘字节。
本机实测环境(Temurin 25.0.5+8 / kafka-clients 4.3.0 / apache/kafka:4.3.0 单节点 KRaft / 2026-10-29):宿主机客户端连 127.0.0.1:19892,单分区 topic,acks=all,batch.size=65536,linger.ms=50。所有耗时都是单次运行,本机还有其他实验并行,数字只用于同批对照,不作基准。
先解决一个环境问题:shaded jar 不带编解码器
kafka-clients:4.3.0 是 shaded 包,但不包含 lz4、snappy、zstd 的本地库。只把 kafka-clients 放进 classpath,配 compression.type=lz4 会在发送时炸:
SEND_SYNC_THROW class=org.apache.kafka.common.KafkaException
SEND_SYNC_THROW errors=UNKNOWN_SERVER_ERROR code=-1
SEND_SYNC_THROW message=java.lang.NoClassDefFoundError: net/jpountz/lz4/LZ4Factory
注意错误码是 UNKNOWN_SERVER_ERROR(-1),看起来像服务端问题,实际是客户端缺类。gzip 用 JDK 自带的 Deflater,不需要额外依赖;另外三种需要显式加 jar:
CP="$KAFKA_CLIENTS:$SLF4J_API:$SLF4J_SIMPLE"
CP="$CP:$HOME/.m2/repository/org/lz4/lz4-java/1.8.0/lz4-java-1.8.0.jar"
CP="$CP:$HOME/.m2/repository/org/xerial/snappy/snappy-java/1.1.10.7/snappy-java-1.1.10.7.jar"
CP="$CP:$HOME/.m2/repository/com/github/luben/zstd-jni/1.5.7-24/zstd-jni-1.5.7-24.jar"
JDK 25 还会对 JNI 加载打印 restricted method 警告,加 --enable-native-access=ALL-UNNAMED 可以消掉;它不影响结果。选型时要把这三个依赖算进产物体积:lz4-java、snappy-java、zstd-jni 都不是零成本。
实验数据长什么样
200 条 library.loan.created 事件,每条目标 4 KB,字段包括事件 ID、图书 ID、会员 ID、ISBN、时间戳、渠道、traceId 和一段由固定词表拼出的 note。payload 总字节 819,433。这不是随机字符串,也不是全 x,压缩比落在常见业务 JSON 的区间里。
两种测量:
- 本地编解码:把 200 条 value 拼成一个 819,433 字节的缓冲区,用
org.apache.kafka.common.compress.Compression逐算法压缩一次,记录输出字节与当前线程 CPU 时间。它隔离了网络和 broker,只看算法本身。 - 端到端发送:每种算法一个独立 topic,先发一条预热记录并 flush,再发 200 条,统计耗时、生产者
outgoing-byte-total,以及 broker 日志目录的字节增量。落盘字节用 Admin 的describeLogDirs读取,预热前后取差值。
本地编解码这一步用的是 4.x 的新 API。旧的 CompressionType 枚举在 4.3.0 里已经退到 org.apache.kafka.common.record.internal 包,业务代码应使用 org.apache.kafka.common.compress.Compression:
ByteBufferOutputStream out = new ByteBufferOutputStream(64 * 1024);
try (OutputStream os = Compression.of(type).build()
.wrapForOutput(out, RecordBatch.CURRENT_MAGIC_VALUE)) {
os.write(concatenated); // 同一个字节数组,逐算法压一遍
}
int compressedBytes = out.position();
这段代码必须在 classpath 里放对应的编解码器 jar,否则 NoClassDefFoundError 可能拖到真正写数据时才出现——错误发生得很晚,容易被误判成 broker 问题。
压缩比:四种算法差了两倍以上
codec none in=819433 out=819433 ratio=1.00x wallMs=2 cpuMs=2
codec gzip in=819433 out=117618 ratio=6.97x wallMs=25 cpuMs=25
codec snappy in=819433 out=240992 ratio=3.40x wallMs=222 cpuMs=31
codec lz4 in=819433 out=317807 ratio=2.58x wallMs=247 cpuMs=45
codec zstd in=819433 out=144064 ratio=5.69x wallMs=303 cpuMs=117
几个读数要注意:
- snappy 与 lz4 的
wallMs包含首次加载本地库的时间,所以看cpuMs更接近算法本身的代价:gzip 25 ms、snappy 31 ms、lz4 45 ms、zstd 117 ms。 - 在这份「半结构化文本」上,gzip 的压缩比高于 zstd。zstd 默认级别是 3(
CompressionType.ZSTD的DEFAULT_LEVEL = 3),gzip 用 JDK 的默认级别(Deflater.DEFAULT_COMPRESSION,实际相当于 6)。级别不对等时,不能拿一次实验结果给算法排座次。 - lz4 在这份数据上只有 2.58x。lz4 的设计目标是速度,不是压缩比;它的优势要在 CPU 受限或吞吐极高的场景里才体现。
发送耗时与 broker 落盘
同一批数据,逐算法发送:
| 算法 | 发送耗时 | 生产者发出字节 | broker 落盘字节 | 相对 none |
|---|---|---|---|---|
| none | 62 ms | 828,693 | 823,419 | 1.00x |
| gzip | 91 ms | 136,363 | 134,068 | 0.163x |
| snappy | 57 ms | 247,865 | 245,147 | 0.298x |
| lz4 | 71 ms | 324,763 | 321,686 | 0.391x |
| zstd | 61 ms | 158,049 | 155,733 | 0.189x |
发送耗时在 57–91 ms 之间,差异主要被首次请求、网络往返和本机调度吞掉,不能据此说 snappy 比 gzip 快。真正稳定的指标是字节数:落盘字节与本地压缩后的字节同向变化,量级也吻合(落盘略大,因为包含批次头和索引)。
本地拼接压缩与 broker 落盘的绝对字节并不相同:gzip 从 117,618 变成 134,068,zstd 从 144,064 变成 155,733,压缩比分别从 6.97x、5.69x 降到约 6.1x、5.3x。原因是端到端链路上压缩单位是 64 KB 左右的批次,不是 819 KB 的整块缓冲区;每个批次都要重新建立压缩上下文,整块数据的比例无法直接搬到落盘结果上。
本例中 gzip 与 zstd 的排序在两种测法下一致,但这不能推广:换一份数据,批次边界可能让排序翻转。用「把整个文件压一遍」的结果去预测落盘字节,会算错;用它做初筛可以,最终必须按真实 batch.size 跑端到端。
解压之后必须逐字节还原
压缩比的另一面是正确性:压缩后能解压回原始字节,才算真正可用。用消费者从每个 topic 的起点读回 201 条(200 条正式数据 + 1 条预热),统计解压后的 value 总字节:
compress.v3.none records=201 decompressedValueBytes=823528
compress.v3.gzip records=201 decompressedValueBytes=823528
compress.v3.zstd records=201 decompressedValueBytes=823528
三种算法解压后都是 823,528 字节,与生产端写入的总和完全一致。这个校验看起来多余,实际能抓到两类问题:编解码器版本不匹配导致解压出乱码,以及生产者与消费者对同一 topic 的压缩算法理解不一致(例如 broker 重压缩后旧消费者不支持)。
验证命令本身也有版本陷阱:kafka-console-consumer.sh 默认按 value 打印,遇到二进制内容会破坏终端;对比字节数要用 Java 消费者并指定 ByteArrayDeserializer,不要用 wc -c 数控制台输出。
CPU 代价要按「每 MB」算
本地编解码的 CPU 时间换算成每 MB:
| 算法 | CPU ms / 819 KB | 粗算 ms / MB |
|---|---|---|
| none | 2 | 2.4 |
| gzip | 25 | 30.5 |
| snappy | 31 | 37.8 |
| lz4 | 45 | 54.9 |
| zstd | 117 | 142.8 |
这张表只说明这一份数据、这一台机器上的相对关系。zstd 的 CPU 是 gzip 的 4.7 倍,换来的落盘字节却更多(在这份数据上),所以「zstd 一定比 gzip 好」不成立;如果 zstd 把级别调到 9 或 15,压缩比会上去,CPU 也会再上一个台阶。选型必须带上自己的 payload 和可接受的 CPU 预算。
负收益一:批次太小,压缩会让字节变多
把消息缩到 77 字节左右的短事件,2,000 条,总 value 154,000 字节,对比 batch.size=1(每条一批)与 batch.size=16384:
records=2000 rawValueBytes=154000 avgRecordBytes=77
local none wholeBatchOut=154000 perRecordOut=154000 (raw=154000)
local gzip wholeBatchOut=11211 perRecordOut=163937 (raw=154000)
local snappy wholeBatchOut=30556 perRecordOut=180708 (raw=154000)
local lz4 wholeBatchOut=38016 perRecordOut=172456 (raw=154000)
local zstd wholeBatchOut=3998 perRecordOut=161143 (raw=154000)
「逐条压缩」一列全部大于原始字节:gzip 变成 106%,snappy 117%,lz4 112%,zstd 105%。压缩流有自己的头部和块结构,数据量太小时,头比省下来的还多。
端到端结果一致:
| 算法 | batch=1 落盘 | 相对 none | batch=16384 落盘 | 相对 none |
|---|---|---|---|---|
| none | 306,750 | 1.00x | 186,714 | 1.00x |
| gzip | 312,688 | 1.02x | 27,476 | 0.147x |
| snappy | 333,200 | 1.09x | 49,690 | 0.266x |
| lz4 | 323,217 | 1.05x | 56,803 | 0.304x |
| zstd | 315,269 | 1.03x | 20,049 | 0.107x |
batch=1 时四种算法全部让落盘字节变大,gzip 多 1.9%,snappy 多 8.6%。同时 batch=1 的发送耗时是 816–1,364 ms(2,000 条记录几乎一条一个请求),而 batch=16384 只要 54–60 ms。压缩的负收益不是「多花一点 CPU」,而是「批次策略本身已经错了,压缩只是把错误放大」。
结论很直接:batch.size 小于几百字节、消息只有几十字节时,不要开压缩。 先把批次攒起来,再谈算法。
负收益二:数据本身不可压
把同样的 200 条 4 KB 记录换成随机字节(819,200 字节),结果:
codec none in=819200 out=819200 ratio=1.00x cpuMs=2
codec gzip in=819200 out=819468 ratio=1.00x cpuMs=18
codec snappy in=819200 out=819466 ratio=1.00x cpuMs=29
codec lz4 in=819200 out=819263 ratio=1.00x cpuMs=41
codec zstd in=819200 out=819230 ratio=1.00x cpuMs=117
压缩后字节全部略大于原始字节,CPU 白花。端到端落盘同样如此:none 823,186,gzip 823,718,snappy 823,777,lz4 823,472,zstd 823,427。
图片、PDF、zip、加密后的密文、已经压缩过的日志,都属于这一类。判断方法不是看文件扩展名,而是抽样跑一次压缩看比例:拿生产流量的样本,压一遍,比例低于 1.1x 就没有开启压缩的必要。
负收益三:broker 端重压缩与消费端解压
还有两笔账不在生产者的计时里:
- broker 重压缩:broker 的
compression.type默认是producer,表示保留生产者选择的算法。如果 topic 被显式设成另一种算法(例如生产端 zstd、topic 设 gzip),broker 会在写入前重新压缩一遍,花的是 broker 的 CPU。本篇没有单独测量这项开销,但配置上应避免「生产端一种、topic 另一种」的组合。 - 消费端解压:压缩省下的是网络和磁盘,解压的 CPU 转移到消费者。消费端 CPU 本来就紧张时,换更高压缩比的算法可能让消费吞吐下降,lag 反而变大。
这两项都要求在做容量规划时把「生产者 + broker + 消费者」当成一条链路算总账,而不是只盯生产者的发送耗时。
如何验证实际落盘用了哪种算法
不要相信配置,直接看段文件。用 kafka-dump-log.sh 打印批次头:
kafka-dump-log.sh --files /tmp/kafka-logs/compress.v3.zstd-0/00000000000000000000.log \
--print-data-log | head -4
baseOffset: 0 lastOffset: 0 count: 1 ... size: 1227 magic: 2 compresscodec: zstd crc: 2289734504 isvalid: true(中间字段略)
compresscodec: zstd 是落盘事实。这个字段还能用来排查「配置改了但没生效」:如果生产者设了 gzip、topic 又设了 zstd,这里显示的是最终落盘的算法。
级别是第二个旋钮,默认值并不对等
compression.type 只是选算法,级别由另外三个配置控制(snappy 没有级别)。4.3.0 的默认值来自 CompressionType 源码:
| 配置 | 默认值 | 取值范围 | 含义 |
|---|---|---|---|
compression.type | none | none/gzip/snappy/lz4/zstd | 生产者不压缩;broker 与 topic 的默认是 producer |
compression.gzip.level | -1 | 1–9 或 -1 | -1 是 JDK 默认,实际相当于 6 |
compression.lz4.level | 9 | 1–17 | Kafka 默认级别,与 lz4-java 常量对齐 |
compression.zstd.level | 3 | -131072–22 | zstd 的默认级别,负值表示「更快」 |
compression.type=zstd
compression.zstd.level=6
把 zstd 从 3 调到 6,压缩比会上升,CPU 也会上升;调到负值则相反。级别不是越高越好,它和「批次多大、CPU 预算多少、消费者能否跟上」一起决定。另一个容易忽略的点是:生产者的级别只影响生产端压缩;broker 的 compression.type=producer 表示原样保留,不会拿自己的级别再压一遍。
三条常见误判
- 按文件扩展名判断可压性。
.json不一定可压(大量随机 ID),.bin不一定不可压(重复的定长结构)。 - 拿一次样本给算法排座次。 本篇 gzip 在文本事件上赢 zstd,换成访问日志或高重复的指标数据,排序可能反过来。
- 忽略批次策略。 同一算法在
batch.size=1和 16384 下的落盘字节相差一个数量级,先调批次再调算法。
选型表
把本机数据放回具体场景,选型可以收敛成四句话:
| 场景 | 建议 | 理由 |
|---|---|---|
| 文本型业务事件,批次 ≥ 16 KB | gzip 或 zstd | 两者都能到 5–7x;zstd CPU 更高,gzip 兼容性最好 |
| 吞吐优先、CPU 紧张 | lz4 | 压缩比低但解压快,适合大量小批次 |
| 跨语言/老客户端多 | gzip | JDK 自带,零额外依赖,兼容性最稳 |
| 图片、压缩包、密文 | none | 压不动,只多花 CPU |
三条硬约束:
- 先有批次,再谈压缩。 低流量下
batch.size低于 16 KB、linger.ms=0时,压缩比会被批次大小锁死。 - 用生产样本做实验。 本篇的 6.97x 只属于这份数据;换成随机字节占主导的 payload,比例会掉到 1x 附近(本篇随机字节实测 1.00x)。
- 把依赖算进产物。 选 snappy/lz4/zstd 意味着多带一个几 MB 的 jar(zstd-jni 还含多平台原生库),容器镜像和启动时间都会变。
复现本篇实验的最小步骤
- 起单节点 broker,建 5 个同构 topic(每种算法一个),均 1 分区、RF=1。
- 编译
CompressProbe,classpath 带上 kafka-clients、slf4j 与三个编解码器 jar。 - 跑
CompressProbe 200 4096 <topic-prefix>,拿到本地编解码、发送耗时、describeLogDirs增量。 - 用
CountConsume从每个 topic 起点读回,确认解压后字节与写入一致。 - 用
TinyProbe跑 2,000 条 77 字节事件;用CompressProbe -Dfill=random跑随机字节,验证两个负收益场景。
每一步都只依赖 kafka-clients、编解码器 jar 与 JDK 标准库,不引入压测框架。数字会随机器和并行负载变化,但同一次运行内的横向对比是可用的。
回到 libraryd
libraryd 的事件是 JSON,字段稳定、重复度高,批次正常在 16–64 KB。团队最终选 gzip:压缩比接近 zstd,客户端零额外依赖,消费者解压开销可接受。对账、报表这类短事件主题不开压缩,因为单条不到 100 字节,压缩只会让落盘变大。
验收时按本篇的三组实验各跑一遍:4 KB 事件、77 字节事件、随机字节。三组都符合预期,才算真正理解「压缩比是批次和数据共同决定的」,而不是记住了一个算法名字。
阅读导航:上一节 11.1 消息大小与批量参数 | 下一节 11.3 大消息的 claim-check 模式
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。