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 云开发实战。
