Electron自动更新工程化:electron-updater、差分更新、灰度与回滚实践
我曾经接手过一个挺典型的桌面端项目:用了 Electron 做了个生产力工具,功能迭代很快,每周都要发版本。前半年大家还手动下载安装包,后来用户量一上来,客服和社区里天天有人问"新功能什么时候能收到""为什么我还是旧版"。
真正把我逼到必须认真搞自动更新的,是那次线上事故。我发布了一个修复严重 bug 的版本,结果第二天一看数据,还有将近四成的活跃用户停留在旧版本上,那个 bug 一直没修到他们身上。我挨个问了一圈,发现不少人确实在官网看到过更新提示,但嫌麻烦没点。那一刻我意识到:客户端应用如果没有自动更新,你的代码再勤奋、修复再及时,都到不了用户手里。
这也是我今天想把 Electron 自动更新工程完整讲透的原因。标题里列的四件事——electron-updater、差分更新、灰度发布、失败回滚——不是四个互相独立的模块,而是一条完整工程链路的前、中、后三段。它们解决的是从上家到止:怎么把新版本安全地送到用户设备、怎么把流量成本压下来、怎么在风险可控的前提下扩大推送面、以及万一失败怎么把用户捞回来。我会把这条链路从选型到落地、从原理到踩坑完整写下来,适合正在用 Electron 做桌面应用、或者正打算自建更新通道的团队参考。
1. 为什么我说自动更新是桌面应用的"生命线"
1.1 没有自动更新的桌面应用有多痛苦
Web 应用有天然优势,改后端就等同于全员升级,用户无感知。桌面应用完全不是这个逻辑,代码在用户硬盘上跑着,你不主动去"替换"它,它就永远停在你最后一次发布的状态。这不是产品体验问题,是工程债。
我的实际体验是,没有自动更新时你至少要面对三件麻烦事:第一是安全漏洞无法及时修补,尤其如果你的应用涉及本地文件解析、网络请求这类高危面,一个漏洞拖上几周,风险是在用户侧持续累积的;第二是产品迭代节奏被拖垮,你精心做的新功能,用户可能一个月后才通过朋友截图才知道"原来还有这个";第三是客服成本倍增,新版改了接口、换了数据格式,旧版调用不了,用户只会觉得"你的软件出 bug 了"。
自动更新解决的问题本质上是三个字:一致性。它让"开发者的最新版本"和"用户运行的版本"之间的距离尽可能缩短。
1.2 方案选型:electron-updater 为什么是默认答案
Electron 生态里,自动更新的方案其实不多。一个是 Electron 官方文档推荐的 Squirrel,分 Windows 和 macOS 两套,适配成本高,而且对安装包的构建流程要求很苛刻。另一个就是 electron-builder 配套的 electron-updater,这也是目前社区里最主流、我会优先推荐的选择。
我推荐 electron-updater 的原因非常务实。它和 electron-builder 是无缝集成的,你构建安装包时顺手就把更新元数据和差异文件生成了,不用另外写一套发布管道。它支持的发布源很全,GitHub Releases、S3、generic HTTP 服务器、坚果云这种纯静态服务器都可以,这对没有复杂基础设施的小团队特别友好。而且它的差分更新能力在 Windows 平台非常成熟,能显著节省带宽,这个后面我会专门展开。
如果说 electron-updater 有一点学习门槛,那就是它的文档比较散,很多关键配置散落在 issue 和源码注释里。我在下文会把核心链路直接给你梳理好。
1.3 一条完整的更新链路拆给你看
整个自动更新流程拆开看,其实就六个环节:
- 发布端构建:用 electron-builder 打包出新版本安装包,同时生成 latest.yml(更新元数据)和 .blockmap(差分更新映射文件)
- 上传分发:把安装包、元数据、blockmap 上传到你配置的发布源
- 客户端检查:应用启动后或者定时触发的某个节点,electron-updater 向服务器请求 latest.yml,对比本地版本号
- 下载更新:如果发现新版本,按需下载全量包或差分包
- 校验与安装:下载完成后校验文件哈希,然后调用系统的安装器执行静默安装
- 重启生效:安装完成后自动重启应用,完成整个升级闭环
灰度发布和失败回滚其实穿插在这条链路里:灰度是在"检查与下载"阶段做流量控制,回滚是在"校验与安装"阶段做兜底保障。下面我按这个顺序逐一展开。
2. electron-updater 接入实战与差分更新的底层原理
2.1 从零完成 electron-updater 接入
接入 electron-updater 的第一步是安装依赖并做基础配置。这里我给出一个可以直接照抄的最小配置。
先看 electron-builder.yml 里的核心配置:
这里的 provider: generic 表示使用通用 HTTP 服务器作为发布源,url 指向你存放更新文件的目录。如果你用 GitHub Releases 或者 S3,provider 对应改成 github 或 s3 即可,electron-builder 会自动处理上传和元数据生成。
主进程里的更新逻辑是核心。我通常这样组织代码:
几点说明。autoDownload = false 是我个人比较推荐的做法,先检查、再提示、后下载,避免在用户不知情的情况下悄悄消耗流量。autoInstallOnAppQuit 表示用户退出应用时自动完成安装,配合 autoRunAppAfterInstall 可以实现"退出后自动安装、装完自动拉起新版本"的顺滑体验。
需要特别提醒的是,开发环境下 electron-updater 默认不会工作。你需要手动在 resources 目录下放置一个 dev-app-update.yml,文件内容和 app-update.yml 类似,指定 provider 和 url,才能在本机调试更新流程。这块官方文档写得不明显,当年我卡了一天。
2.2 差分更新是怎么回事
差分更新是这个方案里最能省钱的一个环节,也是很多人没搞明白的部分。它的核心逻辑可以用一句话概括:只下载新版本和旧版本之间真正不同的字节,而不是把整个新安装包重新拉一遍。
electron-builder 在构建 Windows NSIS 安装包时,会生成一个 .blockmap 文件。这个文件里记录的是安装包被切分成的固定大小数据块(block)的哈希列表。客户端本地有当前版本的安装包缓存,更新时 electron-updater 会拉取新版本的 blockmap,和本地已有安装包的 blockmap 做比对,算出哪些 block 需要重新下载、哪些可以直接复用。之后通过 HTTP Range 请求只下载缺失的 block,再拼接出完整的安装包,最后校验整体哈希,确认无误后执行安装。
这套机制在实践中的收益非常直观。一个 80MB 的安装包,如果新旧版本之间只是替换了几个 dll 或重构了部分渲染逻辑,差分更新往往只需要下载几 MB 甚至更少的数据。网络越差,差分方案的优势越明显。对面向 C 端用户的应用来说,这直接决定了更新成功率,因为很多用户就是在一个不稳定的网络环境下使用你的应用。
但有一点必须说清楚:差分更新依赖之前安装包的缓存。electron-updater 在 Windows 上会把已下载的安装器缓存在本地的 updater 目录。如果用户是全新安装、或者缓存被清理了,那就只能回到全量下载。所以设计上一定要保证全量下载这条链路同样健壮,差分只是锦上添花。
2.3 必须注意的平台差异与签名校验
electron-updater 在不同平台上的差分和安装机制差别很大,这块我踩过的坑比较多,整理了一个对比表供参考:
| 平台 | 安装包格式 | 差分更新 | 安装方式 | 关键注意事项 |
|---|---|---|---|---|
| Windows | NSIS (.exe) | 支持,基于 blockmap | 静默安装,安装后自动拉起 | 注意安装权限、杀软拦截 |
| macOS | zip / dmg | zip 支持,dmg 不支持 | 替换 app 目录,需要应用签名 | 必须公证(notarization) |
| Linux | AppImage | 支持(AppImage 自身机制) | 替换 AppImage 文件 | 发行版差异性大,实测效果不如 Windows |
在 Windows 上,最容易被忽略的是 NSIS 安装器的权限模型。如果你的应用装在 C:\Program Files 下,更新安装时需要管理员权限,一旦用户当前没有提升权限,静默安装就会失败。我后来把安装策略改成了 perMachine 只读不改,或者干脆把应用默认安装到用户目录,更新成功率的提升非常明显。
macOS 的情况更微妙。新版 macOS 强制要求应用签名必须经过 Apple 的公证流程,否则 Gatekeeper 会直接拦截。**如果公证过期或者签名不匹配,更新时应用连启动都做不到,更谈不上安装新版本。**这块不是 electron-updater 的问题,但绝对是自动更新失败的一大来源,后面排查部分我会再展开。
3. 灰度发布:让更新风险可控的三种落地方式
3.1 为什么灰度,以及灰度到底在"灰"什么
有不少团队第一次做灰度时的理解是:把新版本先发给一部分用户,观察一阵子再全量推送。这个理解对,但不够深。灰度真正要控制的不只是"发不发、发给谁",还要控制更新策略本身的差异。
我为什么强调这一点?因为在真实的自动更新工程里,新版本带来的风险往往不是代码层面的闪退那么直接。它可能体现在数据迁移失败、某个第三方 SDK 在新环境不兼容、UI 改动影响了部分用户的操作习惯。灰度给了你一个"小规模暴露问题 + 快速止损"的机会。更关键的是,如果灰度和失败回滚配合得好,你可以在几小时内就把问题版本的影响面限制在极小范围,而不是等到用户大面积反馈才被动响应。
3.2 方式一:服务端请求参数控制比例
electron-updater 检查更新时,实际上就是向发布源发起一个 HTTP 请求,拿到 latest.yml 里的版本信息。这给了我们最大的灵活度:更新服务器完全可以是一个动态接口,根据请求参数返回不同的更新响应。
具体做法是,不把服务端简单配置成静态文件托管,而是做一个轻量接口。客户端请求时携带当前版本号、平台、用户唯一标识等信息,服务端根据灰度规则决定返回什么:
用用户 ID 做哈希取模的好处是:同一个用户每次请求拿到的灰度结果都是稳定的。如果用户第一次请求被灰度命中、下载到一半失败,重试时不会被随机算法"踢出"灰度池;反过来,如果某次接口异常让他进入了灰度池,下次还能稳定复现。
3.3 方式二:利用 release channel 做阶段分发
如果你不想写动态接口,electron-builder 自带的 channel 机制也能实现简单的灰度效果。它的思路是构建时生成多个发布通道的更新元数据,比如 latest.yml 对应正式通道,latest-beta.yml 对应测试通道。
客户端配置了不同 channel 的应用,会去请求不同的更新元数据。你可以先把新版本发布到 beta 通道,让内部用户和种子用户用 beta 版,验证没问题后再发布到正式通道。这本质上就是"按通道灰度",工程成本很低,但颗粒度比较粗,它不能精确到"给 20% 的用户推新版",只能区分"你用的是哪个通道"。
这种方式比较适合团队内部验证,或者配合手动操作做小范围试跑。要对真实用户做精细灰度,还是建议用服务端方案。
3.4 方式三:按版本条件定向灰度
还有一种我在实践里经常用的灰度维度是 按当前版本定向。比如这次新版重点优化了从 1.4.x 升级的迁移逻辑,那我希望灰度对象集中在 1.4.0 和 1.4.1 的用户,老版本用户暂时不推送,因为他们的数据结构和升级路径需要额外验证。
这种条件在静态服务上做不了,但在服务端接口里实现就是一条简单的判断语句。同样是请求更新,不同版本号返回不同的结果。它和前面的比例灰度不冲突,可以叠加:先筛版本,再对筛选后的用户做比例控制。叠加规则的好处是升级路径可控,每次变更的影响面都收敛在你能预判的范围内。
3.5 灰度期间的配套手段:强制更新与静默更新
灰度发布做起来之后,还有一个问题必须处理:被灰度命中的用户,和漏在灰度池外的用户,体验要不要保持一致?
这里我建议把更新的触发形态分成两种。修复关键安全漏洞或数据 bug 的版本,建议做"强制更新",即检测到新版本后直接弹窗告知用户,不更新就不能继续使用核心功能;而普通功能迭代,可以做成"静默下载、提示重启",下载在后台完成,用户下次退出时自动安装,尽量不打断使用。
electron-updater 实现这两种模式都非常顺手。强制更新就是监听 update-available 后立即 autoUpdater.downloadUpdate(),下载完成后提示用户;静默更新则是把 autoDownload 设为 true,然后让 autoInstallOnAppQuit 在退出时兜底。灰度期间建议先用静默模式,把用户无感升级的比例跑起来,确认稳定后再逐渐开启强制更新。
4. 失败回滚:最后一道兜底防线怎么设计
4.1 更新失败的类型与影响面
回滚设计的第一步是清楚地知道"失败"到底发生在哪个环节。我把实际运维中见过的更新失败归成四类:
| 失败类型 | 典型表现 | 影响面 |
|---|---|---|
| 下载失败 | 网络超时、服务器 404、缓存损坏 | 用户停在旧版,影响较小 |
| 校验失败 | 哈希不匹配、block 拼接出错 | 停在旧版,需要重试 |
| 安装失败 | 权限不足、安装包损坏、杀软拦截 | 可能同时影响新旧版本 |
| 启动失败 | 新版本闪退、依赖缺失、数据迁移异常 | 用户卡死在新版或丢失服务 |
前两类失败相对温和,最坏情况是用户暂时收不到更新,重试机制兜底即可。真正的噩梦是第三类、第四类:安装过程搞坏了旧版本,或者新版本装上后根本起不来,用户打开软件就是白屏或立刻闪退。这是回滚方案必须解决的核心问题。
4.2 electron-updater 框架本身的回滚边界
坦白讲,electron-updater 对"失败回滚"这个话题没有提供开箱即用的完整方案。它把重点放在"如何把新版本可靠地送到用户设备上并执行安装",但对"安装完成、启动后炸了怎么办"这类场景,需要你自己在设计层面补完。
我看到很多人对 electron-updater 有个误解,以为它自带回滚能力。实际上 Windows 上 NSIS 安装器如果安装到中途失败,它能尽量恢复原始状态,但前提是系统层面的安装事务没有被破坏。而新版本启动失败这种场景,安装器根本感知不到,因为安装过程已经成功结束了。所以回滚能力必须构建在应用自身的启动逻辑里,这是自动更新工程最容易被忽略的一环。
4.3 一种务实的"安装前备份 + 启动健康检查 + 自动恢复"方案
我在生产环境里真正落地过、也验证过比较稳的方案分三步,核心思路是:先备份,再安装,通过启动状态判断是否回滚。
第一步是安装前备份。在 update-downloaded 事件触发后、真正 quitAndInstall 之前,把当前版本的安装目录复制一份到本地临时目录,比如 %LOCALAPPDATA%\MyApp-backup。注意要排除一些运行时文件,只备份真正属于应用的核心目录。
第二步是启动健康检查。安装完成、新版本启动后,主进程会先检查一个本地状态文件,比如 update-state.json,里面记录了"上次更新前版本"和"本次更新目标版本"。启动时判断:如果状态文件显示"正在更新中但我这个版本已经是目标版本了",说明更新已经完成,进入正常逻辑;如果启动后主进程在限定时间内没有完成必要的初始化,或者发生了会导致主窗口无法打开的异常,就在崩溃前把状态标记为 failed。
第三步是自动恢复。检测到 failed 状态后,应用下一次启动时不再走正常流程,而是先执行恢复脚本:杀掉残留的新版本进程、清空安装目录、把备份目录复制回来、恢复 update-state.json 到空状态,然后启动旧版本。对于用户来说,他的感受只是"上次更新好像没成功,但我还能正常用软件",这已经是最好的回滚体验了。
这套方案说起来简单,但有几个细节必须注意。备份前一定要确认磁盘空间充足,否则备份过程中会失败;健康检查的结果必须写回状态文件,否则回滚条件永远无法触发;恢复脚本本身要做幂等设计,重复执行不能产生副作用。我经历过一次极端情况:新版本启动后崩溃了,但因为状态文件没写对,恢复脚本尝试了三次才真正把旧版恢复回来,那几分钟用户完全无法使用,体验很差。
4.4 回滚之外的监控与上报
回滚做得再好,也不能替代监控。我建议在自动更新链路的每一个关键节点都埋上报点:发起检查、发现有新版本、开始下载、下载完成、开始安装、安装完成、应用启动成功、应用启动失败。这些事件统一上报到日志系统后,你就可以实时看到"当前有多少用户成功升到了新版本,又有多少用户卡在了哪个环节"。
很多更新问题不是复现不出来,是你不在用户的设备现场,只能靠日志反推。如果日志上报做得及时,很多事故可以在早期就发现苗头。比如某次灰度更新后,我发现某批用户的启动失败率突然上升到 10%,于是立刻把灰度比例调整回 0,并触发回滚逻辑。如果全靠用户投诉再处理,至少要多暴露一整天的问题版本。
5. 真实环境踩坑记录与排查路径
5.1 案例一:更新检查成功但总是下载失败
这个问题的表现很典型:日志显示检查到了新版本,但下载到一半就失败,重试仍然失败。我排查后发现根因在更新服务器的 HTTPS 证书配置,下载过程中某个分块请求校验失败,导致连接被重置。
这类问题的排查思路是:先在客户端日志里定位失败的具体阶段,看是网络层错误、TLS 错误还是文件校验错误。然后分别在服务器端用 curl 模拟 Range 请求,验证安装包和 blockmap 文件是否完整、CORS 是否配置正确、CDN 回源是否正常。最容易忽略的一点:很多团队启用了 CDN,但 CDN 节点只缓存了 full 文件,没缓存 blockmap,导致差分请求回源超时。
5.2 案例二:macOS 公证问题导致更新安装失败
macOS 的更新失败在各平台里是最难排查的,因为它的安全机制很隐蔽。我遇到过用户反馈"点了更新,重启后还是旧版",日志里没有任何明显的错误代码。查了很久才发现是公证签名失效,新版本在 Gatekeeper 阶段就被拦下来了。
macOS 自动更新的发布流程比 Windows 复杂得多,除了常规签名,必须保证每次发布的压缩包经过 notarytool 公证,并把公证书 stapled 到安装包上。如果你用 GitHub Actions 这类 CI 自动发布,要特别留意证书过期和公证排队超时,这两个问题我都踩过。排查时可以查看系统日志里的 Code Signing 相关记录,能直接看到拦截原因。
5.3 案例三:Windows 下安装包被安全软件拦截
这类问题在 Windows 上经常出现,尤其是新发布的可执行文件信任分不高时,杀毒软件可能将更新包视为可疑文件。表现是下载完成后,安装环节直接没有进展,进程消失,日志里没有报错。
我的处理策略有两层。第一层是发布前的预防:用代码签名证书对安装包做签名,签名能显著降低杀软的误杀率;第二层是发布后的感知:在安装失败事件发生时,除了记录日志,还可以主动提示用户"更新被安全软件拦截,请手动下载安装包"。这里有个心得:不要把更新包每次都做成结构完全不同的新文件,尽量复用之前的发布路径和文件名规律,信任分会累积。
5.4 排查工具与日志定位的核心路径
最后分享一套我平时排查更新问题的核心路径,希望帮你少走弯路。
在客户端侧,electron-updater 的日志输出非常关键。如果你集成了 electron-log,日志默认写在用户数据目录下,路径类似 %APPDATA%/[应用名]/logs/main.log。排查下载、安装阶段的问题,先去这里翻最后 50 行,通常能找到具体错误码。% LOCALAPPDATA%/electron-updater 目录下可能还有缓存的安装包,也可以检查一下是否损坏。
在服务端侧,重点确认几个文件是否齐全:latest.yml、.blockmap、安装包本体。一个常见的低级事故是上传了安装包但忘了重新上传 latest.yml,导致客户端检查更新时拿到的是旧版本的元数据,自然永远"检查不到新版本"。我建议在发布脚本里加入一个校验步骤,上传完所有文件后模拟一次客户端更新请求,确认返回的新版本号和大小都正确。
如果客户端日志什么都没有,问题可能在检查更新的触发时机本身。很多 Electron 应用只在启动时检查一次更新,如果这里代码逻辑有 bug,比如被某种条件分支挡住了,那整个自动更新就静默失效了。这种问题最难排查,我一般会直接在发布环境里改配置、开启 debug 模式,把每次检查更新的请求 URL 打印出来,然后再逐步收窄。
说实话,自动更新这块工程,真正难的不是把 electron-updater 接进来,而是把差分、灰度、回滚这些环节和你的产品节奏、用户规模结合起来,让每一个环节都在真实环境中替你把关。我自己的体会是,先把全量链路跑通、把日志上报做齐,再逐步引入差分更新和灰度发布,每一步都验证稳定了再往前走。毕竟自动更新这件事,用户平时感知不到它的存在,但一旦出问题,它就会立刻变成你应用的"门面"问题。希望这篇文章里那些亲身踩过的坑和验证过的方案,能让你少走几步弯路。