Others

5 MB 的更新包在 Windows 上安装时为什么会卡一分钟

2021 字约 7 分钟

摘要为了把更新包压到最小,我们把数千个 Python 编译模块展开成独立文件。差量包确实变小了,但 Windows 的 NTFS 和 Defender 在安装时处理这些小文件很慢。本文记录这一权衡的过程。

Auto Email Sender 是一个 Electron 外壳加 Python 后端的桌面软件。为了让用户在每次发版时都不必重新下载几百 MB 的完整安装包,Windows 端上线了基于二进制差量(Differential Update)的更新机制:服务器只计算新旧版本之间的差异补丁,客户端下载补丁后,在本地进行增量替换。

在实验室测试中,这套机制效果很好:几十个 bugfix 版本的差量补丁只有 4 到 8 MB,几秒钟就能下载完。

但在真实用户那里,反馈却是另一回事:

“明明下载很快,点击‘立即重启更新’之后,进度条会在 90% 停留整整一分钟!”

“这段时间风扇狂转,资源管理器里的网络流量却是零,你们的安装器是在后台挖矿吗?”

数据只有几 MB,硬盘也是读写 3000 MB/s 的 NVMe SSD,这一分钟到底花在了哪里?

六千多个零碎文件

起初我怀疑是差量合并时的解压算法太耗 CPU,或者压缩参数过于激进,导致解压耗时过长。

但我解压了最终的构建产物,查看目录结构后,发现了真正的问题:

整个应用解压后大约 700 MB,这个大小是正常的。但文件总数高达 6400 多个。其中超过 4000 个是 Python 后端依赖库中的 .pyc 字节码文件,每个只有几 KB 到几十 KB。

在 Windows 上,写一个 700 MB 的单文件和写 6000 个约 10 KB 的小文件,性能差别非常大:

【单大文件写入 (连续 I/O)】
打开文件句柄 ──> 分配连续簇 ──> 流式高速写入 ──> 触发 1 次安全扫描 ──> 关闭句柄
 
【6400 个小文件写入 (随机元数据操作)】
[循环 6400 次]:
  MFT 记录分配 ──> 创建文件句柄 ──> 写入几个扇区 ──> 更新目录树索引
  ──> 触发 Windows Defender 内核驱动拦截并扫描
  ──> 扫描通过 ──> 释放句柄 ──> 提交事务日志 (USN Journal)
通俗比喻:搬一整箱苹果,还是过安检时搬 6000 颗散装黄豆

为什么写 700 MB 的大文件很快,而写 6000 个几 KB 的小文件会卡住? 想象你要过高铁安检:

  • 单大文件:相当于推着一个大行李箱,安检员扫一下,几秒钟就放行了;
  • 6000 个小文件:相当于口袋里装着 6000 颗散装黄豆,安检员要求每一颗都要单独拿出来,放到托盘上,仔细检查、登记,然后才放行下一颗。

在 Windows 上,Defender 就是这个认真的安检员。即使 SSD 写入速度很快,系统和杀毒软件连续执行 6000 次“打开、检查、登记、关闭”的操作,仍然需要很长时间。

SSD 的顺序吞吐量可以达到几 GB/s。但 NTFS 需要频繁修改主文件表(MFT)等元数据,同时 Windows Defender 还会对每个新写入的 .exe、.pyd 和 .pyc 文件进行静态启发式扫描。两者叠加之后,系统开销会被放大到原来的几十倍甚至上百倍。

为什么小文件在 Windows 上特别慢?

即使在现代 NVMe SSD 上,小文件创建仍然会受到文件系统锁竞争和防病毒实时保护(Filter Manager MiniFilter)的影响。 Defender 的实时保护服务(MsMpEng.exe)会在文件的 IRP_MJ_CREATE 和 IRP_MJ_CLEANUP 阶段进行检测。如果安装器多线程同时写入 6000 个 Python 模块,CPU 会大量消耗在系统中断和 Defender 上,磁盘的队列深度也会迅速升高。

为什么要展开模块

Python 后端使用的是 PyInstaller 打包。默认情况下,PyInstaller 会把依赖模块压缩进一个 .pkg 或 .exe 归档文件中。

但项目早期为了优化差量更新,显式设置了 noarchive=True,把依赖展开成独立的文件。

当时这个决定并非盲目:

如果所有模块都打包进一个归档,只要开发者修改了一行无关的业务代码,重新打包后,整个归档的哈希和二进制排布都会发生变化。差量算法(如 bsdiff 或 courgette)很难在打乱的压缩块中找到可复用的部分,于是即使只改了一个字符,差量包也会增大到 60 到 80 MB。

而把模块全部展开之后,上千个未改动的第三方依赖文件哈希保持不变,差量引擎可以直接跳过,更新包因此降到了 5 MB 左右。

这其实是一种取舍:

方案差量下载体积安装与安全扫描耗时主要问题
单文件归档(Archive)60 到 80 MB5 秒以内(文件少,顺序解压)消耗用户流量和服务器带宽
裸文件展开(No-Archive)4 到 8 MB60 秒以上(6000 多个文件的创建与扫描)本地磁盘和用户的耐心都受到压力
没有免费的午餐:网络传输与本地 I/O 之间的权衡

差量算法只减少了网络传输的数据量。它在带宽上节省的部分,最终会以本地 NTFS 元数据开销和安全软件扫描的形式,全部转移到用户的电脑上。

两难的架构选择

找到根本原因之后,团队内部出现了分歧:是否直接恢复为默认的归档打包方式?

我拉了一个分支,关闭 noarchive,打出单归档安装包进行端到端的构建。安装阶段的效果很明显:几秒钟就完成了,Defender 几乎没有任何反应。

但代价也很大:日常的小补丁,差量包会从 6 MB 增加到 72 MB。在海外或弱网环境下,下载 72 MB 所需的时间,可能比在本地卡顿一分钟更让用户难以接受。

更关键的是,在开发者自己的 Mac 上构建测试,是无法发现这个问题的。 APFS 对小文件的处理方式不同,也没有 Windows Defender 的实时拦截。如果不在真实的 Windows 环境和低配机器上测试,只看本地构建速度,得出的结论会有偏差。

实际测量:给这一分钟做一次分段检查

不能凭感觉判断,应该在真实的 Windows 测试机上,把这一分钟拆分成几段分别计时:

  1. 下载差量包的耗时;
  2. 校验补丁哈希的耗时;
  3. 解压并应用差量补丁的耗时;
  4. 写入正式安装目录的耗时;
  5. 启动进程的耗时。

监控结果显示,真正的瓶颈在于从临时目录解压到正式安装目录写入的阶段,Defender 对每个文件进行的实时扫描是主要原因。

按更新频率分层归档

非此即彼的选择往往说明设计还不够成熟。既然全部展开会因为小文件太多而变慢,全部打包又会因为差量漂移而体积变大,那么能否按更新频率来区分依赖?

  1. 稳定的运行时和核心第三方库(约占 90% 的体积):如 torch、cryptography、pydantic 等基础库,半年都不会变化一次。把它们固化到一个独立的预编译共享归档(Shared Archive)中;
  2. 经常变化的业务代码(约占 10% 的体积):如用户界面、抓取规则和提示词模块,这些代码变动频繁,保持较小的独立粒度。

这样,差量更新只需要对频繁变动的业务层计算增量。而那几千个长期不变的第三方库都保存在稳定的归档中,既不会让差量包变大,也不会在每次补丁升级时让系统重新写入 6000 个小文件。

更新体验是一个整体指标

性能优化常常陷入局部视角:前端只看包体积,后端只看压缩率,算法只关注差量减少了多少个百分点。

但用户对更新体验的感受是一条完整的链路:

更新耗时 = 下载耗时 + 校验耗时 + 磁盘写入耗时 + 安全扫描耗时 + 重启就绪耗时。

如果只在网络侧把差量压到 5 MB,却把问题留给了操作系统的 I/O 队列和杀毒软件,那么用户感受到的仍然只是另一种形式的痛苦。

评论