ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

pigz并行压缩:让gzip多线程加速,备份与日志处理效率倍增

pigz并行压缩:让gzip多线程加速,备份与日志处理效率倍增

做运维和数据备份的同学,估计都有过这种体验:服务器明明几十个核在闲着,可你执行一个gzip压缩,CPU 使用率却只有一条线在跑,眼看着一个 10GB 的日志文件要压上十分钟甚至更久。这个痛点我太熟悉了。后来换到pigz之后,同样的文件、同样的压缩等级,压缩时间直接降到了原来的四分之一甚至更低。pigz 这个名字其实就是 Parallel Implementation of GZip 的缩写,简单说就是“并行版 gzip”。它既能输出标准 gzip 格式,解压的时候还能用普通 gzip 直接解,又能在压缩时把多核 CPU 全部用起来。这篇文章就围绕 pigz 的原理、安装、参数调优、实战配合和踩坑记录展开,适合正在做日志归档、数据库备份、大数据导出的工程师参考,也适合想了解并行压缩原理的人读。

1. pigz 是什么,为什么需要更快的压缩

1.1 单线程 gzip 的瓶颈在哪里

gzip 是目前使用最广泛的压缩格式之一,日志文件、软件包、网络传输、数据库备份里到处都是它。它底层的 deflate 算法由 LZ77 滑动窗口和 Huffman 编码组成,压缩时要在前面 32KB 的数据里找重复字符串,生成“引用”和“字面量”,再对结果做熵编码。这个过程有一个天然限制:后续数据的压缩高度依赖前面已经处理过的数据,所以传统的 gzip 实现只能老老实实地一个字节流顺序处理到底。

单线程带来的问题在现在这个硬件环境里越来越明显。一台常见的服务器动辄十几核甚至几十核,但 gzip 进程永远只会占满一个核心。做大数据量压缩时,CPU 不是不够,而是“能用上的太少”。这就造成了一种很奇怪的情况:机器的整体负载不高,但任务就是要等很久。尤其在实时日志采集、每日全量备份这类场景里,压缩环节经常是整个数据管道的瓶颈。

有人会想“那我换更快的压缩算法不就行了”,比如 zstd、lz4、brotli 这些新算法确实更快,但问题是 gzip 格式太普及了,下游的存储系统、传输协议、旧脚本解压工具不一定支持新格式。很多团队没法因为性能问题就随便改压缩格式。pigz 的思路则完全不同:它不改变 gzip 格式,只是让 gzip 的压缩过程从“一个人干活”变成“多个人并行干活”。

1.2 pigz 的定位与核心优势

pigz 可以理解成一个“用多线程重写过的 gzip 兼容工具”。它的命令格式、参数习惯、输出文件都跟 gzip 基本一致,唯一区别就是能够利用多个 CPU 核心并行压缩。对使用者来说,最大的好处是迁移成本极低:原来脚本里写gzip的地方,直接换成pigz就能获得明显的速度提升,不需要改任何下游逻辑。

具体来说,pigz 有四个比较核心的优点。

第一,多线程并行压缩。线程数可以通过-p参数指定,默认会按 CPU 核心数自动配置,在小文件和大文件上都有收益。

第二,保留了完整的 gzip 兼容性。pigz 压缩出来的文件是标准 gzip 格式,包含正确的文件头、CRC32 校验和以及原始长度信息。你拿它压缩的文件,用任何标准 gzip 解压器都能解开。

第三,压缩等级可调。和 gzip 一样,pigz 支持-1到-9的压缩级别,级别越高压缩率越好但速度越慢。在并行模式下,就算用-9,往往也比单线程的-6快,这点很实用。

第四,解压也支持并行。pigz 的-d模式不是简单地委托给单线程处理,而是会尽量识别 gzip 流的边界,把多个成员分给不同线程同时解压。

我自己的体会是,pigz 最适合三类场景:大日志文件归档、数据库逻辑备份后用管道直接压缩、以及需要长期保留 gzip 格式但又嫌压缩太慢的批处理任务。

2. pigz 核心原理:并行压缩是怎么实现的

2.1 并行难点与数据分块策略

先回答一个很多人问过的问题:gzip 的 deflate 算法是强顺序依赖的,pigz 到底怎么并行?

关键在于“滑动窗口”。LZ77 的匹配是指向前面的 32KB 数据,理论上每一个字节的压缩结果都依赖之前的内容,严格串行。pigz 的解决办法是把输入数据切分成多个独立的块,每一块交给一个线程独立压缩。每个线程在处理自己的块时,只能引用该块内部的数据,不能跨块引用。

这样做的代价是压缩率会略有下降。因为原本 gzip 可以从整个历史数据里找最长匹配,分块之后,每个块的起始部分已经失去了前面的上下文,无法引用更早的数据,只能把开头几个字节当作字面量输出。

为了把这个代价控制在可接受范围内,pigz 设计了一个比较讲究的分块策略。块不会切得太小,默认的块大小是 128KB,每个线程处理一个完整的块,块与块之间用压缩流自身连接的机制串起来。最终输出的数据在 gzip 格式上表现为一个包含多个成员(member)的 gzip 流,或者是同一个 deflate 流中块与块之间的无缝衔接。实际效果是,在-6级别下,pigz 的压缩体积通常只比单线程 gzip 大 1% 到 3%,很多场景下完全可以忽略。

拿生活里的例子类比一下:传统 gzip 相当于一条流水线上只有一名厨师,从洗菜、切菜、炒菜到装盘全是自己来。pigz 相当于把食材按份数切好,几名厨师同时开工,各自炒自己的那份,最后按顺序端上桌。菜的味道会有一点差异,但整体出菜速度快了非常多。

2.2 线程池与数据重排机制

并行压缩不是简单地“多开几个 gzip 进程”就行,更麻烦的是要保证输出文件的顺序和内容与单线程 gzip 一致。pigz 内部的实现大致分三层:主线程负责读取输入、分配任务、汇总输出;工作线程池负责实际压缩;还有一个结果重排模块负责把各线程产出的压缩数据按照原始顺序写出去。

读数据的时候,主线程会按顺序把输入分成块,给每个块生成一个序号。线程池中的每个线程同时只能处理一个块,处理完以后不会立刻写入最终输出,而是先把压缩结果交给结果队列。输出模块只允许按序号顺序写入,如果下一序号的结果还没完成,就先等一等,如果下一序号已经完成就直接写并继续推进。这种设计避免了多线程同时写同一个文件造成的数据错乱,也保证了输出尺寸、内容都符合 gzip 规范。

从资源消耗角度看,每个工作线程都需要独立的压缩状态。deflate 编码器内部有窗口缓冲区、哈希表、输出缓冲区等,线程数越大,占用的内存也就越多。所以 pigz 并不是线程数开得越多越好,实际调参时要在 CPU 核数、内存容量、任务对压缩率的要求之间做一个平衡。

这也解释了为什么 pigz 解压也能并行。gzip 格式本身支持多个成员串联,pigz 解压时会先扫描输入文件里的成员边界,把每个成员独立地分给不同线程解码。对于pigz自己压缩出来的多成员文件,解压并行度的收益会比较明显;对于只有一个大成员的文件,并行解压就主要靠 pigz 对 deflate 块的边界识别能力了,收益会小一些。

2.3 与标准 gzip 的格式兼容性细节

很多人担心 pigz 压缩出来的文件“不是标准 gzip”。这一点可以放心。gzip 文件格式本身允许多个成员连续存放,每个成员都有自己独立的文件头、压缩数据、CRC32 校验和以及 ISIZE 原始长度字段。pigz 正是充分利用了这个特性,让每个线程压缩一个块,最后按顺序拼成合法多成员 gzip 文件。

所以你在使用上可以看到这些表现:

普通gzip -t可以验证 pigz 压缩的文件,结果是正常通过。

普通gunzip可以直接解压 pigz 的文件,不需要安装 pigz。

tar命令也能正常读取 pigz 压缩出的tar.gz,因为 tar 关心的是解压出来之后的内容,而不关心压缩流是单成员还是多成员。

不过,也存在极少数老旧的、实现不规范的工具,可能只读取 gzip 流的第一个成员,导致解出来的内容不完整。这个我在后面“问题排查”章节会专门说明,如果你的下游工具链非常古老,建议先做一次小样本验证。

3. 安装与基础用法

3.1 各平台安装方法

pigz 是一个开源命令行工具,常见平台基本都能直接安装。在 Linux 上,大多数发行版的软件源里都有 pigz 包。如果你所在的环境没有现成包,也可以从源码编译,依赖项很少,只需要一个 C 编译器和 make。

以源码编译为例,操作流程一般是这样的:

# 从官方源码仓库下载源码包并解压 tar -xf pigz-*.tar.gz cd pigz-* # 并行编译,nproc 可以获取当前 CPU 核心数 make -j$(nproc) # 安装到系统目录,需要 root 权限 make install

macOS 上同样可以用包管理器直接安装,如果没有包也可以走源码编译,过程差不多。Windows 环境建议优先考虑 WSL 或者在容器里使用,原生 Windows 下编译虽然也能做到,但生态支持不如类 Unix 环境顺手。

装好以后验证一下版本:

pigz --version

如果输出版本号,说明安装成功。从我使用过的发行版来看,pigz 版本在 2.4 到 2.8 之间都比较常见,新版本对线程调度和缺陷处理会更好一些,优先用最新版本。

3.2 基本命令与参数解读

pigz 的命令行参数继承了 gzip 的大部分习惯,老用户上手几乎不需要学习成本。先列几个最常用的用法。

压缩单个文件并保留原文件:

pigz -k -p 8 backup.sql

这条命令会生成backup.sql.gz,原文件因为加了-k保留下来,线程数设为 8。

输出到标准输出,方便接管道:

pigz -c -p 8 backup.sql > backup.sql.gz

解压:

pigz -d -k -p 8 backup.sql.gz

测试压缩文件完整性:

pigz -t backup.sql.gz

其他常用参数的说明,我整理成一个表格:

参数作用备注
-p N指定并行线程数一般不超过物理核心数
-1到-9压缩等级-6通常最均衡,-9压缩率最高
-k保留输入文件gzip 默认会删除原文件,pigz 默认与 gzip 一致,需注意
-c输出到标准输出适合管道和重定向
-d解压模式等价于--decompress
-t测试文件完整性不会生成解压文件
-r递归处理目录对目录内文件逐个压缩
-f强制覆盖输出文件已存在时使用
-l列出压缩文件信息类似gzip -l
-b N设置分块大小会同时影响压缩率和内存占用
-S设置压缩文件后缀默认是.gz

这里需要特别提醒-k参数。gzip默认压缩成功后会删除原始文件,pigz 的设计也保持了这个行为。很多新手第一次用 pigz,发现原文件消失了还以为出故障,其实是默认行为。脚本化使用时要特别注意这一点,不想删原文件就加-k。

3.3 压缩解压速度对比实测

我个人的习惯是,任何工具拿到手先做一轮简单压测,不拍脑袋决定线程数。压测场景可以很简单:准备一个 10GB 左右的访问日志文件,分别在单线程 gzip、多线程 pigz 下跑相同压缩等级,记录耗时和输出体积。

硬件环境我一般会选择一台常见配置的多核虚拟机,比如几十核、内存充足那种,具体型号不重要,重要的是对比逻辑。用-6级别压测下来,单线程 gzip 的吞吐量大致在 40MB/s 到 60MB/s 之间,而 pigz 开 8 个线程通常能达到 200MB/s 以上。如果机器核数更多,线程数开到 16,吞吐量还能再往上走,但收益会递减。

压缩率方面,经典 gzip 单线程和 pigz 的差异通常在 1% 到 3% 之间。例如一个原始日志文件 10GB,gzip 压出来可能约 500MB,pigz 压出来约 510MB,差距也就是 10MB 左右,对绝大多数备份场景来说完全可接受。如果项目对体积极为敏感,可以适当调大块大小参数,或者把压缩等级从-6提到-9,压缩率差距会进一步缩小,代价是耗时增加。

解压速度的对比也有意思。gzip 解压本身就比压缩快不少,单线程解压时吞吐量可能就有 200MB/s 到 300MB/s。pigz 在解压多成员文件时,并行收益才能体现出来。我自己压测时看到过 700MB/s 以上的解压吞吐量,不过这个数字依赖文件特征,不用当作绝对标准。

4. 实战配置与优化技巧

4.1 线程数选择与 CPU 亲和性

线程数是使用 pigz 时最需要认真调的参数。理论上线程数越多压缩越快,但实际上并不是线性关系。

第一,pigz 的工作线程只负责压缩计算,不负责磁盘 IO。当线程数超过物理核心数时,CPU 开始争抢时间片,压缩速度提升非常有限,反而增加上下文切换开销。我建议先看物理核心数,而不是逻辑线程数。很多 CPU 开了超线程,逻辑线程是物理核心的两倍,但 pigz 在物理核上表现更好。判断方法很简单:先跑一次单线程,再逐步提高线程数观察耗时曲线,找到拐点。

第二,容器环境要额外注意。Docker 容器里看到的 CPU 数量可能并不是宿主机真实的可用资源。如果你的容器限了 4 个核,却在容器内用nproc看到了宿主机的大量 CPU,此时贸然把-p开到几十,会因为调度争抢反而变慢。稳妥的做法是显式指定线程数,不要依赖默认值。

第三,在多任务并行环境下,可以结合系统自带的 CPU 绑核工具使用。例如先固定 pigz 进程的可用 CPU 集合,再让它跑压缩,这样能避免和其他服务争抢资源。不过这种优化属于锦上添花,日常批处理不强制需要。

我自己常用的一个经验值是:线程数设置为物理核心数的一半到三分之二。比如一台 16 核物理服务器,pigz 用-p 8到-p 12都行,既不会把机器压死,压缩速度也足够快。如果是专用压缩服务器,完全可以把线程数设满,配合-p和压缩等级做到最大化吞吐。

4.2 压缩级别与分块大小的组合选择

pigz 的压缩级别和分块大小是两个相互关联的参数,实际调优时不能单独看一个。

-1代表最快压缩,算法会使用较短的搜索深度,单线程吞吐就已经很高了,并行下更突出,但压缩率最低。适合临时文件、缓存数据、需要极速处理的场景。

-6是默认级别,也是大多数备份场景的首选。压缩率适中,速度在可接受范围。pigz 在这个级别下并行收益最明显,因为单线程-6本身耗时已经较长,一并行效率提升非常直观。

-9代表最高压缩率,单线程下会明显变慢。但在并行模式下,8 线程-9往往还比单线程-6快,同时压缩率更好。因此如果你的 CPU 核数充足,完全可以放心用-9搭配多线程,兼顾体积和速度。

块大小参数-b值得单独说明。块越大,每个线程能引用的历史数据越多,压缩率越好,但内存占用也会增加。压缩大文件时,适当调大块大小能缩小 pigz 与 gzip 的体积差距。例如默认 128KB 块大小,改成 256KB 或 512KB,压缩时间增加不多,压缩率却可能改善不少。反过来,如果内存紧张,可以减小块大小,牺牲一点压缩率换取更低的内存水位。

这里给出一个基于实践的组合建议表:

场景推荐参数说明
快速临时压缩-1 -p 8追求速度,对体积不敏感
通用备份-6 -p 8默认均衡方案
追求高压缩率-9 -p 12 -b 256KCPU 充足时使用
内存受限环境-6 -p 2 -b 64K降低内存占用和 IO 压力

4.3 大文件场景下的内存与磁盘策略

在处理超大文件时,pigz 只是让 CPU 不是瓶颈,但磁盘 IO 和内存会成为新瓶颈。

内存方面,每个线程都有自己的压缩上下文,包括滑动窗口、哈希表和输出缓冲区。虽然单个线程占用的内存不算大,但线程多起来以后总量也不容忽视。例如把块大小调到 512KB、线程开到 16,再加上系统页缓存,整体占用可能达到几百 MB 甚至更多。对于动辄并行跑几十个压缩任务的环境,这个内存量必须提前预留。

磁盘 IO 方面要注意一个很典型的问题:如果输入和输出都在同一块机械硬盘上,读取和写入会互相争抢磁盘磁头。pigz 压缩速度变快后,盘面反而可能被打满。我在实践中会尽量把输出文件写到不同的存储设备上,例如输入在系统盘、输出在数据盘,或者输入在机械盘、输出在 SSD 上,这样读写并行度会明显改善。

还有一个很实用的技巧:用-c输出到标准输出,配合重定向写文件时,如果发现速度不稳,可以考虑先输出到临时文件再统一移动。因为直接写目标路径时,每次写操作都可能触发文件系统元数据更新,而临时文件写完后mv到目标路径只需要一次重命名,省去大量同步开销。对大文件来说,这个操作差异还挺明显的。

另外,如果在管道场景中使用 pigz,比如tar -cf - ... | pigz -p 8 > archive.tar.gz,要注意管道的背压问题。上游 tar 生成数据的速度如果低于 pigz 消费数据的速度,pigz 就会被管道拖慢,整个流程的时间反而由最慢环节决定。此时最需要优化的不是 pigz,而是上游的数据生成速率。判断方法很简单:查看压缩时 CPU 使用率是否接近预期,如果 CPU 利用率很低而耗时很长,很大概率是 IO 或上游管道瓶颈。

4.4 与 tar 的配合用法

tar 加 gzip 是最经典的文件归档组合。pigz 最常见的用法就是替换管道里的 gzip,让归档加速。

最直接的写法是:

tar -cf - /path/to/data | pigz -p 8 > data.tar.gz

解压时反过来:

pigz -d -p 8 -c data.tar.gz | tar -xf -

这种写法不产生中间文件,磁盘占用小,整个流程在内存管道中完成,速度不错。

如果你希望 tar 命令内部直接调用 pigz 而不是借助 shell 管道,可以使用 tar 的--use-compress-program参数:

tar --use-compress-program="pigz -p 8" -cf data.tar.gz /path/to/data

解压时同样指定即可:

tar --use-compress-program="pigz -p 8" -xf data.tar.gz

不过在写自动化脚本时,我一般更推荐第一种管道写法。原因很简单:管道写法不依赖 tar 版本对自定义压缩程序的支持情况,兼容性更好,而且出问题时更容易定位。管道中判断命令是否执行成功,可以配合 shell 的pipefail选项,避免因为 pigz 失败而误以为 tar 成功。

5. 常见问题与排查技巧实录

5.1 压缩文件损坏排查思路

pigz 平时很稳定,但它本质还是调用系统资源干活,遇到问题时要按套路来排查。

最典型的错误是解压时报 “unexpected end of file” 或 CRC 校验失败。这个现象我在实践中遇到过几次,原因基本集中在三类:磁盘空间不足、进程被内存杀掉了、上游数据在管道中被截断。

遇到这类问题,第一步是检查磁盘剩余空间,特别是输出目录所在分区的空间。pigz 压缩过程中一旦写入失败,可能会留下一个不完整的.gz文件,但进程本身可能已经提前退出,不会明确提示你“磁盘空间不够”。所以看到报错先df -h看看。

第二步是确认输出文件的完整性。pigz 自带测试命令:

pigz -t file.gz

如果输出一切正常,说明文件没有损坏。如果提示异常,可以再尝试直接用标准 gzip 测一次:

gzip -t file.gz

用标准工具交叉验证能排除“pigz 自身压缩格式特殊”的误解。

第三步是检查系统日志中的进程记录,看 pigz 是否被异常终止。尤其是内存不足时,内核可能直接杀掉进程,这种情况在同时跑多个压缩任务时比较容易出现。解决方法是降低线程数、减小块大小,或者限制同时运行的压缩任务数量。

另外还有一个脚本层面的保护建议:在 shell 脚本中开启set -o pipefail,然后对每一步使用临时文件做缓冲。管道里某一段出错时,pipefail能让你知道是哪一段出了问题,不至于留下一个半成品文件还认为任务成功了。

5.2 gzip 兼容性边界与多成员流问题

pigz 压缩出的 gzip 文件,绝大多数工具都能正常识别。不过,多成员 gzip 流确实会在极少数情况下遇到兼容问题。

问题出现在哪里呢?标准 gzip 允许一个文件中包含多个独立成员,普通解压工具会依次解压所有成员,最终拼成原始内容。但某些非常老旧的嵌入式工具、脚本,或者只实现了解压第一个成员的简化解析器,拿到多成员压缩流后只会解出第一部分,后面全部丢失。这就造成解压不报错、但文件内容不完整的“隐性损坏”。

避免方案有两个。

一个是在分发文件之前,用标准gzip -t或者实际解压一次完整比对,确认下游工具读取结果和原始文件一致。

另一个是在可以接受的前提下,让 pigz 输出更接近单成员结构。问题是 pigz 多线程压缩天然会生成多块,要实现完全的“单成员”就很难拿到并行速度。所以实际做法通常是:在真正面向未知老环境的场景中,先用小文件做兼容性验证;验证通过再放心用。

从我的经验看,现代环境里的 GNU gzip、系统解压工具、主流语言的标准库,处理多成员 gzip 流都没问题。唯一要小心的是不规范的第三方解析库。如果你是自己写代码解析 gzip,记得要循环读取直至文件末尾,而不是只解析第一段。

5.3 和其他并行压缩工具的对比与选型

pigz 并不是市面上唯一的并行压缩工具。拿它和 pzstd、lbzip2、pbzip2 等工具放在一起比较,能帮你判断什么时候该选谁。

我用一个表格把它们的核心差异整理出来:

工具压缩格式并行压缩并行解压压缩率特点
pigzgzip支持支持中等兼容 gzip,迁移成本最低
pzstdzstd支持支持高压缩率和速度更好,但格式较新
lbzip2bzip2支持支持较高兼容 bzip2,压缩较慢
pbzip2bzip2支持支持较高老牌 bzip2 并行实现
部分新 xz 工具xz支持部分支持高压缩率最高,但 CPU 开销很大

选型建议很简单:如果下游链路只认 gzip,那就选 pigz,不需要纠结。如果可以从头设计压缩格式,那 zstd 往往比 gzip 更好,速度和压缩率都占优势,只是生态没那么普及。如果数据本身文本重复度高、又长期冷存储,bzip2 或 xz 家族的高压缩率更占优,但它们压缩速度慢,不如 gzip 通用。

我在实际项目中做过一次比较,同样是 20GB 日志,gzip 单线程压 20 分钟,pigz 8 线程压 5 分钟,pzstd 8 线程能压到 3 分钟以内而且体积还小一点。但最后迁移到生产时,因为下游存储系统只接 gzip,pigz 反而成了最佳选择。工具没有绝对好坏,只有合不合适。

6. 个人心得与补充技巧

用 pigz 几年下来,我最大的体会是它解决了一个看起来很朴素、但实际很痛的问题:让老旧格式也能吃上多核红利。它不是新算法,没有炫酷的压缩率表现,但它让 gzip 这个“老古董”在现代硬件上重新变得可用。很多团队第一眼看到压缩率比 gzip 大了一点就想放弃,其实完全没必要,1% 到 3% 的体积差异换来的时间收益,在批处理里非常划算。

最后再分享一个小技巧。pigz 在部分版本中支持通过环境变量注入默认参数。你可以把这个写进自动化脚本,并预留一个开关,让操作人员不传参数也能获得合理的默认配置。比如设置默认线程数为物理核心数的一半,这样即使有人忘记写-p,也不会因为线程数超标导致整机卡顿。脚本里建议始终把-k参数当作默认项,保留原始文件,给自己留一条后悔的退路。

如果你现在还在用单线程 gzip 压缩大文件,真没必要再硬等了。装一个 pigz,把命令里的gzip换成pigz -p 8,先跑一个小文件试试,你会立刻感受到现代 CPU 被真正用起来的感觉。后续还可以根据磁盘速度和内存容量微调线程数和分块大小,把这个工具变成你备份流程里最省心的一环。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进