3.1 压缩算法:LZ4 与 Zstd 取舍

日志汇聚到中心后,第一笔开销是网络:十台机器各吐 10 MB/s 原文,中心入口就要吃下 100 MB/s,跨可用区传输费用按字节计算,一个月下来非常可观。第二笔开销是磁盘与对象存储:原文存三个月,存储费是压缩后的三到五倍。团队的目标很明确——把日志压小,但不能压慢管道。吞吐掉了,采集就会积压甚至丢日志,这就本末倒置了。

两个候选:LZ4 与 Zstd

日志是高度重复的文本:同一服务的日志行结构几乎一样,时间戳、模块名、级别反复出现,正是压缩算法最喜欢的场景。候选有两个:

算法 压缩速度 压缩率 特点
LZ4 极快(约 500 MB/s 每核) 一般(2~3 倍) 延迟低,CPU 省
Zstd 快(level 3 约 100~200 MB/s) 高(3~5 倍) 级别可调,上限高

Zstd 的级别从 1 到 22,级别越高压得越小、越慢;LZ4 也分级别但区间窄。关键区别在于延迟:LZ4 的压缩耗时基本恒定,适合在热路径上逐批做;Zstd 高级别压缩是给冷路径归档用的。

热路径用 LZ4,冷归档用 Zstd

实战的分工是:实时管道用 LZ4,对象存储归档用 Zstd。实时管道上,每攒一批(比如 4 MB)就压缩一次再发给下游,压缩耗时直接加在端到端延迟上,LZ4 的 2~3 毫秒几乎无感,而 Zstd 高级别要十几毫秒甚至更多。归档则相反,写一次读多次,压得越小省得越多,晚一两秒落盘根本没人在意。

// 热路径:LZ4 逐批压缩,frame 格式带长度头
use lz4_flex::{compress_prepend_size, decompress_size_prepended};

let batch: Vec<u8> = collect_batch();      // 攒好的一批日志
let compressed = compress_prepend_size(&batch);
channel.send(compressed).await?;           // 下游按长度解压
// 冷路径:Zstd 高级别压缩归档段
use zstd::stream::Encoder;

let f = std::fs::File::create(seg_path)?;
let mut enc = Encoder::new(f, 12)?;        // level 12,归档级
enc.write_all(&raw_segment)?;
enc.finish()?;

框架选型上,压缩这一层最好包成一个 trait,让 LZ4 与 Zstd 可以按配置切换——不是为了灵活而灵活,而是压测前你无法确定这批日志的重复度到底有多少。

压缩率从哪来:先做规整再压缩

同样是日志,压缩率能差一倍,差别往往在压缩前的规整。第一,同一批里的日志行尽量同构:nginx access 与业务 ERROR 混在一个批次里互相干扰,按日志种类分通道攒批,压缩率立刻好看一截。第二,字段顺序固定下来:同一字段在不同行里位置一致,重复模式才容易被压缩器识别。第三,时间戳之类的字段天然重复度极高,批量内相邻行的时间差很小,规整成「基准时间加增量」的形态能再省一截。

规整手段 压缩率提升 代价
按种类分批 明显 攒批通道变多
固定字段顺序 中等 上游需约束格式
时间增量编码 小 解压多一步还原

这三步都不难,但不去做的代价是真金白银的存储费。格式规整与存储结构的设计是相通的,可对照 云开发实战 里数据建模的思路。

级别怎么选

Zstd 的级别选择是这场取舍的核心。对一批典型的 nginx 访问日志做实测(常见值,供参考):

Zstd 级别 压缩率 单核吞吐 适用
1 3.2 倍 约 200 MB/s 实时要求较高的场景
3 3.5 倍 约 150 MB/s 默认起步
9 4.0 倍 约 60 MB/s 温数据
12 4.3 倍 约 40 MB/s 冷归档
19 4.6 倍 约 10 MB/s 极端省钱场景

经验:热数据 1~3,温数据 9,冷归档 12。超过 19 的级别收益极小(压缩率只多个百分之几),速度却断崖式下跌,除非存储费用真的贵到离谱,否则不值得。LZ4 类似,默认级别就够,调高了也是一两个百分点的差异。

分批压缩的粒度

压缩不是压整个文件,而是压攒出来的批。粒度太小(比如 4 KB),重复模式还在摇篮里就被切断了,压缩率掉到 1.5 倍以下;粒度太大(64 MB 以上),单次压缩耗时长,内存峰值也难看。实测 1~8 MB 是甜点区:压缩率接近全文件压缩的 95% 以上,单次耗时在毫秒级。

批大小 压缩率 单批耗时 内存峰值
4 KB 1.4 倍 极低 极低
1 MB 3.0 倍 约 2 毫秒 低
8 MB 3.4 倍 约 15 毫秒 中
128 MB 3.5 倍 约 250 毫秒 高

这与第二篇的攒批是同一个旋钮:攒批大小既要喂饱压缩器,又不能让延迟看上去。如果日志行很长(比如带堆栈),批可以小一点;短行密集的场景往大了靠。分批与背压的配合可对照 Go 微服务实战 的流控章节。

还有一个方向值得提一嘴:流式压缩。若写出端不能攒批(比如要求每条日志立刻可见),可以用流式压缩器(Zstd 的 stream 编码器、LZ4 的 frame 流)边写边压,压缩率掉一点但延迟恒定低。流式在采集端与管道中心都适用,取舍逻辑和分批压缩完全一样:重复度喂得饱就分批,喂不饱就上流式。

踩坑提示

第一个坑是压缩放在了序列化前:先压缩再序列化等于压一堆二进制,压缩率立刻打折;一定先把日志行序列化成连续文本再压。第二个坑是解压端算不出原始长度:LZ4 的 block 格式不带长度,解压时必须知道原始大小,用 frame 格式或自己前置 4 字节长度,否则下游只能干瞪眼。第三个坑是字典没复用:Zstd 支持-trained dictionary,对短批(几 KB)日志能显著提升压缩率,但训练字典要离线做、定期重训,否则日志格式一变,老字典反而拉低压缩率。短批场景的字典优化思路,与 MySQL 分库分表实战 中小分片的优化是同一个道理。

还有个隐蔽问题:压缩失败如何降级。极端情况下(某批全是随机串,压缩后反而变大),压缩后的数据可能比原文还大。稳妥做法是压完比一下长度,变大就直接发原文,并在头部打标记区分。

压缩线程的调度

压缩是 CPU 密集操作,放在异步任务里跑会抢占 tokio 工作线程,把别的任务拖慢。正确做法是用 spawn_blocking 把压缩投到专用阻塞线程池,和控制通道解耦。线程池大小按机器核数与压缩算法配:LZ4 单核就能追上大多数写入速率,Zstd 高级别则要多开几路并行。

配置 建议 说明
LZ4 阻塞池 1~2 线程 吞吐高、单核够
Zstd 归档池 2~4 线程 视级别与段产量
压缩线程优先级 低优先级 别抢业务的 CPU

另外,归档压缩不急着当场做完:段冻结后先进归档队列,慢慢排着压也来得及,反正它的 SLA 是小时级。这种「热路径立刻做、冷路径排队做」的意识贯穿整条管道,背压与排队的做法可对照 Go 微服务实战 的任务队列章节。

小结

热路径逐批用 LZ4 保延迟,冷归档用 Zstd level 12 省存储;批大小取 1~8 MB 的甜点区;解压端必须能拿到原始长度。压缩这一层的本质是拿 CPU 换网络与磁盘,而日志的重复度让你这笔交易稳赚不赔。上线前记住三个数:LZ4 走热路径,Zstd level 12 走归档,批大小 1~8 MB——三个数定了,这层的取舍就稳了。

延伸阅读

  • 攒批与定时刷的细节,见 2.4。
  • 压缩段如何落盘进 RocksDB,见 4.1。
  • 冷数据归档与存储类型选择,见 Go 云盘实战。
  • 管道部署的机器规格,见 Node.js 云开发实战。