5MB 的更新包在 Windows 安装时为什么会卡顿一分钟
Auto Email Sender 是一个 Electron 壳套 Python 后端的桌面软件。为了让用户不用每次发版都重新下载数百兆的完整安装包,Windows 端上线了基于二进制差量(Differential Update)的更新机制:服务器只计算新旧版本之间的差量补丁,客户端下载完补丁,在本地应用增量替换。
在实验室测试里,这套机制惊艳无比:几十个 bugfix 版本的差量补丁甚至只有 4~8 MB,几秒钟就下载完了。
可到了真实用户手里,扑面而来的却是吐槽: “明明下得飞快,点完『立即重启更新』,那个进度条能卡在 90% 整整一分钟!” “期间电脑风扇狂转,看资源管理器网络一点流量都没有,你们这安装器是在后台挖矿吗?”
明明数据只有几兆,硬盘也是读写 3000MB/s 的高速 NVMe SSD,这一分钟到底花在哪里了?
六千多个零碎文件
最开始我怀疑是差量合并解压的算法太消耗 CPU,或者是压缩参数拉得太狠导致解压耗时。
但我解压了最终构建产物看了一眼目录结构,瞬间头皮发麻:
整个应用解压后大概 700 MB,这很正常。但文件总数居然高达 6400 多个! 其中超过 4000 个都是 Python 后端依赖库里的 .pyc 字节码文件,一个个体积只有小小的几 KB 到几十 KB。
在 Windows 操作系统上,写一个 700MB 的单文件,和写 6000 个 10KB 的小文件,性能完全是两个维度的概念:
【单大文件写入 (连续 I/O)】
打开文件句柄 ──> 分配连续簇 ──> 流式高速写入 ──> 触发 1 次安全扫描 ──> 关闭句柄
【6400 个小文件写入 (随机元数据风暴)】
[循环 6400 次]:
MFT 记录分配 ──> 创建文件句柄 ──> 写入几个扇区 ──> 更新目录树索引
──> 触发 Windows Defender 内核驱动拦截拦截扫描!
──> 扫描通过 ──> 释放句柄 ──> 提交事务日志 (USN Journal)通俗比喻:搬一整箱苹果 vs 搬 6000 颗散装黄豆过安检为什么写 700MB 大文件飞快,写 6000 个几 KB 小文件会卡死? 想象你去坐高铁安检:
- 单大文件:就像你推着一个大行李箱,安检员一扫描,滴一声,3 秒钟就放行了;
- 6000 个小文件:就像你兜里揣了 6000 颗散装黄豆,安检员要求必须把每一颗豆子单独掏出来、放在托盘上、拿着放大镜仔细照一遍、登记造册、然后再放行一颗。
在 Windows 里,Windows Defender 杀毒引擎就是这个一丝不苟的安检员。即使你的固态硬盘写得再快,也扛不住操作系统和杀毒软件连续执行 6000 次“开门、检查、登记、关门”的繁琐仪式。
对于 SSD 来说,顺序吞吐高达几 GB/s,但在 NTFS 文件系统频繁修改 Master File Table (MFT) 元数据,再加上 Windows Defender 杀毒引擎对每个新落盘的 .exe、.pyd、.pyc 逐个执行静态启发式扫描,系统开销直接被放大成了原本的百倍。
为什么机械硬盘和小文件在 Windows 上尤其痛苦?即使在现代 NVMe SSD 上,小文件创建依然饱受文件系统锁竞争与防病毒实时保护钩子(Filter Manager MiniFilter)的双重拖累。 微软的实时保护服务(
MsMpEng.exe)会在文件IRP_MJ_CREATE和IRP_MJ_CLEANUP时注入检测。如果安装器多线程并发狂写 6000 个 Python 模块,CPU 全部会被系统中断和 Defender 占满,磁盘队列深度瞬间飙升。
展开模块的历史初衷
为什么 Python 打包会产生这么多散落的小文件?
Python 后端打包使用的是 PyInstaller。默认情况下,PyInstaller 会把所有依赖模块压缩进一个单一的 .pkg 或 .exe 归档里。
但在早期的项目配置中,为了优化差量更新,前人显式打开了 noarchive=True,强制将依赖展开为裸文件。
这个决策在当时绝不是盲目的: 如果打成单一的大归档文件,只要开发者改动了一行无关紧要的业务代码,重新打包后整个归档文件的哈希和二进制排布都会发生全局漂移。差量算法(如 bsdiff / courgette)很难在打乱的压缩块中找出复用点,导致即使改了一个字母,差量包也会飙升到 60~80 MB!
把模块全拆散后,没有变动的上千个三方依赖文件 Hash 完全不变,差量引擎可以精准跳过,更新包因此奇迹般地压到了 5 MB。
但这本质上是一场**“各怀鬼胎”的代价值换**:
| 方案 | 差量下载体积 | 安装与安全扫描耗时 | 痛点归宿 |
|---|---|---|---|
| 单文件归档 (Archive) | 60 ~ 80 MB | < 5 秒 (文件极少,顺序解压) | 消耗用户下载流量与服务器带宽 |
| 裸文件展开 (No-Archive) | 4 ~ 8 MB | > 60 秒 (6000+ 文件创建与扫描) | 极度折磨本地磁盘与用户耐心 |
没有免费的午餐:网络传输 vs 本地 I/O差量算法只对“网络传输阶段”负责。它让你在带宽上省下来的每一分钱,最终都被本地 NTFS 的元数据开销和安全软件的查杀税原封不动地讨了回去。
左右为难的架构抉择
抓出根因后,团队内部有了分歧:要不要直接改回默认归档打包?
我拉了个分支,把 noarchive 关掉,打出单归档安装包进行端到端构建。安装阶段确实立竿见影:几秒钟直接刷完,Defender 连头都没回。
但代价是沉重的:日常一个小补丁,差量包直接从 6 MB 暴增到 72 MB。在海外或弱网环境下,下载 72 MB 耗费的时间,可能比在本地卡顿一分钟更让用户绝望。
更要命的是:在开发者自己的 Mac 工作机上构建测试,是永远感受不到这个痛点的。 APFS 文件系统对小文件的处理机制不同,且没有 Windows Defender 疯狂拦截。如果脱离真实 Windows 环境与低配机器,仅看本地构建速度,得出的所有结论都是偏颇的。
科学拆解:给一分钟做个“全身 CT”不能凭感觉拍脑袋,必须在真实的 Windows 测试机上把那 60 秒拆成精确的时段:
- 下载差量包耗时;
- 校验补丁 Hash 耗时;
- 解压并应用差量补丁耗时;
- 覆盖写入正式安装目录耗时;
- 进程拉起耗时。
监控显示,真正的瓶颈恰恰卡在“临时目录解压”向“正式安装目录写落盘”阶段的 Defender 实时扫描。
分层归档的折中之道
“非黑即白”的选择往往意味着设计不够成熟。既然全展开会死于小文件,全打包会死于差量漂移,那为什么不能按更新频率对依赖进行分类?
- 稳定的运行时与核心三方库(90% 体积):如
torch、cryptography、pydantic等基础大库,半年都不会变一次。将它们固化进一个独立的预编译共享归档(Shared Archive); - 易变的业务逻辑代码(10% 体积):用户界面、抓取规则、提示词工程等高频变更模块,保持较小的独立粒度。
这样一来,差量更新只用对变动频繁的业务层计算增量;而那几千个常年不动的三方库由于保持在稳定归档内,既不会把差量包撑大,也不会在每次补丁升级时让操作系统重新写一遍 6000 个小文件。
更新体验不是单项指标的狂欢
很多性能优化往往容易陷入局部视角的数字游戏:做前端的只看包体积,做后端的只看压缩比,做算法的只看 diff 减小了几个百分点。
但用户对软件品质的感知是一个整体链路: 更新耗时 = 下载耗时 + 校验耗时 + 磁盘写入耗时 + 安全查杀耗时 + 重启就绪耗时。
只在网络侧拼命压榨出漂亮的 5MB,却把烂摊子甩给操作系统的 I/O 队列和杀毒软件,这种“优化”在用户眼里不过是把一种痛苦换成了另一种更隐蔽的折磨。