为什么客户端更新不能为了图快直接覆盖安装目录
在排查 Windows 差量更新速度缓慢的问题时,我在安装脚本里抓到了一段看起来“蠢到家”的冗余逻辑:
差量包下载解压后,安装器并没有直接去改安装目录,而是:
- 先完整解压到
%TEMP%\app_staging; - 校验文件哈希;
- 再把整个目录里的 6000 多个文件全部
xcopy /s复制到正式的Program Files安装目录。
当时我的第一反应是:这不是脱裤子放屁吗?
整个应用将近 700 MB,几千个文件,在磁盘上完整落盘一次已经够慢了,你居然还让它在同一个分区的不同目录之间“搬运”第二遍!
差量更新费尽心机在网络上省下的时间,全被这第二次愚蠢的本地 I/O 给吃掉了。一个极其强烈的念头冒了出来: 为什么不直接一步到位,把更新包解压覆盖到正式安装目录?
一个极其诱人的“性能优化”
改动极其轻松,甚至不需要改底层 C++ 代码,只在 NSIS 安装脚本里改了两行路径:把目标解压路径从 TEMP 直接重定向到 $INSTDIR。
为了验证这个改动是否安全,我立刻在本地打包了对比版本:
- 差量算法不受影响:因为安装包内部的归档拓扑和压缩块完全没变;
- 磁盘写入直接减半:省去了 700 MB 的临时文件创建和 6000 次二次移动;
- CI 测试全绿:安装器能正常编译,自动化脚本顺畅完成安装,更新后的主进程也能秒起。
这简直是教科书级别的“低垂果实(Low-hanging fruit)”——没有引入任何复杂的算法,删掉几行历史包袱代码,安装耗时直接肉眼可见地腰斩。
为什么这种优化最容易让人盲目自信?因为它符合我们对“好代码”的一切直觉:路径更短、冗余更少、资源消耗更低,而且在所有 Happy Path(正常路径)的集成测试里表现完美无瑕。
要是写到一半断电了呢
就在我准备提 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 (写入一半被损坏)]通俗比喻:高空走钢丝 vs 装修样板间为什么一定要多走一步临时目录?
- 直接覆盖:就像你在开着飞机的过程中,直接在空中拆换飞机的发动机。只要螺丝拧到一半手抖掉了一颗(断电、杀软拦截),飞机当场就要坠毁,连退回原状的机会都没有;
- 临时目录中转:就像你在地面工厂把新发动机完整组装好、调试合格(在临时目录校验 Hash),确认万无一失后,等飞机停稳了,只要几秒钟的原子切换操作(重命名/替换),就能平安完成升级。即使中间突然停电,旧飞机依然完整完好,不会变成“半块砖”。
这一瞬间,用户的安装目录变成了什么? 它既不是 v1.0,也不是 v2.0,而是一堆新旧版本混杂、依赖严重错位、甚至部分二进制文件只有 0KB 的“科学怪人”!
更绝望的是:
- 用户重新双击桌面的快捷方式,新版可执行文件去调旧版或者损坏的动态库,直接抛出
0xC0000005内存访问冲突闪退; - 软件内置的自动恢复和上报机制完全依赖主进程运行,现在主进程连 Runtime 都起不来,连给服务器发错误日志的机会都没有;
- 用户唯一能做的,就是骂一句“垃圾软件”,去控制面板卸载它——哦不对,甚至连
uninstall.exe都可能处于损坏状态。
原先那个被我嫌弃的“愚蠢临时目录”,虽然多做了一次 I/O,但它在客观上构成了一个最原始的事务隔离区(Staging Area):
临时目录的真正价值:隔离未经验证的状态只有当临时目录里的几千个文件全部解压完毕、Hash 逐个校验无误之后,更新器才会发出“最后通牒”,原子或快速地完成旧文件的替换。哪怕在解压的第 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 System Updates),它们解决这个问题的方案既不是直接覆盖,也不是慢速复制,而是 A/B 槽位轮替(Slot Switching):
[当前运行] ──> Slot A (C:\...\app-1.0.0\) ──> 桌面快捷方式指向这里
│
[后台静默] ──> 解压新版到 Slot B (C:\...\app-1.1.0\) │
│ │
├─ 校验完成,更新快捷方式符号链接 ──────┘
▼
下次启动无缝切入 Slot B!如果 Slot B 连续崩溃,自动回退到 Slot A为什么 A/B 槽位能做到真正的事务安全?
- 写入隔离:新版本完全写在全新的独立物理文件夹里,对当前运行的版本零干扰;
- 原子切换:通过 NTFS Junction(目录连接点)或快捷方式修改,切换只发生在毫秒之间;
- 极速回滚:如果新版本第一次启动触发了严重的 Crash Loop,看门狗脚本只需将指针改回 Slot A,用户就能立刻回退,绝不丢数据。
唯一的代价是需要占用两份软件的磁盘空间(对现代数 TB 硬盘来说微不足道),以及需要额外管理旧槽位的垃圾回收。
永远敬畏那段看似多余的代码
在成熟工程系统里,如果你看到一段写得很别扭、很慢、看起来充满了“历史遗毒”的代码,在没有搞清楚前人为什么这么写之前,千万不要急着把它删掉。
临时目录的那一层拷贝,在性能分析器(Profiler)里看是一根刺眼的红条,但在系统架构师眼里,那是守卫软件最后尊严的一道防火墙。
宁可让用户在进度条前多等 20 秒,也绝不让任何一个用户在软件崩溃的灰烬中对着变砖的电脑骂娘。