一次只改了一个数值,为什么要下四十五兆
《潮汐方舟》的总资源是 1,840MB,资源文件数 86,000,分成首包 350MB 和 6 个分包。版本节奏是每周一次增量热更,平均 45MB;每 6 周发一次商店整包,整包差量在 60 到 90MB 之间。下载成功率 98.6%,低端机占 15%,Android 65%、iOS 35%,其中代码热更只走 Android,iOS 只接受资源与配置。
某个周三的战斗数值调整只动了几份文件:一份战斗配置、一张新图标,以及删掉一张过期的活动图。如果客户端把「热更」理解成「重新下载一份完整资源清单对应的内容」,这次更新会被放大成几十兆;更糟的是,86,000 个文件的清单本身就要遍历和校验,低端机上这段时间足够触发一次 ANR。
差量更新要解决的不是「怎么压缩」,而是「怎么只传变化的部分,并且让客户端确信自己拼出来的结果和服务器一致」。这需要回答三个问题:哪些文件变了、文件内部哪些字节变了、拼装后的结果怎么校验。
差量发生在三个层级
差量不是单一算法,而是三层筛选。越往下越省流量,也越依赖内容;越往上越稳,但只能省到文件粒度。
| 层级 | 输入 | 输出 | 省下的部分 | 代价 |
|---|---|---|---|---|
| 清单差量 | 旧、新两份资源清单 | keep / add / replace / remove | 未变化文件的全部字节 | 需要可信的清单与哈希 |
| 文件差量 | 变化文件的旧、新内容 | 块复制与字面量操作流 | 文件内未变化的块 | 需要读取旧文件内容 |
| 传输压缩 | 补丁字节流 | 压缩后的补丁 | 操作流里的冗余 | CPU 与解压内存 |
清单差量是入口。它先回答「86,000 个文件里哪些需要动」,把绝大多数文件标记为 keep,客户端只需要保留本地副本。文件差量只在 replace 的文件上继续做,把「整文件替换」变成「复制旧块 + 写入新块」。传输压缩是最后一步,作用对象是补丁本身,不改变差量语义。
对照《潮汐消消》:总资源 480MB、12,800 个文件,双周一次增量热更平均 12MB。文件数少一个数量级,清单差量的收益更明显;但它的资源以纹理和音频为主,文件内部可复用的块反而更少。两个案例的共同结论是:先做清单差量,再决定是否值得做文件差量。
清单差量决定哪些文件要动
清单是路径到哈希的映射。比较两份清单时,只有四种结果,没有第五种。
| 操作 | 判定 | 客户端动作 | 常见错误 |
|---|---|---|---|
| keep | 路径存在且哈希相同 | 保留本地文件 | 用修改时间或体积代替哈希 |
| add | 新清单有、旧清单没有 | 下载完整文件 | 忘了写入清单,下次又当成新增 |
| replace | 路径相同但哈希不同 | 下载块补丁或完整文件 | 只比体积,漏掉同体积改动 |
| remove | 旧清单有、新清单没有 | 删除本地文件并记墓碑 | 不删旧文件,运行时仍能读到 |
判定必须用内容哈希,不能用修改时间、文件大小或构建序号。构建机的时间戳会漂移,同体积改动在配置和压缩纹理里都很常见;一旦判定失真,keep 的文件实际上已经变了,客户端会在运行时报资源损坏。
排序也必须确定。按路径字典序遍历,输出顺序在两次构建中保持一致,补丁才可复现、可对比。删除操作要留下墓碑记录:文件从清单里消失,不等于客户端可以立刻忘记它;否则旧版本残留的文件仍可能被按路径加载,形成「服务器认为已删除、客户端还能读到」的幽灵资源。
清单本身要带版本号和目标清单哈希。版本号用于拒绝过期补丁,目标哈希用于在应用完成后确认「拼出来的清单就是服务器签发的那一份」。
块级差量决定文件内部还能省多少
清单说「这个文件变了」,块级差量继续问「变了多少」。最直接的做法是把旧文件切成固定大小的块,建立「块哈希到偏移」的索引,再扫描新文件的每个块:命中就写一条复制操作,未命中就写字面量。
固定块实现简单,但有对齐问题。如果新内容在文件开头插入了一个字节,后面所有块都会错位,命中率会从接近 100% 掉到接近 0。生产环境通常改用内容定义分块,用滚动哈希在内容边界切块,插入和删除只影响局部;代价是切块逻辑更复杂,索引也更大。
不同资源适合不同策略:
| 资源类型 | 建议策略 | 原因 |
|---|---|---|
| 配置、脚本、文本 | 块级差量 | 改动局部、重复片段多 |
| 未压缩纹理、模型 | 块级差量 | 数据量大,局部改动可复用 |
| 已压缩图集、音频、视频 | 整文件替换 | 压缩后字节完全重排,块几乎不命中 |
| 小文件(小于一个块) | 整文件替换 | 块头开销接近文件本身 |
块大小是收益与开销的平衡。块太小,操作流里全是复制头,补丁体积反而变大;块太大,局部改动会放大成整块字面量。4KB 是一个常见起点,但必须用真实资源回放验证,不能凭直觉定。
对于已经压缩的资源,强行做块差量往往得到「补丁比整文件还大」的结果。此时正确做法是回退到整文件替换,并在计划里显式记录回退数量,让发布单能解释补丁为什么没有变小。
补丁格式要能自校验、能重放
补丁不是一个压缩包,而是一段带元数据的操作流。客户端拿到它之后,要在不信任网络的前提下完成校验、应用和回退。
| 字段 | 作用 | 校验点 |
|---|---|---|
| magic / format | 标识补丁格式与版本 | 不认识的版本直接拒绝 |
| base manifest | 旧清单的版本与哈希 | 与本地清单不一致则拒绝 |
| target manifest | 新清单的版本与哈希 | 应用完成后必须完全匹配 |
| op stream | keep / add / replace / remove 操作 | 路径必须存在于目标清单 |
| file hash | 每个新文件的哈希 | 写入后逐文件校验 |
| signature | 发布方签名 | 在应用前验证来源 |
应用顺序决定了失败时会不会留下半成品。推荐顺序是:先把补丁和新增内容写到临时目录,逐文件校验哈希;全部通过后,再原子地切换清单指针;最后清理旧文件。清单指针是唯一的提交点,切换之前任何失败都可以丢弃临时目录,客户端仍然停留在旧版本。
空间是硬约束。应用补丁时,旧文件、新文件和补丁本身会同时存在,峰值空间接近「旧资源 + 新资源 + 补丁」。方舟的稳态内存是 1,150MB,水位 1,450MB,低端机上磁盘和内存都紧张;补丁应用必须分片进行,不能把整个补丁读进内存。卷一讨论过内存水位的控制方式,见 6.2 内存水位与低端机策略 。
还有一个平台边界:方舟的代码热更只走 Android,iOS 只接受资源与配置。补丁格式因此要能表达「这类文件不允许热更」,在生成阶段就拒绝,而不是等到客户端应用时才失败。
实现
下面的文件把清单差量、块级差量和补丁体积估算落成一次计算。输入是旧、新两份清单,文件可以携带内容;输出是操作列表、各操作字节数、补丁体积、整包体积、节省比例和告警。
// file: src/hotfix/delta.ts
// tier: L1
// verify: node --test test/hotfix/delta.test.ts
export interface FileEntry {
readonly path: string;
readonly bytes: number;
readonly hash: string;
readonly content?: string;
}
export interface Manifest {
readonly version: string;
readonly files: readonly FileEntry[];
}
export type ChangeKind = "keep" | "add" | "replace" | "remove";
export interface FileChange {
readonly kind: ChangeKind;
readonly path: string;
readonly bytes: number;
readonly fromHash?: string;
readonly toHash?: string;
}
export interface ManifestDiff {
readonly changes: readonly FileChange[];
readonly keepBytes: number;
readonly addBytes: number;
readonly removeBytes: number;
readonly replaceBytes: number;
}
export interface ContentDiff {
readonly copyBlocks: number;
readonly literalBytes: number;
readonly copyBytes: number;
}
export interface DeltaOptions {
readonly blockSize?: number;
}
export interface DeltaPlan extends ManifestDiff {
readonly baseVersion: string;
readonly targetVersion: string;
readonly patchBytes: number;
readonly fullBytes: number;
readonly savingRatio: number;
readonly warnings: readonly string[];
}
export const DEFAULT_BLOCK_SIZE = 4096;
const RECORD_OVERHEAD = 48;
const COPY_OP_OVERHEAD = 12;
function indexManifest(manifest: Manifest): Map<string, FileEntry> {
if (manifest.version.trim() === "") {
throw new Error("manifest version is empty");
}
const index = new Map<string, FileEntry>();
for (const file of manifest.files) {
if (file.path.trim() === "") {
throw new Error("file path is empty");
}
if (!Number.isInteger(file.bytes) || file.bytes < 0) {
throw new Error(`invalid bytes for ${file.path}: ${file.bytes}`);
}
if (file.hash.trim() === "") {
throw new Error(`missing hash for ${file.path}`);
}
if (index.has(file.path)) {
throw new Error(`duplicate path: ${file.path}`);
}
if (file.content !== undefined && Buffer.byteLength(file.content, "utf8") !== file.bytes) {
throw new Error(`content size mismatch for ${file.path}`);
}
index.set(file.path, file);
}
return index;
}
export function diffContent(
oldContent: string,
newContent: string,
blockSize: number = DEFAULT_BLOCK_SIZE,
): ContentDiff {
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`invalid block size: ${blockSize}`);
}
const oldBuffer = Buffer.from(oldContent, "utf8");
const newBuffer = Buffer.from(newContent, "utf8");
const known = new Map<string, number>();
for (let offset = 0; offset < oldBuffer.length; offset += blockSize) {
const key = oldBuffer.subarray(offset, offset + blockSize).toString("base64");
if (!known.has(key)) known.set(key, offset);
}
let copyBlocks = 0;
let literalBytes = 0;
let copyBytes = 0;
for (let offset = 0; offset < newBuffer.length; offset += blockSize) {
const block = newBuffer.subarray(offset, offset + blockSize);
if (known.has(block.toString("base64"))) {
copyBlocks += 1;
copyBytes += block.length;
} else {
literalBytes += block.length;
}
}
return { copyBlocks, literalBytes, copyBytes };
}
export function diffManifests(base: Manifest, target: Manifest): ManifestDiff {
const from = indexManifest(base);
const to = indexManifest(target);
const paths = [...new Set([...from.keys(), ...to.keys()])].sort();
const changes: FileChange[] = [];
let keepBytes = 0;
let addBytes = 0;
let removeBytes = 0;
let replaceBytes = 0;
for (const path of paths) {
const before = from.get(path);
const after = to.get(path);
if (before !== undefined && after !== undefined) {
if (before.hash === after.hash) {
changes.push({
kind: "keep",
path,
bytes: after.bytes,
fromHash: before.hash,
toHash: after.hash,
});
keepBytes += after.bytes;
} else {
changes.push({
kind: "replace",
path,
bytes: after.bytes,
fromHash: before.hash,
toHash: after.hash,
});
replaceBytes += after.bytes;
}
} else if (after !== undefined) {
changes.push({ kind: "add", path, bytes: after.bytes, toHash: after.hash });
addBytes += after.bytes;
} else if (before !== undefined) {
changes.push({ kind: "remove", path, bytes: before.bytes, fromHash: before.hash });
removeBytes += before.bytes;
}
}
return { changes, keepBytes, addBytes, removeBytes, replaceBytes };
}
export function planDelta(
base: Manifest,
target: Manifest,
options: DeltaOptions = {},
): DeltaPlan {
const diff = diffManifests(base, target);
const from = indexManifest(base);
const to = indexManifest(target);
const blockSize = options.blockSize ?? DEFAULT_BLOCK_SIZE;
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`invalid block size: ${blockSize}`);
}
let patchBytes = 0;
let replacedWithoutContent = 0;
let fullBytes = 0;
for (const file of target.files) fullBytes += file.bytes;
for (const change of diff.changes) {
patchBytes += RECORD_OVERHEAD;
if (change.kind === "add") {
patchBytes += change.bytes;
} else if (change.kind === "replace") {
const before = from.get(change.path);
const after = to.get(change.path);
if (before?.content !== undefined && after?.content !== undefined) {
const contentDiff = diffContent(before.content, after.content, blockSize);
patchBytes += contentDiff.literalBytes + contentDiff.copyBlocks * COPY_OP_OVERHEAD;
} else {
patchBytes += change.bytes;
replacedWithoutContent += 1;
}
}
}
const warnings: string[] = [];
if (replacedWithoutContent > 0) {
warnings.push(
`${replacedWithoutContent} replaced files lack content; counted as full replacement`,
);
}
if (fullBytes > 0 && patchBytes >= fullBytes) {
warnings.push("delta patch is not smaller than the full package");
}
const savingRatio = fullBytes === 0 ? 0 : Number((1 - patchBytes / fullBytes).toFixed(4));
return {
...diff,
baseVersion: base.version,
targetVersion: target.version,
patchBytes,
fullBytes,
savingRatio,
warnings,
};
}
export function formatDeltaPlan(plan: DeltaPlan): string {
const counts: Record<ChangeKind, number> = { keep: 0, add: 0, replace: 0, remove: 0 };
const lines = [`delta ${plan.baseVersion} -> ${plan.targetVersion}`];
for (const change of plan.changes) {
counts[change.kind] += 1;
lines.push(`${change.kind}\t${change.path}\t${change.bytes}`);
}
lines.push(
`summary\tfull=${plan.fullBytes}B\tpatch=${plan.patchBytes}B\tsaving=${(plan.savingRatio * 100).toFixed(1)}%\tkeep=${counts.keep}\tadd=${counts.add}\treplace=${counts.replace}\tremove=${counts.remove}`,
);
return lines.join("\n");
}
diffContent 把旧内容按固定块切开,建立块哈希到偏移的索引,再扫描新内容;命中块只记复制操作,未命中块计入字面量。它返回复制块数、复制字节和字面量字节,供上层估算补丁体积。
diffManifests 先校验两份清单:路径不能为空、体积不能为负、哈希不能缺失、路径不能重复、声明体积必须与内容一致。随后按路径排序,把每个文件分类为 keep、add、replace 或 remove,并累计各类字节数。
planDelta 在清单差量之上估算补丁体积。每个操作都有固定记录开销;新增文件按完整字节计;替换文件如果有新旧内容,就调用 diffContent,按「字面量 + 复制操作头」计;没有内容时退回整文件体积,并记录回退数量。最后给出相对整包的节省比例和告警。
formatDeltaPlan 输出逐操作明细和一行汇总,适合直接贴进构建日志或发布单。
运行结果与边界
用一组小样本运行,输出如下。字节数是示例输入,不是方舟的真实体积:
delta 2026.10.28 -> 2026.11.05
replace config/battle.json 8192
keep ui/atlas/hud.png 40960
add ui/atlas/new-mode.png 2048
remove ui/atlas/retired.png 4096
summary full=51200B patch=4360B saving=91.5% keep=1 add=1 replace=1 remove=1
这张表能回答四个问题:哪些文件要动、补丁多大、相对整包省了多少、有没有回退。真实接入时,路径会变成 86,000 个资源路径,字节数会变成 MB 量级,结构不变。
边界一:固定块差量对插入和删除敏感。样本里新配置只是尾部替换,所以命中 6 个块;如果在文件开头插入一个字节,命中率会大幅下降。生产环境应换成内容定义分块,或者对这类文件直接整文件替换。
边界二:没有内容就只能按整文件估算。清单通常只带哈希和体积,构建系统必须额外提供变化文件的内容,块级差量才算得准;否则计划会偏保守,补丁体积被高估。
边界三:补丁不一定更小。小文件、已压缩资源、块头开销过大的场景都会让补丁超过整包,此时应该回退到整文件替换,并把「回退数量」作为构建指标,而不是假装差量总是有效。
边界四:本文只算体积,不负责签名、分发和灰度。补丁的真实下载成功率、断点续传和 CDN 命中由 12.x 讨论;补丁发给谁、按什么比例放量由 11.2 讨论。
与相邻章节的关系
10.3 讨论了热更失败与回滚兜底,回答「补丁应用失败之后怎么办」;本节回答「补丁长什么样、怎么算出来」。11.2 会把补丁放进灰度发布流程,决定哪些账号先拿到它;11.3 会从版本收敛角度讨论回滚时限。
本节不讨论资源分组和包边界,那属于 8.x;也不讨论下载器的分片与并发,那属于 12.1。差量算法只对「已经确定要更新的文件集合」负责,不改变包结构。
小结
- 差量分三层:清单差量决定哪些文件要动,块级差量决定文件内部还能省多少,传输压缩只作用于补丁本身。
- 清单判定必须用内容哈希,排序必须确定,删除必须留墓碑。
- 固定块差量简单但对齐敏感;已压缩资源和小文件通常应回退到整文件替换。
- 补丁格式要带基线清单、目标清单、逐文件哈希和签名,清单指针是唯一提交点。
- 补丁应用受磁盘与内存双重约束,低端机上必须分片处理。
阅读导航:上一节:10.3 热更失败与回滚兜底 · 下一节:11.2 灰度发布:分组、比例与观测 。
常见错误与边界
- 用修改时间判断文件是否变化。构建机时间戳会漂移,keep 的文件可能已经变了,运行时报资源损坏。
- 只比较文件体积。同体积改动在配置和压缩纹理里很常见,必须比较内容哈希。
- 清单输出顺序不稳定。两次构建的操作流不同,补丁无法复现,也无法做基线对比。
- 删除文件不留墓碑。服务器认为已删除,客户端仍能按路径读到旧文件,形成幽灵资源。
- 对所有资源都做块差量。已压缩图集和音频的块几乎不命中,补丁会比整文件还大。
- 块大小凭直觉定。块太小操作头膨胀,块太大局部改动放大,必须用真实资源回放验证。
- 忽略固定块的对齐问题。文件开头插入一个字节,后续块全部错位,命中率骤降。
- 把整个补丁读进内存。低端机内存水位本来就紧,补丁应用必须分片。
- 先切换清单再校验文件。校验失败时客户端已经指向新版本,只能整包重装。
- 认为补丁一定更小。小文件、压缩资源、块头开销都可能让补丁超过整包,必须有回退路径。
- 边界:本节不负责签名分发与灰度。补丁怎么传、发给谁、放量多少,分别属于 12.x 和 11.2。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。