
title: 日志压缩从 Snappy 切到 Zstd 省了 47% 带宽但延迟涨了 0.3ms这笔账我算了 3 天才敢拍板description: 深度对比 LZ4、Snappy、Zstd 三种压缩算法的实现原理、Java 集成方式和选型策略附真实压测数据。tags: [压缩算法, LZ4, Snappy, Zstd, Java, 性能优化]2024 年 3 月我们的日志采集链路带宽成本连续两个月超标。技术负责人让我在两周内把日均 12TB 的日志压缩率提升至少 30%。我当时的选择是把 Kafka producer 端的压缩从 Snappy 换成 Zstd。这个改动看似简单但实际上我花了 3 天时间做压测、算账、写回滚方案——因为压缩算法的选型从来都不是 哪个压缩率高就选哪个。三种算法的底层差异不是在比数字是在比设计哲学LZ4、Snappy、Zstd 都是现代高性能压缩库但它们的设计目标完全不同。LZ4追求的是 解压速度压倒一切。它的核心是一个基于 hash table 的简单匹配算法把输入数据切成 4 字节的 chunk用 hash 找前面有没有相同的序列有就记一个 (offset, length) 引用。压缩时只扫描一次解压时直接从引用位置拷贝——这就是为什么 LZ4 的解压速度能到 1-2GB/s。Snappy是 Google 为内部大数据场景设计的平衡了压缩率和速度。它用 32KB 的滑动窗口做字符串匹配比 LZ4 的窗口大一些所以压缩率稍好但解压因为要处理更多引用链速度比 LZ4 慢约 30%。ZstdZstandard是 Facebook 的作品它的杀手锏是可配置压缩级别1-22。级别 1 时接近 LZ4 的速度级别 22 时接近 gzip 的压缩率。底层用了有限状态熵FSE和哈夫曼编码的组合比 Snappy 的变长编码更高效。// Zstd 的 Java 绑定github.com/luben/zstd-jni // Maven: com.github.luben:zstd-jni:1.5.5-11 import com.github.luben.zstd.Zstd; public class ZstdDemo { public static void main(String[] args) { String log 2024-03-15 10:23:45 [INFO] orderId12345 userId67890 amount199.99 statusPAID\n.repeat(10000); byte[] input log.getBytes(StandardCharsets.UTF_8); // 压缩级别 3默认平衡型 byte[] compressed Zstd.compress(input, 3); byte[] decompressed Zstd.decompress(compressed, input.length); System.out.println(原始: input.length bytes); System.out.println(压缩后: compressed.length bytes); System.out.println(压缩率: (100.0 * compressed.length / input.length) %); // 典型输出原始 680000压缩后 4200压缩率 0.6% } }Zstd.compress(input, 3)的第二个参数就是压缩级别范围 1-22。级别 1 最快但压缩率最低级别 22 最慢但压缩率最高。默认值 3 是一个平衡点。注意Zstd.decompress需要传入原始数据长度因为 Zstd 的压缩格式不内嵌原始大小。我的压测环境和方法压测机器阿里云 ecs.c7.2xlarge8C16G本地 NVMe SSD。测试数据真实的业务日志单行 180-220 字节高重复率时间戳前缀、日志级别、固定字段名。// 压测框架三种算法在相同数据上的对比 public class CompressionBenchmark { private static final int WARMUP 1000; private static final int ITERATIONS 10000; public static void benchmark(String name, Compressor compressor, byte[] data) { // Warmup for (int i 0; i WARMUP; i) { byte[] c compressor.compress(data); compressor.decompress(c, data.length); } // 压缩速度测试 long start System.nanoTime(); for (int i 0; i ITERATIONS; i) { compressor.compress(data); } long compressTime System.nanoTime() - start; // 解压速度测试 byte[] compressed compressor.compress(data); start System.nanoTime(); for (int i 0; i ITERATIONS; i) { compressor.decompress(compressed, data.length); } long decompressTime System.nanoTime() - start; System.out.printf(%s: 压缩%.2f MB/s, 解压%.2f MB/s, 压缩率%.1f%%\n, name, (double) data.length * ITERATIONS / compressTime * 1e9 / 1024 / 1024, (double) data.length * ITERATIONS / decompressTime * 1e9 / 1024 / 1024, 100.0 * compressed.length / data.length ); } }Compressor是自定义接口统一了 LZ4、Snappy、Zstd 的调用方式。注意压测必须做 warmup因为 JVM 的 JIT 编译对这类循环密集型代码影响很大。System.nanoTime()比currentTimeMillis()精度高适合测微秒级操作。压测结果数字会说话但要会听算法压缩速度 (MB/s)解压速度 (MB/s)压缩率CPU 占用LZ4 (level 1)78021008.2%低Snappy52014006.1%中Zstd (level 3)1806503.8%中高Zstd (level 1)4206804.5%中这里的关键数字-Zstd level 3 的压缩率只有 Snappy 的 62%意味着同样的数据带宽只需要 Snappy 的 62%。对日均 12TB 的日志来说这就是从 12TB 降到 7.4TB每月省下的带宽费非常可观。-但 Zstd level 3 的解压速度只有 Snappy 的 46%650 vs 1400 MB/s。这意味着 consumer 端解压会更耗 CPU。-Zstd level 1是一个被很多人忽略的甜点压缩速度 420 MB/s接近 Snappy压缩率 4.5%比 Snappy 好 26%。我最终选的方案不是 Zstd level 3而是Zstd level 1。为什么因为 producer 端的压缩是批量的Kafka 默认 batch 16KB压缩速度 420 MB/s 完全够用而 consumer 端解压速度 680 MB/s 虽然不如 Snappy但我们的 consumer 不是 CPU 密集型瓶颈在 IO。Kafka 集成的细节不是改个参数就完事Kafka producer 的压缩配置在ProducerConfig里// Kafka Producer 压缩配置 Properties props new Properties(); props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka:9092); props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); // 关键compression.type 支持 none/gzip/snappy/lz4/zstd props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, zstd); // batch.size 和 linger.ms 影响压缩效率batch 越大重复性越高压缩率越好 props.put(ProducerConfig.BATCH_SIZE_CONFIG, 32768); // 32KB batch props.put(ProducerConfig.LINGER_MS_CONFIG, 10); // 等 10ms 攒 batch KafkaProducerString, String producer new KafkaProducer(props);compression.typezstd是 Kafka 2.1 才支持的。如果你的 Kafka 集群版本低于 2.1consumer 会不认识 Zstd 压缩的消息直接抛UnknownCodecException。这是我在 staging 环境踩的第一个坑——集群版本是 2.0.1producer 发出去的消息 consumer 解不开。升级集群到 3.5 后遇到的第二个坑Zstd 的 JNI 库加载。zstd-jni依赖本地 so/dll 库在 Alpine Linux 容器里默认找不到 glibc 兼容层。解决办法是在 Dockerfile 里加apk add --no-cache gcompat或者换 Debian 基础镜像。# Alpine 容器运行 Zstd JNI 的修复 FROM eclipse-temurin:17-jdk-alpine RUN apk add --no-cache gcompat # 提供 glibc 兼容层 COPY target/app.jar app.jar ENTRYPOINT [java, -jar, app.jar]延迟涨了 0.3ms这笔账怎么算切到 Zstd level 1 后端到端延迟producer → broker → consumer 处理完从平均 4.1ms 涨到了 4.4ms。这 0.3ms 来自两个地方Producer 端压缩耗时Snappy 压缩 1MB 数据约 2msZstd level 1 约 2.8msConsumer 端解压耗时Snappy 解压 1MB 约 0.7msZstd level 1 约 1.2ms0.3ms 的增长对我们的场景来说完全可接受——日志采集不是在线交易链路延迟不敏感。但如果是 RPC 请求压缩这 0.3ms 可能就是不可承受之重。我的判断标准是如果压缩/解压耗时占请求总耗时的比例超过 5%就不值得为了压缩率换算法。我们的日志链路总耗时 200ms含网络传输和落盘0.3ms 占比 0.15%可以忽略。LZ4 什么时候选LZ4 的解压速度是 Snappy 的 1.5 倍、Zstd 的 3 倍。如果你的 consumer 是 CPU 密集型比如解压后还要做复杂解析或者你的数据压缩率本身已经很好比如图片、视频的二进制数据LZ4 是更好的选择。我们有一个实时风控系统consumer 解压后要用正则匹配日志内容做特征提取。这个 consumer 的 CPU 使用率长期 85%用 Snappy 时解压占了 12% 的 CPU。换成 LZ4 后解压 CPU 降到 5%整体吞吐提升了 18%。总结压缩算法的选型公式不是 压缩率 ÷ 压缩速度而是带宽成本节省 - CPU 成本增加 - 延迟影响÷ 维护复杂度。Zstd level 1 是我目前最喜欢的甜点配置压缩率明显优于 Snappy速度可接受生态支持好Kafka、RocksDB、Linux kernel 5.9 都内置了。但我要提醒一点不要在生产环境直接切。先在 staging 用真实流量跑 48 小时监控 producer 的record-queue-time-avg和 consumer 的fetch-latency-avg确认没有异常再切。思考题你现在的 Kafka 或日志系统用的是什么压缩有没有算过 压缩省下的带宽费 和 解压多花的 CPU 成本 哪个更大如果数据重复率很低比如加密的二进制包LZ4/Snappy/Zstd 的压缩率会趋近于多少这时候还有必要压缩吗