Junie's Blog

为什么客户端更新不能为了图快直接覆盖安装目录

全文共 2034预计阅读 7 分钟

在排查 Windows 差量更新速度缓慢的问题时,我在安装脚本里抓到了一段看起来“蠢到家”的冗余逻辑:

差量包下载解压后,安装器并没有直接去改安装目录,而是:

  1. 先完整解压到 %TEMP%\app_staging
  2. 校验文件哈希;
  3. 再把整个目录里的 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 槽位能做到真正的事务安全?
  1. 写入隔离:新版本完全写在全新的独立物理文件夹里,对当前运行的版本零干扰;
  2. 原子切换:通过 NTFS Junction(目录连接点)或快捷方式修改,切换只发生在毫秒之间;
  3. 极速回滚:如果新版本第一次启动触发了严重的 Crash Loop,看门狗脚本只需将指针改回 Slot A,用户就能立刻回退,绝不丢数据。

唯一的代价是需要占用两份软件的磁盘空间(对现代数 TB 硬盘来说微不足道),以及需要额外管理旧槽位的垃圾回收。

永远敬畏那段看似多余的代码

在成熟工程系统里,如果你看到一段写得很别扭、很慢、看起来充满了“历史遗毒”的代码,在没有搞清楚前人为什么这么写之前,千万不要急着把它删掉

临时目录的那一层拷贝,在性能分析器(Profiler)里看是一根刺眼的红条,但在系统架构师眼里,那是守卫软件最后尊严的一道防火墙。

宁可让用户在进度条前多等 20 秒,也绝不让任何一个用户在软件崩溃的灰烬中对着变砖的电脑骂娘。

评论