每天一个开源项目#153 LZ4:用块边界和有序并行换取低延迟

1 阅读20分钟

日期:2026-11-06 GitHub Trending 排名:第 8 名;榜单采集时间:16:00(北京时间) 技术版本:LZ4 v1.11.0

给日志管道加压缩,看起来只是少传一些字节。真正上线时,问题往往出在别处:压缩线程抢走业务 CPU,小包等到缓冲区填满才发出,或者接收端读完网络数据,就误以为压缩流也完整了。

LZ4 很适合用来拆解这些问题。它的块格式很小,但并没有把文件封装、缓冲区所有权和并行调度混进同一个接口。理解这些边界,比记住一个“解压很快”的数字有用得多。

📋 项目概览

项目内容
主仓库lz4/lz4
定位面向低 CPU 开销的无损压缩算法、C 实现与命令行工具
本文版本v1.11.0;示例使用该版本的接口和 CLI
核心接口lz4.h 块接口、lz4hc.h 高压缩接口、lz4frame.h 帧接口
数据组织块内 literal/match 序列;帧负责块边界、描述符和可选校验
许可分界lib/ 使用 BSD 2-Clause;命令行等其他代码原则上为 GPL-2.0-or-later,个别文件另有声明
典型取舍少花压缩与解压 CPU,接受不一定最小的压缩体积

这里的“块”和“帧”不是同义词。LZ4_compress_default() 输出的是原始压缩块,不能直接当成普通 .lz4 文件。需要通用文件互通时,应使用帧接口或 CLI。

🔥 为什么值得关注

对在线系统来说,压缩效果应该按整条链路计算。节省的网络时间可能被压缩 CPU 抵消;磁盘空间变小,也可能伴随更高的读取延迟。日志、缓存页和批量数据传输尤其容易遇到这种矛盾。

LZ4 没有在块格式后面再接一层熵编码。解码器主要处理原样字节,以及“回到前面某个位置复制一段”的指令。压缩端可以改变搜索策略,接收端依然读取同一种块格式。因此,快速压缩与 HC 高压缩之间,增加的主要是编码端寻找更好匹配的工作,而非要求接收端换一套解码协议。

v1.11.0 还把多线程压缩带入帧库。这个变化值得注意:应用不必自行把原始块拼成文件、再设计任务排序协议。但多线程不会消除状态,它只是把状态放进了压缩上下文,包括尚未完成的任务、输入缓冲区和等待按序输出的结果。

对接入方而言,最有价值的问题由此变得具体:什么时候输入可以释放,什么时候必须 flush,什么时候返回的零表示“暂时没产出”,什么时候才表示“整个帧完成”。

🏗️ 核心特性

同一块格式,不同的编码成本

默认路径优先找便宜的匹配。LZ4_compress_fast() 还允许增加 acceleration,以跳过更多搜索位置来换取速度,压缩率可能下降。HC 则愿意在匹配选择上多花 CPU。

这不是无损与有损之间的切换。无论使用哪种搜索策略,正确解码后都应该还原输入字节。选择的差异在压缩体积和计算成本,不在数据精度。

原始块允许宿主接管元数据

块接口要求调用者提供压缩长度及解压目标容量。数据库页格式、消息协议如果已经保存了这些字段,就不必为每一小块重复附加通用文件头。

反过来,这也意味着“把压缩字节存起来就够了”不成立。没有准确边界,解码器无法自动判断一块在哪里结束;没有容量限制,接入方就无法约束内存消耗。选择原始块接口,等于选择自己维护容器协议。

帧接口负责可交换的数据流

帧头记录最大块大小、块是否独立、校验选项等信息;数据区由带长度的块组成,末尾有结束标记和可选内容校验。块压缩后如果不省空间,帧实现可以直接保存原始内容,并设置未压缩标志。

因此,不可压缩数据不会被强行表示成更大的压缩块,但整个帧依然有头部、块头和尾部开销。“支持原样存储”不等于“文件绝不膨胀”。

字典和历史都受生命周期约束

字典适合大量结构相似的小消息,编码端和解码端必须使用相同的字节。LZ4 只使用字典最后 64 KB 的内容;帧中的字典 ID 只是帮助选择字典的标识,不会把字典实体一起传过去。

连续块也能引用之前的内容。此时,旧输入不再是调用结束后就能随意覆盖的临时变量。原始流式 API 明确要求保留前面的历史;如果做不到,就要通过保存字典等方式把它迁移到稳定位置。

🔬 技术架构深度解析

1. 从文件到帧,再到块搜索

下面是 CLI 的常规压缩路径。小文件有单次压缩分支,较大的输入走 Begin、Update、End;两条路径最终都需要产生符合帧格式的输出。

lz4cli.c:解析压缩级别、块参数、线程数
    |
    v
lz4io.c:打开输入/输出,构造 LZ4F_preferences_t
    |
    +-- 小输入 --> LZ4F_compressFrame_usingCDict()
    |
    +-- 连续输入 --> compressBegin_usingCDict()
                         |
                         v
                   compressUpdate()
                         |
            +------------+----------------+
            |                             |
       单线程块缓冲                  多线程 job 槽位
            |                      worker 分别压缩
            |                             |
            |                      按提交顺序取结果
            +-------------+---------------+
                          v
                压缩块 / 未压缩块 + 块头
                          |
                          v
              compressEnd():排空、结束标记、可选校验
                          |
                          v
                       fwrite()

programs/lz4io.c 的 LZ4IO_compressFrame() 把文件读写和库调用分开。库返回一段压缩字节后,由 CLI 检查实际写入长度;写失败不是压缩器能够替应用完成重试的事情。压缩完成也不等于应用已经获得了事务化、原子替换的文件存储协议。

2. token 为什么能让解码保持简单

一个普通序列包括 token、可选长度扩展、literal、offset 和可选 match 长度扩展。末尾序列只有 literal,不带 offset。

token 的高 4 位:literal 长度的起始值
token 的低 4 位:match 长度减去 4 的起始值

[token][literal 长度扩展?][literal 字节][offset: 2 字节][match 长度扩展?]

高四位不满 15 时,直接给出 literal 长度;到 15 时,要继续读扩展字节。低四位描述匹配长度,最短匹配为 4 字节,达到饱和值时同样要读取扩展。offset 使用小端 16 位,合法距离最大为 65535 字节。

这里有两个容易混淆的尺度。帧中的单块可以达到 4 MB,回溯距离却仍然只有约 64 KB。把块设大,主要能减少分块开销、改变缓冲与调度,并不会把匹配搜索窗口自动扩展到 4 MB。

另一个细节是重叠复制。match 长度可以超过 offset,例如不断重复前一个字节。后续要复制的内容,可能就是这次复制刚刚生成的内容。手写解码器如果直接拿普通整段复制来替代这套语义,容易得到错误结果。实际实现还使用特化的小距离复制路径和批量复制优化,格式上的简单并不意味着随便一段 memcpy 就足够。

3. 快速压缩器如何少做搜索

lib/lz4.c 的 LZ4_compress_generic_validated() 维护哈希表,用当前字节位置的哈希找历史候选,再比较起始字节是否一致,并检查匹配距离。找到候选后,它向后扩展匹配、写出 literal 和 offset,最后编码 match 长度。

哈希相同不代表内容相同;距离超出窗口的候选也不能使用。快速路径不会为了每个位置穷尽所有历史匹配。在连续找不到匹配时,搜索步长会逐渐变化,acceleration 参与这个过程。这样做的代价是可能错过更好的匹配,收益是减少无效探测。

这解释了 LZ4 的方向:它愿意保留一些可压缩空间,换取较低的搜索成本。若业务更在意最终体积,HC 或其他压缩算法可能更合适,不能只因为默认编码速度快就下结论。

4. 流式压缩的输入和输出并不一一对应

在单线程路径 LZ4F_st_update() 中,输入先补足上次未完成的块;完整块可以直接压缩,剩余不足一块的内容进入内部缓冲。

所以,LZ4F_compressUpdate() 成功返回零,不代表没有接收输入,而可能表示输入全部被缓存了。默认非 autoFlush 模式下,小批次调用尤其容易出现这种情况。低延迟消息流如果只看“Update 已成功”,下游可能还收不到任何业务数据。

autoFlush 会改变这个权衡:不要等到块满再产出,但小块更多,固定开销和匹配机会也会变化。主动 LZ4F_flush() 是另一种边界选择,应按业务消息、时间窗或吞吐需求设计,而不是每写一个字节就 flush。

输出容量同样不能按原始长度拍脑袋计算。LZ4F_compressBound() 考虑当前调用的最坏输出,包括可能残留的块内容和收尾空间,但不包括已经由 Begin 输出的帧头。重复往同一目标缓冲区追加时,每次都要检查剩余容量,而不是只在首次分配时算一次。

5. 多线程压缩要并行工作,也要串行交付

v1.11.0 的 nbWorkers 仍标为实验性配置。默认零表示帧库的单线程模式;非零会把输入切成 job,由 worker 压缩,调用线程负责喂入与输出。job 通常为 1 MB,或者在块更大时容纳一个大块。

worker 可以乱序完成,文件不能乱序落盘。LZ4F_mt_emit() 先检查最早提交的 job;LZ4F_mt_emitOldest() 必要时等待它完成,再拷贝结果并回收槽位。后面的 job 即使已经完成,也不能绕过前面的字节顺序。这会产生队头等待,但换来了稳定、合法的压缩流。

独立块的并行结果与单线程结果一致。依赖块则在 job 开头带上前面 64 KB 的历史重新启动,可能和不切 job 的单线程输出略有不同;不同非零 worker 数之间,完整帧的结果不依赖线程调度。这个保证针对相同输入、参数和 flush 点,不能把改变 flush 边界后的输出也当成必须逐字节相同。

tests/fuzz/frameMT.c 不只做往返解压,还比较不同 worker 数的输出,并验证独立块与单线程输出一致、容量不足时报错。这是仓库中的测试断言。一个小例子即使通过,也不能代替这些组合测试或生产负载评估。

6. Update 返回后,输入能否释放

在默认 LZ4F_srcUnstable 模式下,库允许调用者在 Update 返回后修改或释放输入。为了兑现这个约定,必要的数据必须复制或保存到内部存储。

LZ4F_srcStable 则要求输入末尾 64 KB 在后续调用中继续存在且保持不变。v1.11.0 的 LZ4F_srcStableUntilFlush 更严格:除了历史窗口,相关输入整体还要保持到 flush 或 End 的对应边界,worker 才能直接读取原位置。它能减少复制,却把内存所有权责任交还给调用者。

这和“指针是 const”不是一回事。const 不会阻止另一个线程复用网络缓冲区,也不会阻止持有同一内存的生产者改写数据。接入异步系统时,应先解决缓冲区归属,再考虑减少复制。

CLI 对普通文件还可能使用 mmap。压缩过程中改写、截断源文件,会让直接读取映射内容的 worker 遇到不一致甚至读取故障。因此,正在写入的日志最好先轮转成稳定文件再压缩;--no-mmap 可以避开映射读取的部分问题,但它并不把动态文件变成某个时刻的原子快照。

7. 解压完成条件藏在返回值里

帧解码器是状态机:先收集帧头,分配必要缓冲,读取块头,再选择原样拷贝或块解压,最后处理校验与结束标记。输入不够一个头时,它可以暂存部分字节,等待下一次调用。

LZ4F_decompress() 中有两组输入输出参数:调用前是可用输入长度和输出容量,调用后变成实际消耗和实际产出。返回值另有含义。

结果调用者应如何处理
LZ4F_isError(ret) 为真解码失败,不能继续把当前上下文当正常帧使用
ret > 0帧尚未完成;值是下一次输入量的提示,也可能还需要输出空间
ret == 0当前帧完成;若仍有未消耗输入,它可能属于下一个帧

不要把 ret > 0 当成“本次解出了多少字节”,那是 *dstSizePtr 的工作。也不要在底层 socket EOF 时直接宣布成功:EOF 到来而帧尚未完成,应该作为截断处理。只有输出空间不足而未完成时,则应继续排空,不能误判成网络缺数据。

8. 校验与资源边界各管一件事

块校验覆盖块在帧中实际保存的内容;内容校验覆盖还原后的整个内容。它们帮助发现数据损坏,不是鉴权或抗恶意篡改的密码学协议。是否启用、何时完成校验,也决定应用何时能把输出视为完整可信的数据。

默认参数还有一个坑:CLI 默认独立块、启用内容校验;全零初始化的帧库默认配置则是依赖块、关闭内容校验。不能把命令行的默认行为直接套到 LZ4F_preferences_t 上。线上协议应显式设置关键参数,并确保收发双方支持相应选项。

安全解码接口可以约束读写缓冲区,却不会替应用决定最大的消息长度、总帧数量或解压后的总字节预算。即便帧头提供原始长度,也应先检查业务上限再分配。磁盘、网络和消息队列还需要各自的超时、背压与结果提交策略。

📖 README 核心内容摘要

README 最值得保留的,是性能数字的条件和接口选择。

维护者在 Core i7-9700K、Ubuntu 24.04、GCC 13.3.0、lzbench 2.4.1、Silesia Corpus 的单线程条件下,给出 v1.11.0 默认模式 770 MB/s 压缩、4950 MB/s 解压和 2.112 的压缩因子;HC -9 的对应数字是 40 MB/s、4800 MB/s 和 2.717。这说明维护者的这组数据中,提高压缩率明显增加了编码成本,解码仍保持较高吞吐。

这些数值属于维护者的指定基准,不是任意手机、日志或网络链路的速度承诺。小消息还要计入函数调用和封装开销,实际端到端延迟也受内存复制、存储和并发争用影响。

接口上,可以按已有协议边界选择:

  • 已经管理块长度、目标容量与校验的存储引擎,可考虑 lz4.h。
  • 希望交换标准 .lz4 数据或接入连续流,优先看 lz4frame.h。
  • 接受更慢编码来缩小文件,可以评估 HC,但应按自己的样本比较。

项目提供 Make、CMake、Meson 等构建路径。许可证则必须按目录和文件区分:将 lib/ 集成到应用,与直接抽取 CLI 的实现,不是同一许可边界。复用时应保留对应声明,不要把整仓统一写成 BSD。

🚀 快速上手

先做文件往返,不必安装到系统目录

下面使用 POSIX 环境中的 Git、Make、C 编译器和 Python 3,固定 v1.11.0。构建产物留在仓库内,无须执行 sudo make install。

git clone --depth 1 --branch v1.11.0 https://github.com/lz4/lz4.git
cd lz4
make -j2 lz4 liblz4.a
./lz4 --version

python3 -c 'from pathlib import Path; Path("demo.log").write_text("route=home status=200 latency_ms=12\n" * 2000)'
./lz4 -B4 -BI -BX --content-size demo.log demo.log.lz4
./lz4 -t demo.log.lz4
./lz4 -d demo.log.lz4 restored.log
cmp demo.log restored.log

-B4 是 64 KB 块,不是 4 字节;-BI 显式选择独立块;-BX 增加块校验;--content-size 把已知文件长度写入帧头。默认内容校验仍然保留。-t 丢弃解压输出,只做完整性检查。cmp 没有输出且退出零,才表示两个文件逐字节相同。

相同参数下,这个重复日志样本的 72000 字节输入生成了 414 字节帧,解压后逐字节一致。这个样本刻意重复,不能拿它推断真实日志压缩率。删除帧末尾两个字节时,完整性检查返回非零;翻转内容校验末字节时,报出 ERROR_contentChecksum_invalid。这两种失败与“成功解出了一部分文本”可以同时出现,应用必须检查最终结果。

示例文件名应在空目录中使用。再次运行遇到已有输出时,不要随手加 -f 覆盖重要文件;先确认目标。CLI 默认保留源文件,也没有要求加 --rm。

嵌入 C 应用:把长度带回来

仍在上述仓库目录,把下面代码保存为 roundtrip.c。这是一个内存块示例,不会生成能交给 CLI 读取的 .lz4 文件。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "lz4.h"

int main(void) {
    char src[4096];
    const char pattern[] = "route=home status=200 latency_ms=12\n";
    for (size_t i = 0; i < sizeof(src); ++i)
        src[i] = pattern[i % (sizeof(pattern) - 1)];

    int src_size = (int)sizeof(src);
    int capacity = LZ4_compressBound(src_size);
    if (capacity <= 0) return 1;
    char *compressed = (char *)malloc((size_t)capacity);
    char *restored = (char *)malloc(sizeof(src));
    if (!compressed || !restored) {
        free(compressed); free(restored); return 1;
    }
    int compressed_size = LZ4_compress_default(
        src, compressed, src_size, capacity);
    if (compressed_size <= 0) {
        free(compressed); free(restored); return 2;
    }
    int restored_size = LZ4_decompress_safe(
        compressed, restored, compressed_size, src_size);
    int ok = restored_size == src_size &&
             memcmp(src, restored, sizeof(src)) == 0;
    printf("roundtrip=%s input=%d compressed=%d\n",
           ok ? "ok" : "failed", src_size, compressed_size);
    free(compressed);
    free(restored);
    return ok ? 0 : 3;
}
cc -std=c99 -Wall -Wextra -Ilib roundtrip.c lib/liblz4.a -o roundtrip
./roundtrip

这个例子的输出为 roundtrip=ok input=4096 compressed=62。它检查了压缩失败、分配失败、解压长度和内容一致性,而不只是“函数没有崩溃”。解码时传入的 compressed_size 必须是实际压缩结果长度,不能用分配容量代替。

接真实数据时还要在进入这些 int 长度接口前检查范围,并为原始长度设置业务上限。不要先把外部任意 size_t 强转为 int,再指望库发现已经发生的截断。

流式接入前先写清状态约定

使用帧 API 时,至少明确四件事:输入所有权何时交回,何时需要立即输出,每次目标缓冲有多大,以及对端断开后怎样判断帧是否完整。

Begin 之后反复 Update,最后 End;v1.11.0 下如果 flush 或 End 返回值等于提供的容量,还可能有剩余输出,应继续调用同一个收尾操作直到完成,并检查每次错误。单线程且提供 LZ4F_compressBound(0, prefs) 容量时,一次收尾调用足够;不能把这个条件无差别套到多线程、小缓冲路径。

如果只是把文件交给另一个进程处理,先用 CLI 把这些行为跑通,通常比一开始就自建流式容器省事。

📊 增长速度与社区热度

LZ4 在当天捕获的 GitHub Trending 列表中排名第 8。榜单位置反映这次页面中的排列,不等于新增 Star 数,也不能据此计算增长率。对于这种基础组件,更实际的关注点是接口兼容、格式稳定、错误处理和测试覆盖;它们会直接影响存量数据还能不能被正确读出。

以下为当天捕获的完整榜单,按原始顺序列出。

排名仓库
1NVIDIA/auto-ontology
2mattpocock/skills
3garrytan/gstack
4Usagi-org/ai-goofish-monitor
5D4Vinci/Scrapling
6vercel-labs/agent-desktop
7max-sixty/worktrunk
8lz4/lz4
9google/skills
10vercel-labs/agent-browser
11scenario-labs/skills
12ConardLi/garden-skills
13microsoft/VibeVoice
14floci-io/floci
15JiaFeiMiao-K-Cat/DiscImageStudio

🎯 适用场景

如果在做缓存页、短生命周期数据块或延迟敏感的传输,LZ4 值得评估。前提是用实际样本计算节省的 I/O 是否大于编码、解码和复制成本。原始块接口适合已经有容器协议的系统,帧接口则适合希望复用标准封装的应用。

对 Android 或其他移动端,先选清楚绑定层的数据格式。一个库名里有 LZ4,不代表它输出标准帧;有的绑定在原始块前面附加自己的长度字段。JNI 两端还要协商长度范围、错误码和缓冲区生命周期。不能拿一个进程里的原始块示例,推导出跨语言、跨工具一定互通。

下面几类需求不应直接交给它解决:

需求为什么还需要别的设计
长期冷存储,优先追求最小体积LZ4 的快速默认路径会主动减少搜索,需与更重的压缩方案比较
对加密文件或已压缩媒体再次压缩可利用的重复可能很少,帧开销与额外 CPU 可能得不偿失
任意位置随机读大文件标准帧本身不提供随机访问索引;独立块只是必要条件之一
防篡改或来源认证普通校验只处理损坏检测,仍需要可信传输或密码学保护
超小消息逐条实时发送帧头、校验与 flush 成本可能占比很高,应比较批处理或字典方案

此外,并行压缩不代表并行解压已经自动发生。独立块创造了并行解码的可能性,但 CLI 的常规解压仍是单线程;真正按块调度、建立偏移索引和管理输出位置,是另一层工程问题。

💡 总结

LZ4 的优势来自明确的取舍:块格式只保留简单的 literal 与匹配复制,帧层再解决交换格式、流式缓冲和有序并行。编码端可以增加搜索成本,接收端不必随之更换格式。

接入时先确定块还是帧,再确定容量、历史窗口和完成条件。速度数字值得参考,输入什么时候能释放、错误之后能不能继续、EOF 是否代表完整,这些约定才决定程序能否可靠处理真实数据。