《Java 消息中间件·可靠消息与消费治理》11.2 压缩算法选择

同一批事件,gzip、snappy、lz4、zstd 的压缩比可以差两倍以上,CPU 可以差五倍;批次太小或数据本身不可压时,压缩还会让字节数变大。本篇用同一份真实结构的事件跑四种算法,贴出压缩后字节、发送耗时与 broker 落盘字节,并给出负收益判据。

压缩发生在批次上,不在单条消息上

Kafka 的压缩单位是 RecordBatch,不是单条 record。生产者在发送前把一个批次里的多条记录一起压缩,broker 原样落盘,消费者按批次解压。这个事实决定了两件事:

  1. 压缩比取决于批次里有多少数据。 同一份 JSON,单条压缩可能变大,几百条一起压可能小一个数量级。
  2. 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
none62 ms828,693823,4191.00x
gzip91 ms136,363134,0680.163x
snappy57 ms247,865245,1470.298x
lz471 ms324,763321,6860.391x
zstd61 ms158,049155,7330.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
none22.4
gzip2530.5
snappy3137.8
lz44554.9
zstd117142.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 落盘相对 nonebatch=16384 落盘相对 none
none306,7501.00x186,7141.00x
gzip312,6881.02x27,4760.147x
snappy333,2001.09x49,6900.266x
lz4323,2171.05x56,8030.304x
zstd315,2691.03x20,0490.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.typenonenone/gzip/snappy/lz4/zstd生产者不压缩;broker 与 topic 的默认是 producer
compression.gzip.level-11–9 或 -1-1 是 JDK 默认,实际相当于 6
compression.lz4.level91–17Kafka 默认级别,与 lz4-java 常量对齐
compression.zstd.level3-131072–22zstd 的默认级别,负值表示「更快」
compression.type=zstd
compression.zstd.level=6

把 zstd 从 3 调到 6,压缩比会上升,CPU 也会上升;调到负值则相反。级别不是越高越好,它和「批次多大、CPU 预算多少、消费者能否跟上」一起决定。另一个容易忽略的点是:生产者的级别只影响生产端压缩;broker 的 compression.type=producer 表示原样保留,不会拿自己的级别再压一遍。

三条常见误判

  1. 按文件扩展名判断可压性。 .json 不一定可压(大量随机 ID),.bin 不一定不可压(重复的定长结构)。
  2. 拿一次样本给算法排座次。 本篇 gzip 在文本事件上赢 zstd,换成访问日志或高重复的指标数据,排序可能反过来。
  3. 忽略批次策略。 同一算法在 batch.size=1 和 16384 下的落盘字节相差一个数量级,先调批次再调算法。

选型表

把本机数据放回具体场景,选型可以收敛成四句话:

场景建议理由
文本型业务事件,批次 ≥ 16 KBgzip 或 zstd两者都能到 5–7x;zstd CPU 更高,gzip 兼容性最好
吞吐优先、CPU 紧张lz4压缩比低但解压快,适合大量小批次
跨语言/老客户端多gzipJDK 自带,零额外依赖,兼容性最稳
图片、压缩包、密文none压不动,只多花 CPU

三条硬约束:

  1. 先有批次,再谈压缩。 低流量下 batch.size 低于 16 KB、linger.ms=0 时,压缩比会被批次大小锁死。
  2. 用生产样本做实验。 本篇的 6.97x 只属于这份数据;换成随机字节占主导的 payload,比例会掉到 1x 附近(本篇随机字节实测 1.00x)。
  3. 把依赖算进产物。 选 snappy/lz4/zstd 意味着多带一个几 MB 的 jar(zstd-jni 还含多平台原生库),容器镜像和启动时间都会变。

复现本篇实验的最小步骤

  1. 起单节点 broker,建 5 个同构 topic(每种算法一个),均 1 分区、RF=1。
  2. 编译 CompressProbe,classpath 带上 kafka-clients、slf4j 与三个编解码器 jar。
  3. 跑 CompressProbe 200 4096 <topic-prefix>,拿到本地编解码、发送耗时、describeLogDirs 增量。
  4. 用 CountConsume 从每个 topic 起点读回,确认解压后字节与写入一致。
  5. 用 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 模式

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收