为什么客户端更新不能为了速度直接覆盖安装目录
1821 字约 7 分钟
摘要为了加快 Windows 更新,我删掉了先解压到临时目录再覆盖的步骤。这个改动看似合理,但在断电和杀毒软件拦截的情况下,可能导致大量电脑无法正常启动。
在排查 Windows 差量更新速度慢的问题时,我在安装脚本中发现了一段看起来多余的逻辑:
差量包下载并解压后,安装器没有直接修改安装目录,而是:
- 先完整解压到
%TEMP%\app_staging; - 校验文件哈希;
- 再把整个目录中的 6000 多个文件用
xcopy /s复制到正式的Program Files安装目录。
当时我的第一反应是:这一步没有必要。
整个应用接近 700 MB,几千个文件,在磁盘上完整写入一遍已经不快了,还要在同一个分区的不同目录之间再“搬运”一次。
差量更新在网络上省下的时间,全被这次多余的本地 I/O 抵消了。于是我想到:为什么不直接把更新包解压覆盖到正式安装目录?
一个看起来很划算的优化
这个改动很简单,甚至不需要修改底层的 C++ 代码,只需要在 NSIS 安装脚本中把解压的目标路径从 TEMP 改为 $INSTDIR。
为了确认改动是否安全,我在本地打包了对比版本,结果如下:
- 差量算法不受影响:安装包内部的归档结构和压缩块都没有变化;
- 磁盘写入减少一半:省去了 700 MB 的临时文件和 6000 次二次移动;
- CI 测试全部通过:安装器可以正常编译,自动化脚本顺利完成安装,更新后的主进程也能很快启动。
这看起来是一个很典型的“低垂果实”:没有引入复杂的算法,删掉几行历史遗留的代码,安装时间就能明显缩短。
为什么这类优化容易让人盲目自信?因为它符合我们对“好代码”的直觉:路径更短、冗余更少、资源消耗更低,并且在正常路径的集成测试中表现良好。
如果中途断电会怎样
就在我准备提交 PR 时,同事在评审中问了一个问题:
“如果解压到一半,用户的电脑突然关机,或者 Defender 弹出提示把安装器进程杀掉,会发生什么?”
这个问题让我意识到了风险。
我在纸上画出了直接覆盖时发生故障的状态:
原版本文件:
[主程序.exe (v1.0)] [core_runtime.dll (v1.0)] [python310.dll (v1.0)]
安装器开始解压覆盖 v2.0...
[主程序.exe (已变 v2.0)] ──> 此时突然断电 / 磁盘写满 / 杀毒软件拦截并导致崩溃
[core_runtime.dll (仍是 v1.0)]
[python310.dll (写入一半,已损坏)]通俗比喻:空中换发动机,与地面组装后再切换为什么要多走一步临时目录?
- 直接覆盖:相当于在飞机飞行过程中更换发动机。只要拧螺丝时出现意外(断电、杀毒软件拦截),飞机就会坠毁,没有机会退回原状;
- 临时目录中转:相当于在地面车间组装好新发动机,并完成调试(在临时目录校验哈希)。确认无误后,等飞机停稳,再用几秒钟完成替换(重命名或替换)。即使中途断电,旧飞机仍然完好,不会变成一堆残骸。
这时,用户的安装目录会变成什么状态?
它既不是 v1.0,也不是 v2.0,而是新旧版本混杂、依赖错位,甚至有些二进制文件大小为 0 KB 的状态。
情况还会更糟:
- 用户双击桌面快捷方式,新版本的可执行文件会去调用旧版或损坏的动态库,然后因
0xC0000005内存访问冲突而闪退; - 软件内置的自动恢复和错误上报机制依赖主进程运行,而主进程连 Runtime 都无法启动,也就无法把错误日志发送到服务器;
- 用户只能卸载软件,但
uninstall.exe也可能已经损坏。
原来那个被我认为多余的临时目录,虽然多做了一次 I/O,却构成了一个基本的事务隔离区(Staging Area):
临时目录的真正作用:隔离未经验证的状态只有临时目录中的几千个文件全部解压完毕,并且逐个校验哈希通过后,更新器才会替换旧文件。即使在解压到第 59 秒时断电,旧版本的安装目录仍然完好,用户下次打开软件,仍然可以正常使用 v1.0。
正常路径决定速度,异常路径决定底线
性能优化时,很多工程师容易陷入“幸存者偏差”:认为软件大部分时间都在正常运行,因此所有设计都应该优先考虑正常路径。
但对于操作系统级软件,尤其是安装器和更新器,实际环境中有很多风险:
- 用户的 C 盘可能只剩 300 MB,写到一半磁盘空间耗尽;
- Windows Defender 可能在第 4500 个文件写入时加锁扫描 5 秒,导致写入句柄报
ERROR_SHARING_VIOLATION; - 旧版本的某些后台进程可能没有完全退出,导致某个
.dll被只读锁定; - 笔记本电池耗尽自动关机,或者有人误按了机箱的电源键。
| 方案 | 正常升级耗时 | 中途崩溃或断电的后果 | 可恢复性 |
|---|---|---|---|
| 直接解压到安装目录 | 15 秒 | 应用被撕裂,新旧文件混杂,软件无法使用 | 无法自动恢复,只能手动重装 |
| 临时目录中转校验 | 35 秒 | 临时目录残留文件,但正在运行的应用不受影响 | 下次启动仍为旧版本,可重新发起更新 |
看清这个对比后,我果断删除了这项“优化”。
为了节省 20 秒,让 0.1% 的用户面临软件损坏的风险,这种做法是不可接受的。
兼顾速度与事务安全:A/B 双槽位
难道只能接受多余的复制吗?
并非如此。Chrome、VS Code 以及 Android 的 A/B 系统更新,通常采用的是 A/B 槽位切换(Slot Switching),而不是直接覆盖或慢速复制:
[当前运行] ──> Slot A (C:\...\app-1.0.0\) ──> 桌面快捷方式指向这里
│
[后台静默] ──> 解压新版到 Slot B (C:\...\app-1.1.0\) │
│ │
├─ 校验完成,更新快捷方式符号链接 ──────┘
▼
下次启动时切换到 Slot B。如果 Slot B 连续崩溃,则自动回退到 Slot AA/B 槽位为什么能保证事务安全?
- 写入隔离:新版本完整写在一个独立的文件夹中,不会影响正在运行的版本;
- 原子切换:通过 NTFS 目录连接点(Junction)或快捷方式修改来切换,切换过程只需要几毫秒;
- 快速回滚:如果新版本第一次启动就持续崩溃,看门狗脚本只需把指向改回 Slot A,用户可以立即回退,数据也不会丢失。
这种方案的代价是需要为两份软件保留磁盘空间(对现在的硬盘来说影响很小),并且需要额外管理旧槽位的清理。
在删除看似多余的代码之前先弄清原因
在成熟的工程系统中,如果遇到一段写得很别扭、速度较慢、看起来像历史遗留的代码,在没有弄清前人为什么这样写之前,不要急着删除。
临时目录的那次复制,在性能分析器中确实是一个明显的耗时点,但从系统设计的角度看,它是保护软件的一道防线。
宁可让用户在进度条前多等 20 秒,也不能让任何用户面对崩溃、损坏的软件和无法启动的电脑。