4、增量升级的回退:增量包的依赖关系、回退链设计与版本号管理

增量升级,说白了就是只传差异部分。好处是省流量、省时间,但代价呢?回退逻辑变得复杂了。全量升级的回退很简单——把旧版本整包刷回去就行。增量不行,你没法单独回退一个补丁,因为每个增量包都建立在特定的基线版本上。

我刚开始做OTA那会儿,就吃过这个亏。当时想当然地认为增量包回退就是“反向打补丁”,结果设备变砖了。嗯,今天咱们就把这块彻底讲透。

4.1 增量包的依赖关系

增量包不是独立的。它必须知道:我从哪个版本出发?我修改了哪些文件?我依赖哪些前置条件?

举个例子,假设你的设备当前是 v1.0,你想升到 v1.2。如果直接做一个 v1.0→v1.2 的增量包,那没问题。但如果先升了 v1.1,再升 v1.2,这时候 v1.0 的增量包就不能用了——基线变了。

核心原则:每个增量包必须声明它的 source_version 和 target_version。升级引擎在应用增量包之前,必须校验当前版本是否匹配 source_version。

我在项目中见过一种很实用的做法——在增量包的头部元数据里,放一个依赖清单:

// 增量包元数据示例
{
  "package_type": "incremental",
  "source_version": "1.0.0",
  "target_version": "1.1.0",
  "dependencies": [
    {"version": "1.0.0", "checksum": "a1b2c3..."}
  ],
  "files": [
    {"path": "/system/lib/libfoo.so", "old_checksum": "...", "new_checksum": "..."},
    {"path": "/system/bin/bar", "old_checksum": "...", "new_checksum": "..."}
  ]
}

为什么要放 checksum?因为光靠版本号不够。我记得有一次,客户那边私自改了系统文件,版本号没变,但内容变了。增量包打上去直接崩溃。从那以后,我坚持在依赖里加上文件校验和。

4.2 回退链设计

增量升级的回退,不能只考虑“回到上一个版本”。你得设计一条完整的回退链。

什么叫回退链?就是每个版本都知道自己能从哪些版本升上来,也能知道自己能回退到哪些版本去。

我习惯用有向图来管理这个关系。节点是版本号,边是增量包。举个例子:

增量升级回退链示意图 增量升级回退链设计 v1.0.0 v1.1.0 v1.2.0 v2.0.0 说明: • 实线箭头 = 正向升级路径(增量包) • 虚线箭头 = 回退路径(反向增量包或全量包) • 每个版本只能回退到直接前驱版本,不能跨版本回退(除非有全量包兜底)

看到这个图了吗?每个版本只能回退到它的直接前驱。v1.2.0 可以回退到 v1.1.0,但不能直接回退到 v1.0.0。为什么?因为 v1.2.0 的增量包只记录了 v1.1.0→v1.2.0 的差异,它不知道 v1.0.0 长什么样。

注意:不要试图做“跨版本回退”。如果你必须支持从 v1.2.0 直接回退到 v1.0.0,那就准备一个全量包,或者准备一个 v1.2.0→v1.0.0 的专用反向增量包。否则,回退链会断掉。

4.3 版本号管理策略

版本号不只是给人看的,更是给升级引擎做决策用的。我见过太多团队把版本号当摆设,结果回退时一团糟。

我个人推荐语义化版本号 + 内部构建号的组合方案:

字段 示例 含义 回退中的作用
主版本号 1 不兼容的API变更 主版本不同时,禁止增量回退,必须用全量包
次版本号 2 向下兼容的功能新增 次版本不同时,允许增量回退,但需校验依赖链
修订号 3 问题修复 修订号不同时,直接回退到上一个修订号
构建号 20241015 构建日期/流水号 用于精确匹配增量包的基线版本

这里有个坑——构建号必须唯一。我曾经遇到一个项目,两个不同分支的构建号撞了,结果升级引擎以为版本相同,跳过了增量包,导致设备停留在旧版本。从那以后,我强制要求构建号包含分支标识和时间戳。

我的习惯:在版本号里嵌入一个“回退深度”字段。比如 1.2.3-4 表示当前版本距离基线版本有 4 次增量升级。这样升级引擎可以快速判断:如果回退深度超过某个阈值(比如 5),就自动切换成全量升级,避免增量链过长导致性能问题。

4.4 回退链的存储与校验

回退链不能只存在服务器上。设备本地必须维护一份回退链的副本,否则断网后设备就不知道该怎么回退了。

我建议在设备的 recovery 分区或 OTA 元数据分区里,维护一个回退链表:

// 设备本地回退链表(简化示例)
[
  {"version": "1.0.0", "checksum": "abc...", "type": "full"},
  {"version": "1.1.0", "checksum": "def...", "type": "incremental", "from": "1.0.0"},
  {"version": "1.2.0", "checksum": "ghi...", "type": "incremental", "from": "1.1.0"},
  {"version": "2.0.0", "checksum": "jkl...", "type": "full"}
]

每次升级成功后,往这个表里追加一条记录。回退时,从表尾往前遍历,找到第一个可以回退的目标版本。

校验逻辑也很关键:

  1. 版本号校验:目标版本的语义化版本号必须小于当前版本
  2. 依赖链校验:当前版本必须在目标版本的“可回退到”列表中
  3. 完整性校验:回退路径上的每个增量包或全量包,checksum 必须匹配
  4. 签名校验:所有回退包必须经过签名验证,防止篡改
我曾经踩过的坑:只校验了版本号大小,没校验依赖链。结果设备从 v1.2.0 回退到 v1.0.0,但 v1.0.0 的增量包是基于 v1.1.0 做的,v1.2.0 的文件结构和 v1.1.0 不一样,打上去直接文件错乱。从那以后,我坚持每个回退操作都要走完整的依赖链校验。

4.5 回退链的剪枝与优化

回退链不能无限增长。设备存储空间有限,你不可能保留所有历史版本的增量包。

我常用的策略是“三级保留”:

  • 当前版本:保留完整的全量包(用于紧急回退)
  • 上一个版本:保留增量包(用于快速回退)
  • 基线版本:保留全量包(用于跨版本回退)

其他中间版本,只保留元数据,不保留实际包体。如果用户需要回退到更早的版本,就触发全量回退。

嗯,这里要注意一点——剪枝操作必须在升级成功并验证通过之后进行。我见过有人为了省空间,升级还没完成就把旧包删了,结果升级失败,设备直接变砖。

总结一下:增量升级的回退,核心是管理好依赖关系。版本号要语义化,回退链要有向图,校验要严格。别想着偷懒,该存的全量包一定要存,该校验的依赖链一定要走。否则,回退就不是“退”,而是“崩”。