title: Zstd 级别拉到 19 那天,采集 agent 把自己压死了:LZ4、Snappy、Zstd 的 4 个真实账
topic: 压缩算法对比:LZ4、Snappy、Zstd
batch: 6
round: 3
我们日志采集 agent 早期直接把原始 JSON 往 Kafka 丢,带宽一个月烧掉 6 万。有人提议「上 Zstd,压缩比高」,我脑子一热把压缩级别设成 19(Zstd 最高档附近),心想压缩比越高越省带宽。结果 agent 部署后 CPU 从 15% 飙到 100%,把所在机器的其他进程全拖慢,Kafka 没省多少带宽却差点把采集节点搞崩。压缩不是「级别越高越好」,它本质是 CPU 和带宽的置换,级别调错就是拿 CPU 换那几个百分点的压缩比。这篇文章用我们交的学费,把 LZ4、Snappy、Zstd 三个常选算法的真实账算清楚。
事故:Zstd 级别 19,CPU 换来了多少带宽
我当时的代码(简化):
public class LogCompressor { private final ZstdCompressor compressor = new ZstdCompressor(); public byte[] compress(byte[] raw) { // level 19:追求极致压缩比 int level = 19; return compressor.compress(raw, level); } }逐行解释为什么这版是坑:
- 第 4 行new ZstdCompressor()用的是zstd-jni(Facebook 的 JNI 封装),它调用原生 Zstd 库,级别越高,压缩时搜索匹配串的窗口越大、尝试越深。
- 第 6 行level = 19是接近最高的级别。我们一条日志平均 800 字节,级别从 3 提到 19,压缩比只从 3.4 提到 3.9,但单条压缩耗时从 8 微秒涨到 210 微秒,涨了 26 倍。
- 采集 agent 单机每秒要压 4 万条日志,级别 19 时压缩线程直接 100% 满载,连带 GC 也涨(压缩中间 buffer 多),机器 load 从 2 飙到 20,同机上的订单上报服务 P99 都受了影响。
修正:级别降到 3,压缩比几乎没掉,CPU 砍回去了
public class LogCompressor { private static final int LEVEL = 3; // 性价比甜区,不是越高越好 public byte[] compress(byte[] raw) { return Zstd.compress(raw, LEVEL); // 静态方法,避免重复 new } public byte[] decompress(byte[] in, int originalLen) { return Zstd.decompress(in, originalLen); // 解压必须知道原始长度 } }逐行解释:
- 第 2 行LEVEL = 3是 Zstd 的「快速档」。实测级别 1~3 压缩比只比 19 低一点点(3.4 vs 3.9),但 CPU 只有 19 级的零头。我们把级别定在 3,带宽省了和 19 级差不多的量,CPU 回到 18%。
- 第 6 行Zstd.decompress(in, originalLen)提醒一个坑:Zstd 解压需要你提供原始长度(或帧里有记录),我们第一次解压忘了传长度,默认按 0 处理直接报错。压缩框架几乎都要求「压缩时记住原始长度」,写工具类时务必一起存。
- 第 2 行「静态方法」是我们踩过的另一个坑:new ZstdCompressor()每次 new 在高频调用下对象创建开销也不小,直接用Zstd.compress静态方法省掉对象。
三个算法的真实定位:一张对比表
光讲 Zstd 不够,我们把三个常用算法在日志场景压了一组数:
| 算法 | 压缩比(日志) | 压缩速度 | 解压速度 | 适合的场景 |
|---|---|---|---|---|
| LZ4 | 2.1 | 极快 | 极快 | 追求低延迟、CPU 敏感,压缩比要求不高 |
| Snappy | 2.4 | 很快 | 很快 | 和 LZ4 同类,Google 系生态默认(Hadoop/ES) |
| Zstd (lvl 1-3) | 3.3 | 快 | 极快 | 要兼顾压缩比和速度,带宽贵时首选 |
| Zstd (lvl 19) | 3.9 | 极慢 | 极快 | 离线归档、冷数据,CPU 不敏感 |
我的取舍:线上传输/采集这种「CPU 和带宽都敏感、且实时」的场景,Zstd 级别 1~3 或 LZ4 最稳;Snappy 是「生态默认」选(比如你们已经在用 Hadoop/Elasticsearch 的管道),单独引入没优势。Zstd 19 级只留给「一次写入、多次读、几乎不压缩」的离线归档,比如我们的历史日志冷存,压缩一次 CPU 烧了无所谓,省下的对象存储费用是真金白银。
第二个坑:小数据压缩反而膨胀
我们还有个场景:把每条 MQ 消息体(平均 200 字节)单独压缩后发送。上线后一看带宽没降反升,排查发现是「小数据压缩膨胀」。
public class SmallMsgCompressor { public byte[] pack(byte[] body) { byte[] z = Zstd.compress(body, 3); // 压缩后还要在头部写原始长度、算法标记等元信息 ByteBuffer buf = ByteBuffer.allocate(4 + 1 + z.length) .putInt(body.length).put((byte) 1).put(z).array(); return buf; } }逐行解释:
- 第 5 行ByteBuffer.allocate(4 + 1 + z.length)压缩后我们额外加了 4 字节原始长度 + 1 字节算法标记。对 200 字节的小消息,Zstd 压缩比只有 1.1 左右,加上 5 字节头,整体反而比原文大。
- 更隐蔽的:压缩算法为了建字典/写帧头,小数据常有固定开销(几字节到几十字节),数据越短越不划算。我们测过,单条 < 512 字节的消息,Zstd 级别 3 平均膨胀 6%;> 4KB 才开始稳定收益。
- 修法是「设阈值 + 批量」:单条 < 1KB 不压缩直接发,或者把多条消息攒成一批(比如 64KB 一个 batch)再压,批内冗余多、压缩比直接到 3.5 以上。我们改成批量压缩后,MQ 带宽降了 58%,比逐条压还省。
第三个坑:压缩级别和「数据特征」强相关
压缩比不是算法单方面决定的,和你的数据冗余度强相关。我们同一套 Zstd 级别 3:
- 应用日志(大量重复字段名、堆栈):压缩比 3.4
- 已压缩过的图片 base64 / gzip 过的上游报文:压缩比 0.98(几乎不压,甚至略膨胀)
- JSON 里塞了随机 UUID、签名串的:压缩比 1.3
这是很多人忽略的:如果你要压的数据本身已经「很随机」(加密、base64 二进制、UUID 满天飞),上什么压缩算法都白搭,CPU 白烧。我们的做法是「压缩前先判断数据类型」——带application/octet-stream或已知已压缩的,直接跳过压缩。这条规则上线后,采集 agent 的无效压缩 CPU 又降了 12%。
第四个坑:解压侧的 CPU 也是钱,别只算压缩侧
选算法时大家只盯着「压缩快不快」,忘了「解压」也要 CPU。我们的日志进 Kafka 后在 Flink 消费端解压,如果生产者用 Zstd 19 级压缩,消费者解压虽然比压缩快得多,但 Kafka 分区多、并行度高,累积解压 CPU 也不小。LZ4/Zstd 的解压都极快(比压缩快一个量级),Snappy 也是,三者解压侧差异不大;真正要警惕的是「生产者高压缩级别」会放大「消费者解压总量」。所以级别选择要站在「全链路 CPU 账单」看,不是只看发送端。
应用层压缩 vs HTTP 传输层压缩:该压哪一层
我们有个接口既在应用层用 Zstd 压了 body,又开着 Nginx 的gzip on,结果是「压缩两次」——应用层压完的字节流对 gzip 来说已经是高熵随机数据,gzip 几乎压不动还多烧 CPU,纯亏。
我的取舍很直接:要么在传输层统一 gzip/br(HTTP 天生支持,客户端透明解压),要么在应用层压(适合进 Kafka/Redis 这种不带传输层压缩的通道),二选一,别叠加。我们最终定:对外 HTTP 接口走 Nginxbr(brotli,比 gzip 还狠一点),关掉应用层压缩;对内 Kafka/Redis 走应用层 Zstd 级别 3。这条规则上线后,应用层压缩的 CPU 全省了,对外接口带宽靠 br 一样省下来,没损失。
还有一个常被问的:压缩级别和耗时到底是不是线性?我们压过一组日志(单条 800 字节,10 万条批量):
- Zstd 级别 1:压缩比 3.1,单条 5μs
- Zstd 级别 3:压缩比 3.4,单条 8μs
- Zstd 级别 6:压缩比 3.6,单条 22μs
- Zstd 级别 19:压缩比 3.9,单条 210μs
你看级别 1→3,压缩比涨 10%、耗时涨 60%,还能接受;3→19 压缩比只涨 15%、耗时涨 26 倍,完全不划算。这条曲线就是我们定级别 3 的硬依据:过了 3,每多一分压缩比都要几十倍的 CPU 换。
压缩失败要有回退:压完比原文还大就发原文
第四坑之后补一条工程纪律:压缩不是「压了就一定发压缩版」,要比较压缩后和原文大小,膨胀就回退原文。我们工具类最终长这样:
public class SafeCompressor { public CompressResult pack(byte[] raw, int level) { byte[] z = Zstd.compress(raw, level); // 压完比原文还大(小数据常见),直接发原文,省一次误事 if (z.length >= raw.length) { return CompressResult.raw(raw); } return CompressResult.compressed(z, raw.length); } }逐行解释:
- 第 5 行if (z.length >= raw.length)是回退闸门:小数据、随机数据压缩后常常比原文大,这时候发压缩版既费 CPU 又费带宽,不如直接发原文。我们线上小消息占比 30%,加这层后这部分流量不再无效压缩。
- 第 6 行返回CompressResult.raw(raw)时要在协议头里标「未压缩」,消费端按标记决定解不解压。压缩/未压缩必须带标记,否则解压端不知道该不该解——这是自研压缩通道最容易漏的一环。
复盘真实数字
- Zstd 级别 19:单条压缩 8μs → 210μs,采集 agent CPU 15% → 100%,同机订单服务 P99 涨了 40%。
- 降到级别 3:带宽节省从 19 级的「省 74%」变成「省 70%」,但 CPU 回到 18%,P99 恢复正常——4 个百分点的压缩比,不值 85% 的 CPU。
- 小消息改批量压缩(64KB/batch):MQ 带宽再降 58%,相比逐条压。
- 跳过「已压缩/随机数据」:无效压缩 CPU 再降 12%。
我的取舍:压缩是 CPU↔带宽的置换,别替一方代言
我不建议无脑上最高压缩级别。先问自己两件事:瓶颈在带宽还是 CPU?数据冗余度高不高?带宽贵、数据冗余高、且实时性要求一般 → Zstd 1~3 或 LZ4;CPU 已经紧张 → LZ4 优先,别碰 Zstd 高档;离线冷存、几乎不压缩 → Zstd 19 随便用。小数据先攒批或设阈值,别逐条压;已压缩/随机数据直接跳过压缩。一句话:压缩级别调高之前,先量「每多 1% 压缩比要烧多少 CPU」,多数时候级别 3 就是甜区,再往上纯属给账单添堵。
思考题
如果你的消息总线里 80% 是 < 512 字节的小消息、20% 是大报文,你会统一压缩还是按大小分流?分流的阈值怎么定才不引入新的判断开销?