ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

流式解压+分块处理+增量安装,大型包部署从43分钟降至9分钟

2026/9/9 11:33:44 拓冰建站 浏览量
流式解压+分块处理+增量安装,大型包部署从43分钟降至9分钟 如果你维护过那种需要频繁发版的客户端或应用服务一定见过这种画面更新包三四个 GB解压完快十个 GB里面两万八千多个文件。以前每次发版我的处理流程就是下载、解压、全量覆盖一条龙跑完基本要一整晚还经常因为磁盘写满、内存打满而失败。后来我把整条链路改成了流式解压 分块处理 增量安装同一个包在 4C8G 的测试机上从 43 分钟压到了 9 分多钟内存峰值从 1.1GB 降到 200MB 左右磁盘临时写入减少 80% 以上。这篇文章是这次改造的完整记录适合做部署工具、客户端更新器、离线包安装器的同学参考。1. 先还原一下现场这个改造到底在解决什么1.1 三个瓶颈是怎么同时出现的改造前的老流程其实很朴素先把 3.8GB 的 tar.gz 从内网仓库下载到本地然后解压到一个临时目录再把整个临时目录里的文件全量复制到安装目录。一次发版下来磁盘要经历三次大写入下载压缩包写 3.8GB解压出全部文件写 9.2GB全量复制到安装目录再写 9.2GB加起来差不多 22GB 的临时写入。光有临时目录还不行你得同时容纳压缩包、解压产物、安装目录三份数据磁盘空间一紧张就直接失败。内存方面也好不到哪去当时我图省事用了多进程并行解压峰值内存稳定在 1GB 以上小机器根本不敢跑。这三个动作其实对应三个可以独立优化的点但单独做任何一个都解决不了整体问题。只做流式解压省掉了压缩包落盘和解压临时目录但后续全量复制还是会把 9.2GB 原样写一遍只做增量安装你还是得先把整个包下载下来、把整个包解压开才能判断哪些文件变了只做分块处理内存是稳住了但磁盘三遍写入一点没少。这也是为什么我把三种技术一起上——它们解决的问题是正交的。1.2 一个关键判断包的性质决定了方案值不值得做开始动手之前我先把包的构成摸了一遍。这是做技术选型时最容易被跳过的一步但恰恰是最重要的。如果你的包每次版本 90% 的内容都变了增量安装的收益很小前面两个优化才是大头如果包主要是静态资源、编译产物、引擎文件这类东西版本间真实变化往往只有 10%~20%那增量安装就能把安装阶段的时间砍掉一大截。我这边的情况很典型压缩后 3.8GB解压后 9.2GB一共 28473 个文件但相邻两个版本之间实际变更的文件只有 4100 个左右加起来 1.2GB变更率不到 15%。也就是说用旧方案光是把没变的 8GB 文件复制一遍这个动作就占了整个安装流程 40% 以上的时间。这个数据一出来方案基本就定了流式解压解决下载和解压阶段的磁盘与内存问题分块处理把内存峰值钉死在低水位增量安装让那 85% 没变的文件真正原地不动。2. 流式解压把等下载完再解开改成边收边解2.1 为什么 targzip 天生适合流式而 zip 不是流式解压能不能做完全取决于归档格式。tar 的结构是一串按顺序拼接的文件块每个文件前面是一个 512 字节的头部后面跟文件数据读完一个再读下一个天然就是顺序流。gzip 本身是 DEFLATE 压缩流解压器只需要维护一个 32KB 的滑动窗口不需要随机访问任何位置。所以 tar.gz 可以做到给多少字节就解多少字节网络流可以直接对准解压器。ZIP 就不一样了。ZIP 把中央目录central directory放在文件末尾里面记录每个文件在归档中的偏移量。常规解压工具要先跳到文件末尾读中央目录再根据偏移量回头找各个文件。这就决定了 ZIP 本质上不是为流式读取设计的。我试过把网络流直接丢给 Python 的 zipfile结果就是BadZipFile: File is not a zip file。这不算 bug是格式本身决定的。所以结论特别简单只要打包格式自己说了算就选 tar.gz或者更现代的 tar.zst。如果上游只给 ZIP我的做法是退一步先把整个包拉到本地或者用 Range 请求只拉末尾的中央目录再处理等于主动放弃流式收益。这个收益其实也没多大因为中央目录通常只有几百 KB真正的瓶颈还是在后续的解压和安装阶段。2.2 流式解压的代码骨架Python 的 tarfile 本身就支持流式模式不用引第三方库。关键在mode参数import tarfile import urllib.request def open_remote_tar(url): resp urllib.request.urlopen(url, timeout30) # resp 是原始二进制流read(n) 拿到的就是压缩后的字节 tar tarfile.open(fileobjresp, moder|gz) return tartarfile 的 mode 有两种常见写法差别很大r:gz普通模式打开时会先把整个归档从头到尾读一遍建立索引之后可以随机提取任意文件也可以 seek。代价是必须拿到完整数据启动慢不用网络流。r|gz流式模式逐块解压不能 seek不能随机提取只能按顺序一个一个取 member。好处是内存占用和启动时间都非常小可以直接吃网络流。改造后的代码里用的是r|gz。拿到 tar 对象之后用for member in tar:迭代每次取一个文件条目。这里有个前提流式模式取到的 member 只能顺序处理你不能先回头提取第 100 个文件再回来处理第 5 个架构上必须按顺序消费整个流。2.3 流式模式下最容易踩的内存坑tar.extractfile(member)返回的是一个流对象正确用法是循环调用read(CHUNK_SIZE)。千万别图省事写data src.read()——不带参数会把整个 member 一次性读进内存。如果你的包里混着几个 500MB 的大文件一行read()就能把内存顶爆。这个坑在普通模式下不明显因为普通模式随机提取时你通常会明确知道文件大小人也容易警醒。但在流式模式下for member in tar:的写法会给人一种反正一个个来的错觉手一滑就写了无参 read。我排查过几次 OOM最后都是这个原因。3. 分块处理内存上限是设计出来的不是碰运气碰出来的3.1 先把内存账算清楚流式解压解决了磁盘三遍写入但内存并不是自动就稳了。如果不做控制几个大文件同时进内存照样崩。我先列一下整个流水线上有哪些内存消耗点消耗项典型大小说明网络读缓冲64KBtarfile 内部默认缓冲gzip 解压窗口32KBDEFLATE 固定滑动窗口单文件 hash/写出缓冲1MB我们自己控制的 CHUNK_SIZEstaging 写缓冲8KB~64KBPython 文件对象默认manifest 字典每 10 万文件约 30MB路径字符串 hash 字符串无参 read() 单个大文件等于文件本身大小这是唯一不可控项必须杜绝算下来只要能保证任何时刻内存里最多只有一块数据整条流水线的峰值就完全可以控制在 200MB 左右。所以分块处理的核心不是把包切成多少 MB而是设计一个机制任何一个文件不管它多大永远是切成一兆一兆地流经内存绝不整块驻留。3.2 1MB 的 hash 块是怎么定出来的CHUNK_SIZE 我试过 64KB、1MB、8MB 三档。64KB 时系统调用和哈希循环的调用次数太多CPU 空转明显1MB 时 SHA256 单次 update 的效率已经上来了而内存增量只有 1MB几乎可忽略再往上到 8MB吞吐几乎没变化内存峰值反而更难看了。最后定了 1MB。hash 算法不要换别贪图快用 CRC32。这里要的是防篡改和防损坏级别的完整性校验不是简单的传输校验。SHA256 在 1MB 块上的吞吐能跑到 1~2GB/s相对解压速度根本不是瓶颈。3.3 把安装包切成 segment分块处理在文件层面的延伸内存分块能解决单机内存但解决不了另一个实际问题tar.gz 是线性流网络一断就得从头再来。普通文件可以用 HTTP Range 续传tar.gz 这种压缩流没法从任意字节位置续解。所以我做了一个更彻底的分块打包阶段就把一个巨大的 tar.gz 拆成若干个 64MB 左右的 segment每个 segment 都是独立的 tar.gz配一个 descriptor 文件JSON描述整体结构。descriptor 大概长这样{ version: 2024.06.01, segments: [ {id: 0, url: https://repo.internal/app-2024.06.01.seg0.tgz, sha256: ab12...}, {id: 1, url: https://repo.internal/app-2024.06.01.seg1.tgz, sha256: cd34...} ], manifest: { static/js/app.js: {sha256: ef56..., size: 482133, mode: 420, seg: 0} } }segment 化带来的好处有三个。第一是断点续传客户端记录每个 segment 的状态中断后重跑只拉没处理完的第二是并行下载不同 segment 可以同时从多个节点拉第三是单 segment 内存和临时文件可控同时只处理一个 segment 的流。这里有个关键设计如果某个文件本身就超过 segment 大小就把它单独放进一个 segment不和其他文件混装。这样每个 segment 处理完就是一个完整状态不会出现一个文件被切开、前后两个 segment 互相依赖才能恢复的情况。我见过把 3GB 单文件硬切成多个 tar 片段的方案恢复逻辑复杂度直接翻倍收益却很小完全不值得。3.4 每个 segment 怎么自校验segment 的压缩字节有一个 sha256成员文件的内容也有 sha256在全局 manifest 里。客户端处理完一个 segment 后两个哈希都对得上才标记为 verified。这样网络层损坏和打包层损坏都能在 commit 之前被拦下来。注意这个校验非常重要因为后面增量安装阶段未变更的文件我们是不会逐个读内容算 hash 的完整性就靠 segment 压缩字节的 sha256 兜底。这一段要是在设计时省了后面出了问题连排查方向都没有。4. 增量安装让九成文件真正原地不动4.1 全量覆盖为什么这么贵全量覆盖的浪费在于大多数文件的内容根本没变但你仍然把它们从临时目录复制到安装目录。9.2GB 的复制在 SSD 上也许只要几分钟在机械盘或者网络盘上可能就是半小时起步。更麻烦的是复制行为会让文件系统缓存失效杀毒软件、索引服务、备份 agent 全部会重新扫一遍新写入的文件这些隐性开销比复制本身还难控制。4.2 用双 manifest 预计算做增量决策安装侧增量需要回答一个问题这个文件到底要不要动它。判断依据不是文件名而是内容摘要。我的做法是本地维护一个上次安装成功后的 manifest记录每个路径的 sha256、大小、mode服务端的 descriptor 里带本次包的 manifest。处理任何字节之前先把两个 manifest 做一次 diff得到三个集合新增、变更、无变化。def diff_manifests(old: dict, new: dict) - set: changed set() for path, meta in new.items(): if path not in old or old[path][sha256] ! meta[sha256]: changed.add(path) return changed这里最重要的一个思路是增量决策必须发生在读取文件内容之前而不是之后。因为服务端 manifest 已经告诉我们哪些文件没变当 tar 流走过一个未变更的 member 时我直接continue不打开 stage 文件也不计算它的 hashtarfile 在取下一个 member 前会自动把当前 member 剩余字节读完并丢弃。这个continue写起来只有一秒但它决定了 80% 的文件不会碰磁盘。作为对比我一开始写过一版先全量解压到 stage算 hash再和旧 manifest 比相同就删掉的逻辑。功能上没错但没变的文件也被写了一遍盘再删掉磁盘浪费依然严重等于增量了个寂寞。后来改成预计算才真正把写盘量压到 1.2GB 左右。这个设计的代价是即使文件没变CPU 仍然要解压它因为 tar 是线性流没法跳过去。但解压的 CPU 开销比写盘便宜一到两个数量级这个取舍非常划算。4.3 提交阶段写盘最少化 原子替换 回滚整个 stream 阶段我们只把新增 变更的文件写到一个 stage 目录而且 stage 目录必须和安装目录在同一文件系统上这是为了后面os.replace的原子性。直到所有 segment 都 verified才进入 commit把 stage 里的文件逐个os.replace到目标路径创建缺失目录删除版本间消失的文件最后原子替换本地 manifest。commit 阶段我做了三件事来防备半路崩溃先做备份把即将被替换的旧文件移动到 backup 目录而不是直接删。每个os.replace之后写一行操作日志。如果中途失败按日志反向恢复。回滚逻辑不复杂但一定要在真正出故障之前写出来等出事再补就来不及了。实际操作中commit 本身很快因为变更文件只有 1.2GB大部分时间花在 stream 阶段所以 commit 崩溃的概率并不高但概率低不等于可以不做防护。def commit(stage_dir, staged, install_root, old_manifest, new_manifest, backup_dir): for rel, stage_path in staged.items(): target os.path.join(install_root, rel.lstrip(/)) os.makedirs(os.path.dirname(target), exist_okTrue) if os.path.exists(target): backup_name hashlib.sha256(rel.encode()).hexdigest() shutil.move(target, os.path.join(backup_dir, backup_name)) os.replace(stage_path, target) for rel in set(old_manifest) - set(new_manifest): target os.path.join(install_root, rel.lstrip(/)) if os.path.exists(target): os.remove(target) with open(os.path.join(install_root, manifest.json), w, encodingutf-8) as f: json.dump(new_manifest, f, ensure_asciiFalse)还有个细节要注意symlink、可执行位、mtime。tar 流里这些信息都在 member 头里提交时逐个 set 回去。但 mtime 我建议谨慎处理——很多 CI 构建出来的产物所有文件 mtime 都是同一个打包时间戳这种情况下你把 mtime set 回去没有任何意义反而会坑到下游基于 mtime 判断的同步工具。我的做法是只保留 hash 判断逻辑mtime 交给安装时机统一处理。4.4 进阶方案release 目录 硬链接切换如果应用场景是部署一个服务而不是更新一个客户端还有更彻底的方案每次发布建一个全新的 release 目录未变化的文件用os.link()从旧 release 目录硬链过来不占额外数据块变更的文件写新文件最后把current符号链接原子地指向新目录。这是在文件系统层面做增量commit 阶段几乎没有 IO 成本回滚就是换一个链接。这个方案的代价是要保留多份 release 目录占用额外 inode跨文件系统不支持Windows 下os.link有权限限制。但它和本文的流式解压 分块处理完全不冲突反而能配合得很好——stream 阶段照旧只处理变更文件只是 stage 的目标从原地替换变成写入新 release 目录。如果条件允许这个方向值得试。5. 把三件事串成流水线步骤、状态机、代码骨架和实测5.1 完整的九个步骤整个流水线跑起来是这样一个顺序拉取并校验 descriptor.json拿到版本号、segment 列表、全局 manifest。读取本地旧 manifest。对两个 manifest 做 diff算出 changed_paths新增 变更。读取本地状态文件恢复上次残留的 segment 状态。对每个 pending 的 segment流式下载 流式解压对 changed_paths 里的成员写 stage 并计算 hash对无变化的成员只推进流不写盘。segment 结束校验压缩字节 sha256 和成员内容 hash标记 verified。所有 segment verified 后进入 commit备份、替换、新增、删除、更新 manifest。清理 stage 目录和备份目录。更新本地状态文件标记本次版本完成。步骤 5 和 6 是整个改造的核心步骤 7 是保证安全的关键其他步骤都是配套。这个顺序我踩过坑曾经为了省事把 commit 混在 stream 里做结果一个文件 hash 校验失败时前面已经替换的文件没法回滚只能全量重装。后来强制拆成两阶段问题才彻底消失。5.2 核心代码骨架前面已经贴了 diff 和 commit这里补上最核心的 stream 处理部分import hashlib import tarfile from pathlib import Path CHUNK 1024 * 1024 # 1MB def extract_to_stage(tar, member, meta, stage_dir): 流式解压单个 member写入 stage 目录并返回 sha256。 h hashlib.sha256() out_path Path(stage_dir) / member.name.lstrip(/) out_path.parent.mkdir(parentsTrue, exist_okTrue) src tar.extractfile(member) with open(out_path, wb) as out: while True: block src.read(CHUNK) if not block: break h.update(block) out.write(block) digest h.hexdigest() if digest ! meta[sha256]: raise RuntimeError(fhash mismatch: {member.name}) return out_path def stream_segment(stream, changed_paths, manifest, stage_dir): tar tarfile.open(fileobjstream, moder|gz) staged {} for member in tar: if not member.isfile(): continue if member.name not in changed_paths: continue # 无变化只推进流不写盘 staged[member.name] extract_to_stage(tar, member, manifest[member.name], stage_dir) tar.close() return staged这段代码里真正决定性能的就两行if member.name not in changed_paths: continue和block src.read(CHUNK)。前者让 85% 的文件不碰磁盘后者保证任何一个大文件都不会整块进内存。5.3 实测数据改造完成后我在 4C8G 的虚机上跑了一轮对比普通 SSD内网千兆。包体是前面提到的 tar.gz 压缩后 3.8GB解压 9.2GB28473 个文件版本间实际变更 4100 个文件、1.2GB 左右。指标旧方案新方案临时磁盘写入3.8 9.2 9.2 ≈ 22GB约 1.2GB变更文件峰值内存约 1.1GB并行解压约 210MB总耗时43 分钟9 分 20 秒断点续传不支持按 segment 粒度支持失败恢复全量重来已验证 segment 直接跳过耗时的大头已经从写盘变成了解压。这也解释了为什么我建议工具链允许时把打包格式从 tar.gz 换成 tar.zst。在 4C8G 上gzip level 6 的解压速度大概是 50~80MB/s压缩字节zstd 大约是 200~300MB/s瓶颈直接砍半。流式方案本身不用改tarfile 支持moder|zstd前提是装了zstandard库打包端也要配套改。5.4 参数表最终用的配置参数值依据CHUNK1MB哈希与写盘吞吐的均衡点网络读缓冲64KB覆盖常见 TCP 抖动够用segment 大小64MB断点粒度与 CDN 分片习惯匹配manifest 存储JSON简单直观10 万条约 20~30MB完整性算法SHA256防篡改 防损坏速度不是瓶颈这套参数不是一次调出来的后面第 6 节会讲具体踩坑过程。6. 落地过程中踩过的坑和最终经验6.1 坑一requests 的原始流被偷偷解压过一开始我用的是requests.get(url, streamTrue).raw直接丢给tarfile.open结果 tarfile 报file could not be opened successfully或者not a gzip file。排查半天发现某些版本的 requests 在特定响应头比如Content-Encoding: gzip下会把resp.raw.decode_content置为 True导致底层 read 的时候已经做了一层 gzip 解压而我又在r|gz模式里用 gzip 解了一次双重解压当然报错。解决方式有两个要么手动设resp.raw.decode_content False要么干脆用urllib.request.urlopen拿到的就是原始压缩字节没有这层花活。我后来选了后者少一个隐式行为也少一类线上问题。6.2 坑二src.read() 没带参数单体大文件吃光内存这个前面提过但值得单独列出来。现象是内存稳步上涨直到 OOM而且只在流式模式下出现。原因是有人包括我自己早期写了data src.read()没带 size 参数。在流式模式下这个调用会把整个 member 一次性读进内存。包里如果有几个 500MB 的大文件一条语句就够把机器打趴。排查方法很简单看内存上涨曲线是不是和某个大文件的出现时间吻合再检查那段代码里有没有裸的read()。修复也简单改成循环read(CHUNK)。但我在代码 review 里还是会提醒所有人流式模式下read()必须带参数这个要当硬性规范写进团队文档。6.3 坑三用 mtime/size 做增量判断被 CI 时间戳骗了不少次改造初期我偷过懒diff 只比 size 和 mtime想着这样不用读内容、速度更快。结果我们的 CI 每次构建都会把所有产物 mtime 刷成打包时刻导致每次发版几乎所有文件都被判定为变了增量安装的收益直接归零。更隐蔽的是有些文件 size 恰好相同但内容变了这版逻辑也会漏判。后来统一改成 sha256而且这个 hash 来自服务端 descriptor不是客户端现算的所以没有额外开销。现在回头看这可能是整个项目里性价比最高的一个修复——一段判断逻辑把增量安装的正确性从碰运气变成了可证明。6.4 坑四把下载块和文件边界混在一起解出一堆坏文件这个坑发生在更早的原型阶段。我当时尝试把一个 tar.gz 按 HTTP Range 切成 64MB 的块分别下载然后各自解压到不同目录再合并。结果 tar 的 512 字节头部对齐根本不是按下载块对齐的拼出来的目录要么缺文件要么多了半截垃圾数据完全没法用。这次教训让我彻底放弃把单个压缩流切开的念头改成打包时就按 segment 分好。分块处理是对数据的处理方式不是对压缩流的物理切割。这个观念转变很重要segment 是打包阶段就规划好的独立归档而不是下载阶段事后拼凑的字节片段。6.5 两个收尾经验小包冒烟权限写入 manifest这种东西一定要先用小包冒烟。我每次调完参数都会构造一个 500MB 左右、只有几十个文件的小包跑一遍全流程故意在中间杀进程验证恢复逻辑然后再上真实包。生产包十几分钟跑一次靠肉眼排查太慢。另外分享一个刚踩过的权限坑这套流程跑了好几个版本一直没事直到有一次运维改了系统的默认 umaskstage 文件落盘时权限位不对服务启动直接失败。后来我把 mode 显式写进 manifest提交时用os.chmod强制 set 回去再也没出过问题。这类环境一改就翻车的细节往往比主流程更值得记录。