300MB 预算,解压 2.2GB 的包

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 取小:

rust 复制代码
let budget = avail.min(BATCH_MAX_BYTES.max(first));

first 是这一批第一个文件的体积,所以预算的下限就是「至少塞得下一个文件」。盘越满,批次越小;盘越空,批次越大,最多 2GiB。省不省空间,取决于它当时愿意切多小。

这解释了一个反直觉的地方:宽盘上它不会省。因为批次直接顶到 2GiB 上限,行为和普通解压差不多,甚至因为要复制尾部索引、维护日志而略高一点。

check 子命令把这份预算直接打出来,而且不动文件,可以先干跑一遍:

python 复制代码
$ 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 时,它连输出目录都没建就退了:

lua 复制代码
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 去掉、给足余量之后才解完,打印:

makefile 复制代码
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-256 4d393d7b...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)

所有数字都是这台机器上单次跑出来的。单次测量只能说明「这次观察到什么」,不能说明「它稳定是多少」。

相关推荐
刘水生4 小时前
《你的 AI 为什么总在你没批准的时候动手?》
开源
鶴哥只手遮天4 小时前
《Redis 8.10.3 深度剖析》系列 · 第 1 章·全景与源码地图:版本谱系、构建体系、全局状态与启动链路
redis·后端·开源
分布式存储与RustFS7 小时前
Longhorn 的卷备份别堆本地盘:用 RustFS 给它当 S3 备份目标
运维·云原生·开源·对象存储·分布式存储·s3·性能基准
miofly8 小时前
GitHub 日榜趋势速报 | 2026-11-05
开源·github
wflynn17 小时前
GitHub 日榜趋势速报 | 2026-11-03
开源·github
冬奇Lab18 小时前
一天一个开源项目(第253篇):dsh-superagent —— DeepSeek 开源的“主动式”编程智能体原型,核心原则是少占人的注意力
人工智能·开源·资讯
字节跳动开源19 小时前
OpenViking 上线原生 3D 内容理解模型
数据库·开源
車瑜91121 小时前
PM-Skills42个链式工作流-数据分析
开源·产品经理·pm·pm-skills
m4Rk_1 天前
【论文阅读】Agent 记忆机制(119):EvoLife——让具身 Agent 把主动问到的答案沉淀为可复用的空间经验
论文阅读·人工智能·学习·开源·github