300MB 预算,解压 2.2GB 的包
一个 2.2GB 的 ZIP,把可用预算压到 300MB 后,常规解压仍得先腾出 2.2GB,HatchZip 直接解完了。
先列三条能核对的事实:
- 【实测】 2.2GB / 22 个条目(21 个文件 + 1 个目录条目)的合成 ZIP,在把可用预算压到 300MB 的条件下,
hatchzip x用时 6.97s,21 个文件 SHA-256 全部对得上;过程峰值(包 + 已解出)2,412MB,比包本身多约 210MB,df记录到的空闲空间最大跌幅 205MB。 - 【实测】 同一份包、宽盘条件下,
unzip和hatchzip的磁盘占用峰值都在 4.4GB 上下,这次 hatchzip 高 25MB(0.6%);它的省空间只在盘紧张时才出现。 - 【实测】 预算压到不够一个最小批次时,它在动文件之前就拒绝:
Insufficient disk space: 34.5 MB more is required,退出码 3,包字节数与 SHA-256 都不变,没有输出目录,也没有残留日志。
HatchZip 是 Rust 写的开源解压器(MIT / Apache-2.0 双许可),我测的是 0.1.8,截至 11 月 5 日仓库 2,497 star。它没有发明压缩算法,改的是「解压时文件怎么摆」这一点。
一、它做的不是流式解压,是「解一批、砍一刀」
普通解压器的顺序是:读完整包,把解出来的文件一个个写出去,压缩包原样留着。所以峰值永远是「包 + 全部输出」,2.2GB 的包至少要吃 4.4GB 磁盘。
HatchZip 反过来:从包的尾部开始解,解完一批就把这批在包里占的字节截掉。 截掉的字节立刻变成可用空间,给下一批输出用。
这需要三个前提,缺一个都做不成。
第一,索引要提前保存。 ZIP 的中央目录在文件尾部,直接截尾就把索引毁了。所以它开工前先把尾部复制成 <archive>.hatchzip-tail,记下 CRC,之后的读取器把「剩余 archive + 保存的 tail」拼成一个逻辑文件。你看到的后缀不是垃圾文件,是索引的备份。
这份尾部不只是存盘,还会整个读进内存常驻到解压结束。源码里的上限是 1,000,000,000 字节;超过这个数,它在动文件之前就拒绝,提示「索引太大会一直占着内存,archive 没有被修改」。正常 ZIP 的中央目录离这个量级很远——我这个 22 条目的包只有几百字节——但条目数极多的包会先撞到这条线。我没造过这么大的索引,这条是按源码写的,没实测。
第二,提交顺序不能反。 截断只有在输出确实落盘、日志确实写完之后才能做。源码里 commit 的顺序是:sync_batch 刷盘 → 写 journal → 才 set_len 截断。注释写得很直白:日志不能声称一个断电后还能被收回的文件。这个顺序决定了一次中断之后,已经提交的那几批还算不算数。
第三,批次大小得看盘吃饭。 engine.rs 里两个常量:BATCH_MAX_UNITS = 512、BATCH_MAX_BYTES = 2GiB。每批的预算是「可用空间减掉 --keep-free」,再和 2GiB 取小:
let budget = avail.min(BATCH_MAX_BYTES.max(first));
first 是这一批第一个文件的体积,所以预算的下限就是「至少塞得下一个文件」。盘越满,批次越小;盘越空,批次越大,最多 2GiB。省不省空间,取决于它当时愿意切多小。
这解释了一个反直觉的地方:宽盘上它不会省。因为批次直接顶到 2GiB 上限,行为和普通解压差不多,甚至因为要复制尾部索引、维护日志而略高一点。
check 子命令把这份预算直接打出来,而且不动文件,可以先干跑一遍:
$ hatchzip check game.zip
game.zip 2.2 GB zip, 2.2 GB uncompressed in 22 entries
available 22.0 GB
standard extraction needs 2.2 GB
required 104.9 MB (+2.0 GB reserved)
available after 22.0 GB
这里的 required 是 104.9MB,不是 2.2GB。它要的不是「装下所有输出」的空间,而是「装下这一批第一个文件」的空间。括号里的 2.0GB 是默认的 --keep-free,也就是说它默认给你留 2GB 不碰,剩下的才拿来算批次。这两个数一起看,才是它真正的承诺:不要求两倍空间,只要求一个文件的余量,外加你愿意划给系统的保留区。
二、三个场景的实测
测试包是合成的:21 个 100MiB 的 /dev/urandom 文件,zip -0 存储不压缩,得到 2,202,013,120 字节 / 22 个条目(21 个文件 + 1 个目录条目)。不是真实游戏包,也不含真实项目文件——这一点影响下面的结论边界,后面会讲。
环境:macOS 27.2、arm64、HatchZip 0.1.8、Info-ZIP zip 3.0 / UnZip 6.00。每个场景跑一次,采样脚本目标间隔 80ms,实际约 120–280ms(stat + du + df 本身要花时间),峰值按采样点计,都是单次测量。
| 场景 | 工具 | 用时 | 峰值(包 + 已解出) | 空闲空间最大跌幅 |
|---|---|---|---|---|
| 宽盘,约 34GB 可用 | unzip | 6.26s | 4,404MB | 1,966MB |
| 宽盘,约 29.5GB 可用 | hatchzip | 7.79s | 4,429MB | 2,212MB |
| 预算压到 300MB | hatchzip | 6.97s | 2,412MB | 205MB |
| 预算压到约 70MB | hatchzip | 拒绝,rc=3 | — | 未测 |
【实测】宽盘那两行是重点:它没有比 unzip 省。 两者峰值差 25MB(0.6%),属于同一量级。df 跌幅这两列不能直接比:两次跑在不同时刻,起始空闲一个 34GB、一个 29.5GB,df 又有约 40MB 的步长,我只能说「都在 2GB 上下」,不下「谁更好」的结论。
【实测】真正有意思的是第三行。--keep-free 26283MB 把可用预算摁到 300MB 之后,同一份 2.2GB 的包解完了。峰值 2,412MB 是「还没砍掉的包 + 已经解出来的文件」的最高点,比包本身(2,202MB)多约 210MB;df 那边最大跌幅 205MB,因为 df 的步长约 40MB,这个数只当量级看,别当精确值。
包的长度是一条阶梯,采样里看得很清楚(字节):
2202013120 → 1992295869 → 1782580521 → 1572865173 → 1363149825
→ 1153434477 → 943719129 → 734003781 → 524288433 → 314573085 → 0
多数级差约 200MiB(实测 209,715,348 字节),也就是两个 100MiB 文件;首批多出约 2KB(尾部索引的开销),最后一级 314,573,085 字节是 300MiB、三个文件。批次的切法随剩余预算变,不是固定两个一批,解完就砍,砍完再解。
这也意味着峰值不是一个固定值:它等于「还没砍掉的包」加「已经解出来的文件」,而这两者在整个过程中此消彼长。2.4GB 是这次采样到的最高点,不是理论值。
三、它什么时候会停下来
余量地板是一个文件的体积,不是零。 预算被压到约 70MB(--keep-free 20000MB)、离一个 104.9MB 的文件还差 34.5MB 时,它连输出目录都没建就退了:
Insufficient disk space: 34.5 MB more is required. Free up space or lower --keep-free.
退出码 3。我又跑了一次并留了记录:ck.zip 前后 SHA-256 相同(353ba6ac…)、字节数相同(2,202,013,120),没有输出目录,也没有 journal 文件。这一条比「能解压」更重要:失败是干净的。
预算耗尽停下后能续,但前提是预算够。 我用 --keep-free 24357MB 跑到 2.6s,这一批余量不够,工具自己报 not enough disk space to continue 后退了:包已从 2,202,013,120 降到 1,992,295,869,少掉一个 200MiB 批次,另加约 2KB 尾部索引(实测差 209,717,251 B),journal 里的 committed_len 和文件长度一致。重跑时如果用接近的预算,会再停一次——我第二次用 --keep-free 24000MB,0.42s 就停在 1,677,722,847(打印 resuming: 2.0 GB of 2.2 GB remaining)。把 --keep-free 去掉、给足余量之后才解完,打印:
resuming: 1.7 GB of 2.2 GB remaining
然后接着解完,21 个文件全部校验通过,journal 自己清掉了。这三步证明的是「预算耗尽后能接着解」,前提是下一次的预算真的够用。
SIGKILL 我只验到一半。 我在 3.59s 用 SIGKILL 杀过一次,那一次落在第一批提交之前:包长没变(2,202,013,120),journal 的 committed_len 也还是全长,等于没有可续的进度,重跑打印 resuming: 2.2 GB of 2.2 GB remaining,13.8s 全量重做。「支持续传」不等于「任何时刻中断都赚」,得看第一个批次有没有落完。杀在提交之后能不能续、断电会不会留坏状态,这两件我没测,不能把这段当成崩溃安全性的证据。
越界路径直接拒。 一个带 ../../evil.txt 的包,hatchzip l 就报了 would be written outside the destination folder; extraction refused,退出码 1。我核了前后状态:包还是 342 字节、SHA-256 没变(3456b288…),没建输出目录,/tmp/hzb/evil.txt 也不存在。它连列表都不给,更不会写到目标目录外面。
四、代价和边界
只能从尾部截。 截断用 ftruncate / SetEndOfFile,跨平台都有;但要删掉包中间或头部的字节,得靠 hole punch,FAT32 和 exFAT 不支持。所以这套机制天然只适用于「文件数据在索引之前、可以从后往前吃」的格式。作者自己在文档里写明了这个限制。
默认会删掉原包。 解压成功那一刻 archive 就没了。要保留就加 --keep-archive,但那样它退化成普通解压器,空间需求回到「包 + 全部输出」,省空间的好处一起消失。这是个二选一,没有中间态。
覆盖已有目录要显式开。 目标路径下已存在的文件默认不动,除非加 --replace;加了之后,旧文件先被移进目标目录里的 .hatchzip-replaced,全部解完才删,restore 可以把它们移回来——除了「新解出来的文件占了同名路径」的那几个。这段是文档写的,我没测,restore 也没跑。
一次跑多个包会顺序检查。 每个包开工前才查空间,第一个装不下的就终止整轮。所以批量解压时,失败点可能在中途,而不是开头——这一点同样来自文档,没实测。
格式支持比宣传窄。 README 列了 ZIP(含 ZIP64、自解压 exe)、7z、未压缩 tar、tar.gz/tgz、分卷、RAR(solid 包只能用 --keep-archive)。我只测了 ZIP,其他格式没跑;分卷和 RAR 的续传行为我也没有验证。别把这篇的结论套到 7z 上。
内存不是零成本。 尾部索引常驻内存,上限 1GB;除此之外每批的输出缓冲、CRC 校验也都在内存里走。我这次没测 RSS,只测了磁盘,所以这篇不写「内存占用多少」。如果包里有超大单文件,流式写入的缓冲会跟着放大——具体多大,得你自己量。
真实项目没测。 测试包是 21 个等大的 100MiB 文件,每个文件都是完整的一批边界,这是最理想的情况。真实的 node_modules 是几万个小文件,批次会先撞上 BATCH_MAX_UNITS = 512 而不是字节上限,每批的截断次数、元数据开销都会变。这种情况我没测,也不打算靠推理下结论。
真满盘没做成。 我试过用 hdiutil 建一个 260MB 的 APFS 映像来模拟,报「设备未配置」,没成功。上面的「300MB 预算」是用 --keep-free 把可用额度摁下去模拟的,不是物理上快写满的盘。这两者的差别在于:真满盘时文件系统碎片和分配行为可能不同,我没法排除。
耗时别当结论。 宽盘那两次 6.26s 和 7.79s,加上后来配对测的一次 8.5s 和 3.0s,波动很大,明显受页缓存影响。那组配对测里 hatchzip 的输出目录没清空(记到 2.4GB,比包内数据大),只能说明「同一份包两次能差出一倍多」,不能当对照。单次测量,不给速度结论。
五、什么时候值得用
判断标准不是「包大不大」,是「盘紧不紧」。
盘上有两倍于包的空闲,用谁都一样,HatchZip 反而多一层索引复制和日志。把 --keep-free 压到只剩一两百 MB 预算、而包有几个 GB 时,它是少数能直接把事做完的工具——前提是格式在支持列表里,且你能接受原包被吃掉。真·物理满盘我没测,这一条是从预算受限推出来的。
还有一类场景它天然合适:下载完就地解压的流水线。包下载到一半、盘快满了,普通解压会卡在「需要 2 倍空间」,HatchZip 可以边解边把包吃掉。代价是解压失败时原包可能已经不完整——它靠 journal 和 restore 子命令兜底,但这条恢复路径我没测。
如果你只是偶尔解压一个大包,为它换工具不值。如果你经常在「盘不够」和「必须现在解」之间做选择,它把这个问题从「先删点什么」变成了「直接跑」。
还有一层要自己掰清楚:它的「省空间」不是无条件的,是用批次大小换峰值。默认 --keep-free 2GB,意味着它一开始就只肯用掉「可用空间减 2GB」的部分;在 30GB 的盘上,这个预算会顶到 2GiB 上限,于是每批吃满 2GiB,峰值也就跟着上去了。想让峰值更低,得主动把 --keep-free 调大——代价是留给自己的余量变少,一旦估算错了,就会撞上「还差 34.5MB」那种拒绝。省空间和「一定能解完」在这个工具里是一对需要你自己调的参数,不是自动最优。
我这次把预算压到 300MB 能过,不代表 300MB 是通用安全值。它取决于包里最大那个文件有多大:预检要的余量就是一个文件的体积,这个包是 100MiB 一个文件,所以 300MB 绰绰有余;如果是一个 1.5GB 的单文件,同样的 300MB 预算会在预检就被拒。换包就必须重新 check 一遍,别把这篇文章里的数字当模板套。
复现材料
- 仓库:
github.com/xevrion/hatchzip,v0.1.8,macOS arm64 产物 1,739,132 字节,SHA-2564d393d7b…51a12 - 测试包:21 × 100MiB
/dev/urandom+zip -0,2,202,013,120 字节(合成数据) - 采样脚本:目标每 80ms 记录一次
stat -f %z(包长)、du -sk(输出)、df -k(空闲),实测间隔约 120–280ms - 关键结论的原始记录:受限场景逐行采样、宽盘对照 CSV、拒绝运行的退出码与前后 SHA-256、越界包的前后哈希、中断后的 journal 原文,都留在本地,未截断
- 本文终端输出在 HatchZip 0.1.8、macOS 27.2 上实跑(2026-11-05)
所有数字都是这台机器上单次跑出来的。单次测量只能说明「这次观察到什么」,不能说明「它稳定是多少」。