11.2 增量更新与回滚
11.1 算出了「差在哪」:added 2、changed 5、removed 1,要下 230400 字节。但差异本身不是计划——真正的更新计划还要回答三件事:怎么分批下、每批多大、出错怎么退。本节把差异变成一份可执行的更新计划,并给回滚一个可判定的语义。传输层怎么重试、怎么断点续传仍是基础设施的事,本节只做计划层。
11.2.1 更新计划 = 把要下的资产装进有限容量的批
更新计划的核心约束只有一个:单批下载字节数不超过 updatePolicy.batchBudgetBytes(样本里 65536)。为什么要有上限?一批要么整批成功、要么整批重来——批太大,一次网络抖动就得重下几百 KB;批太小,请求数暴涨。上限是工程折中,但它必须是整数,这样两侧装批结果才能逐位一致。
11.2.2 贪心装批:确定性优先于最优
装批用的是最朴素的首次适应贪心:把要下的 id 排序后依次放入当前批,放不下就开新批。它不保证批数最少(那是最优装箱,NP 难),但保证同一输入同一结果——这是跨语言对拍的前提。单个资产自身就超过上限时(样本里 atlas_ui 是 131072 > 65536),它独占一批并标记 oversize。
下面这段是 S 层脚本,复用 11.1 的差异函数,产出更新计划与回滚计划:
#!/usr/bin/env python3
# file: sim/ta11_update.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta11_update.py
"""11.2 增量更新计划与回滚的 S 层计算器(卷二第 11 章)。
复用 sim/ta11_diff.py 的 diff_maps(保证与 11.1 同一口径)。做两件事:
* **更新计划**:added + changed 的资产按 id 排序后贪心装批,每批不超过
updatePolicy.batchBudgetBytes;单资产超上限时独占一批(oversize)。
naiveBatchCount = (downloadBytes + budget - 1) // budget 是批数下界。
* **回滚计划**:target → base 的反向差异,下载 changed + removed,删除 added。
断言 apply(base, forward) == target 且 apply(target, rollback) == base——回滚即可逆。
落 out/ta11_update.json。
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ta11_diff import diff_maps, load_manifests # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
def pack_batches(ids, sizes, budget):
"""按 id 顺序贪心装批,返回 [{index, ids, bytes, oversize}]。"""
batches, cur, cur_bytes = [], [], 0
for aid in ids:
sz = sizes[aid]
if cur and cur_bytes + sz > budget:
batches.append({"index": len(batches), "ids": cur, "bytes": cur_bytes,
"oversize": len(cur) == 1 and cur_bytes > budget})
cur, cur_bytes = [], 0
cur.append(aid)
cur_bytes += sz
if cur:
batches.append({"index": len(batches), "ids": cur, "bytes": cur_bytes,
"oversize": len(cur) == 1 and cur_bytes > budget})
return batches
def apply_manifest(entries_map, rows):
"""把差异作用到一份清单上,返回 id → hash(只关心内容)。"""
out = {aid: e["hash"] for aid, e in entries_map.items()}
for r in rows:
if r["status"] == "removed":
out.pop(r["id"], None)
else:
out[r["id"]] = r["toHash"]
return out
def main() -> int:
cfg, base_map, target_map = load_manifests()
budget = cfg["updatePolicy"]["batchBudgetBytes"]
fwd = diff_maps(base_map, target_map)
back = diff_maps(target_map, base_map)
dl_ids = sorted(r["id"] for r in fwd if r["status"] in ("added", "changed"))
sizes = {aid: target_map[aid]["sizeBytes"] for aid in dl_ids}
download_bytes = sum(sizes.values())
batches = pack_batches(dl_ids, sizes, budget)
rb_ids = sorted(r["id"] for r in back if r["status"] in ("added", "changed"))
rollback_bytes = sum(base_map[aid]["sizeBytes"] for aid in rb_ids)
delete_ids = sorted(r["id"] for r in back if r["status"] == "removed")
base_hashes = {a: e["hash"] for a, e in base_map.items()}
target_hashes = {a: e["hash"] for a, e in target_map.items()}
forward_ok = apply_manifest(base_map, fwd) == target_hashes
rollback_ok = apply_manifest(target_map, back) == base_hashes
report = {
"project": cfg["project"], "batchBudgetBytes": budget, "downloadCount": len(dl_ids),
"downloadBytes": download_bytes, "naiveBatchCount": (download_bytes + budget - 1) // budget,
"batchCount": len(batches), "oversizeCount": sum(1 for b in batches if b["oversize"]),
"maxBatchBytes": max(b["bytes"] for b in batches), "batches": batches,
"rollbackCount": len(rb_ids), "rollbackBytes": rollback_bytes,
"rollbackDeleteIds": delete_ids, "reversible": forward_ok and rollback_ok,
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ta11_update.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2, sort_keys=True)
fh.write("\n")
print(f"downloadCount={len(dl_ids)} downloadBytes={download_bytes} budget={budget}")
print(f"batchCount={len(batches)} naiveBatchCount={report['naiveBatchCount']} "
f"oversizeCount={report['oversizeCount']} maxBatchBytes={report['maxBatchBytes']}")
for b in batches:
print(f" batch{b['index']} bytes={b['bytes']:>6} oversize={str(b['oversize']).lower():5s} "
f"ids={','.join(b['ids'])}")
print(f"rollbackCount={len(rb_ids)} rollbackBytes={rollback_bytes} "
f"rollbackDeleteIds={','.join(delete_ids)}")
print(f"reversible={str(report['reversible']).lower()} forwardOk={str(forward_ok).lower()} "
f"rollbackOk={str(rollback_ok).lower()}")
print("wrote out/ta11_update.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta11_update.py:
downloadCount=7 downloadBytes=230400 budget=65536
batchCount=4 naiveBatchCount=4 oversizeCount=1 maxBatchBytes=131072
batch0 bytes=131072 oversize=true ids=atlas_ui
batch1 bytes= 16384 oversize=false ids=furn_lamp_01,icon_moon,icon_star
batch2 bytes= 65536 oversize=false ids=tex_lamp_brass
batch3 bytes= 17408 oversize=false ids=ui_hud,vfx_steam_puff
rollbackCount=6 rollbackBytes=285696 rollbackDeleteIds=icon_moon,vfx_steam_puff
reversible=true forwardOk=true rollbackOk=true
wrote out/ta11_update.json
7 个资产装成 4 批。naiveBatchCount = (230400 + 65536 - 1) // 65536 = 4 是「每批都尽量装满」的下界,贪心实际也是 4——正好打平;atlas_ui 单独占 batch0,因为它 131072 字节已经超过 65536 的批上限,标 oversize。
11.2.3 P 层:同一套装批的 TS 内核
下面这段是 P 层 TS 内核 core/src/ta11/update.ts,与上面的 Python 逐条对应:
// file: core/src/ta11/update.ts
// tier: P
// verify: node --test 'core/test/ta11_*.test.ts'
// 11.2 增量更新计划与回滚的 P 层内核。与 sim/ta11_update.py 同一口径:按 id 贪心装批,
// 单资产超预算时独占一批;回滚 = 反向差异,用 applyManifest 断言可逆。只允许可擦除 TS 语法。
import { type DiffRow, type Entry } from "./diff.ts";
export type Batch = { index: number; ids: string[]; bytes: number; oversize: boolean };
export function packBatches(ids: string[], sizes: Map<string, number>, budget: number): Batch[] {
const batches: Batch[] = [];
let cur: string[] = [];
let curBytes = 0;
for (const id of ids) {
const sz = sizes.get(id) as number;
if (cur.length > 0 && curBytes + sz > budget) {
batches.push({ index: batches.length, ids: cur, bytes: curBytes,
oversize: cur.length === 1 && curBytes > budget });
cur = [];
curBytes = 0;
}
cur.push(id);
curBytes += sz;
}
if (cur.length > 0) {
batches.push({ index: batches.length, ids: cur, bytes: curBytes,
oversize: cur.length === 1 && curBytes > budget });
}
return batches;
}
/** 把差异作用到一份清单上,返回 id → hash(只关心内容)。 */
export function applyManifest(entries: Map<string, Entry>, rows: DiffRow[]): Map<string, string> {
const out = new Map<string, string>();
for (const [id, e] of entries) out.set(id, e.hash);
for (const r of rows) {
if (r.status === "removed") out.delete(r.id);
else out.set(r.id, r.toHash);
}
return out;
}
export function hashMap(entries: Map<string, Entry>): Map<string, string> {
const out = new Map<string, string>();
for (const [id, e] of entries) out.set(id, e.hash);
return out;
}
11.2.4 回滚语义 = 可逆
回滚不是「再下一份完整的 base」,而是 base → target 差异的逆。把 target 当起点、base 当终点重算一次差异,就得到回滚计划:changed 要下回旧内容,对 base 而言的 added(tex_counter_wood)要下回,对 base 而言的 removed(icon_moon、vfx_steam_puff)要删。本节用纯函数 applyManifest 把这件事验出来——回滚的定义就是正向运算的逆:
// file: core/test/ta11_update.test.ts
// tier: P
// verify: node --test 'core/test/ta11_*.test.ts'
// 11.2 跨语言对照:读 out/ta11_update.json,断言 TS 侧装批结果与 Python 逐位一致;
// 并断言可逆性 apply(base, forward) == target 且 apply(target, rollback) == base。
// 负例:无下载时批数为 0;单个超预算资产独占一批且标记 oversize。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { diffMaps, mapById, type Entry } from "../src/ta11/diff.ts";
import { applyManifest, hashMap, packBatches } from "../src/ta11/update.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta11_manifest.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta11_update.json"), "utf8"));
const base = mapById(cfg.base.entries as Entry[]);
const target = mapById(cfg.target.entries as Entry[]);
const budget = cfg.updatePolicy.batchBudgetBytes as number;
test("跨语言:TS 装批 == Python 装批(逐位一致)", () => {
const fwd = diffMaps(base, target);
const ids = fwd.filter((r) => r.status === "added" || r.status === "changed").map((r) => r.id).sort();
const sizes = new Map<string, number>(ids.map((id) => [id, (target.get(id) as Entry).sizeBytes]));
const batches = packBatches(ids, sizes, budget);
assert.deepEqual(batches, py.batches);
assert.equal(batches.length, py.batchCount);
assert.equal(batches.length, 4);
assert.equal(py.naiveBatchCount, 4);
assert.equal(py.oversizeCount, 1);
assert.equal(py.maxBatchBytes, 131072);
});
test("可逆性:正向应用得 target,回滚应用得 base", () => {
const fwd = diffMaps(base, target);
const back = diffMaps(target, base);
assert.deepEqual(applyManifest(base, fwd), hashMap(target));
assert.deepEqual(applyManifest(target, back), hashMap(base));
assert.equal(py.reversible, true);
});
test("负例:无下载 → 批数为 0;超预算资产独占一批", () => {
assert.deepEqual(packBatches([], new Map(), budget), []);
const sizes = new Map<string, number>([["big", 131072], ["small", 2048]]);
const b = packBatches(["big", "small"], sizes, budget);
assert.equal(b.length, 2);
assert.equal(b[0].oversize, true);
assert.equal(b[1].oversize, false);
});
实跑 node --test 'core/test/ta11_*.test.ts'(本节 3 条;本章三节合计 9 条):
✔ 跨语言:TS 装批 == Python 装批(逐位一致)
✔ 可逆性:正向应用得 target,回滚应用得 base
✔ 负例:无下载 → 批数为 0;超预算资产独占一批
ℹ pass 9
ℹ fail 0
回滚要下 6 个资产、285696 字节,并删除 2 个新增资产。reversible=true 意味着正向与回滚互为逆运算:先更新再回滚,清单逐条还原。
11.2.5 回滚为什么必须可判定
回滚最容易被写成「再下一份完整包」——那样回滚的字节数等于整个包,一次误更新就要用户重下几百 MB。把它定义成「反向差异」之后,回滚的代价降为 rollbackBytes,而且它和正向更新一样是可判定的:给一份 base 和一份 target,回滚计划唯一确定,apply 之后逐条相等。
注意 rollbackBytes = 285696 > downloadBytes = 230400:回滚比正向贵,因为 removed 的 tex_counter_wood(65536 字节)要下回来,而正向更新只需删除它、不下载。「删除」在正向是省字节的,在回滚是费字节的——不对称来自「新增/删除」在正反两个方向上互换。把这条不对称算清楚,才能给回滚预留带宽预算,而不是等出事了才发现回滚下不动。
阅读导航:上一节:11.1 清单与差异计算 · 下一节:11.3 版本一致性与缓存失效 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。