一、资源版本清单记录什么
资源热更的核心不是“下载文件”,而是让客户端、CDN 和服务端对“当前应该存在哪些文件、每个文件是什么内容”达成一致。清单就是这份共识:它列出文件路径、内容哈希、字节数和依赖关系,客户端只有在校验通过后才把本地目录标记为某个版本。
| 字段 | 作用 | 缺失后的典型事故 |
|---|---|---|
| 版本号 | 标识一次完整发布,用于协商与回滚 | 无法判断客户端新旧,灰度与回滚失去依据 |
| 文件路径 | 定位资源并决定落盘位置 | 同名文件覆盖,或大小写不一致导致加载失败 |
| 内容哈希 | 判断文件是否真的变化 | 只比修改时间,会漏更或重复下载 |
| 字节数 | 快速发现截断与半包 | 下载中断的文件被当成完整文件加载 |
| 依赖列表 | 约束加载顺序与资源组合 | 脚本先于配置加载,启动时报空指针 |
清单必须由发布流水线生成,而不是由客户端或运维手工维护。只要清单和文件来自同一次构建,哈希就能把“文件内容”和“版本描述”绑定在一起;反过来,手工编辑清单会立刻破坏这个不变量。
哈希负责发现内容变化,大小负责快速发现截断。只校验哈希也能发现截断,但客户端要为每个不完整文件完整计算一次 SHA-256;先比大小可以更早失败,也更容易把“网络没传完”和“文件被替换”区分开。两个检查都通过,才进入加载阶段。
资源之间的依赖不能靠文件名约定。脚本依赖配置、配置依赖表结构、界面依赖图集,这些关系应该显式写进清单。客户端先做拓扑排序,再按层下载和初始化;发现环或缺失依赖时直接拒绝该版本,而不是运行到一半才崩溃。
二、差量更新:按文件还是按块
| 策略 | 适用场景 | 优点 | 代价 |
|---|---|---|---|
| 全量下载 | 首次安装、版本跨度大、文件数少 | 实现简单,结果最确定 | 流量大,弱网耗时不可控 |
| 按文件差量 | 大多数资源更新 | 只传变化文件,服务端实现直接 | 单个大文件小改动也要整包重传 |
| 按块差量 | 大文件、安装包、语音包 | 流量最省 | 需要块索引、重组与更复杂的校验 |
按文件差量是默认选择:清单里哈希不同的文件进入下载列表,哈希相同的文件直接复用。它的边界很清楚——一个 200 MB 的包体只改了几个字节,仍然要传 200 MB。这时才需要按块差量:服务端把文件切块并记录块哈希,客户端只请求缺失块,最后按偏移重组并做整文件校验。
差量依赖“客户端本地确实拥有旧版本”。玩家可能删过文件、换过设备、被安全软件清理过缓存,也可能从非常老的版本直接更新。服务端必须允许客户端上报本地清单摘要,发现无法匹配任何已知旧版本时退回全量下载。没有全量兜底,差量逻辑越精细,卡在中间状态的玩家越多。
差量不是“服务端说下载什么就下载什么”。客户端应用差量后,必须用目标清单重新校验全部文件;服务端也应该能根据旧清单和新清单复算出同一份差量。只要两端算法一致,差量就可以被审计、缓存和重放,而不是一次性的黑盒操作。
三、CDN 发布、灰度与回滚
资源文件先上传到对象存储,再刷新 CDN;清单最后发布,并且带短缓存或版本化 URL。顺序不能反:如果清单先可见而文件还没同步,客户端会拿到 404 或旧内容。常见做法是把文件和清单放在同一个版本前缀下,例如 /res/20261104-a/,清单 URL 带版本号,CDN 对文件做长缓存,对清单做短缓存。
灰度按渠道、地区或玩家分桶放量。第一批只让内部账号和小比例玩家拉到新清单;观察下载失败率、校验失败率、启动崩溃率和资源加载错误,再逐级扩大。回滚时先把清单切回旧版本,再决定是否清理新文件。旧文件先保留一个窗口,避免已经下载新资源的客户端在回滚后找不到文件。
四、客户端校验与断点续传
客户端下载应支持分片与断点续传:记录已完成分片,重启后从缺口继续,而不是从头再来。每个分片落盘后写临时文件,全部完成再原子重命名;校验失败的分片删除重下,连续失败达到阈值后降级为全量下载。下载过程要限速并让出主线程,避免更新把游戏卡成假死。
校验分三层:分片校验保证传输完整,文件校验保证重组正确,清单校验保证整个版本自洽。任何一层失败都不能把版本标记为可用。已经通过校验的文件可以保留,下次更新继续复用,这也是差量更新的本地基础。
五、服务端版本协商与强制更新
客户端登录时上报当前资源版本、清单摘要和平台信息,服务端返回目标版本、差量地址、是否强制更新以及最低可运行版本。协商结果要带过期时间,防止客户端拿着旧响应无限重试。服务端还要记录每个版本的发布状态:灰度中、全量、已回滚,避免把已回滚版本重新下发给新登录玩家。
强制更新只应在协议不兼容、安全漏洞或经济系统变更时使用。强制更新同样要灰度:先对小比例玩家强制,确认下载与启动成功率后再扩大。直接把全量玩家切到强制更新,会让 CDN 和登录队列在同一分钟承受峰值,也会把一个小概率的兼容问题放大成全服事故。
六、失败场景:清单已切换,文件还没传完
某次发布先刷新了清单,再上传资源,CDN 边缘节点出现短暂不一致。第一批玩家拉到新清单后,部分文件返回 404,客户端把版本标记为损坏并反复重试;重试又命中同一批边缘节点,失败率持续升高。更糟的是,客户端在失败后删除了旧版本目录,回滚时已经没有可用的本地资源。
这类事故的根因不是某个文件传慢了,而是发布顺序和回滚设计同时缺失。正确做法是:文件和清单使用同一个不可变前缀,清单最后发布;客户端下载到临时目录,校验通过才切换;旧版本至少保留一个回滚窗口;服务端在灰度阶段监控 404 率和校验失败率,异常时先切清单再排查文件。
七、可运行实现:清单生成与差量应用
下面的实现把清单、校验、差量和回滚所需的最小能力放在一个包里。Diff 只把字节变化的文件放进 Downloads,仅依赖变化的条目仍会出现在 Changed 中但不需要重新下载;Apply 会检查下载集合是否恰好覆盖差量,并在最后用目标清单做完整校验。
// file: ch17/manifest/manifest.go
// tier: L1
// verify: go test ./ch17/manifest/
// Package manifest 生成与校验资源版本清单,计算全量与差量更新集合。
package manifest
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
)
var (
ErrInvalidVersion = errors.New("manifest: invalid version")
ErrEmptyPath = errors.New("manifest: empty path")
ErrHashMismatch = errors.New("manifest: hash mismatch")
ErrSizeMismatch = errors.New("manifest: size mismatch")
ErrMissingFile = errors.New("manifest: missing file")
ErrExtraFile = errors.New("manifest: unexpected file")
ErrMissingDownload = errors.New("manifest: missing download")
ErrUnexpectedDownload = errors.New("manifest: unexpected download")
ErrDeltaMismatch = errors.New("manifest: delta does not match manifests")
ErrUnknownDep = errors.New("manifest: unknown dependency")
ErrInvalidManifest = errors.New("manifest: invalid manifest")
)
// File 是清单中的一个文件条目,Path 必须与 Files 的键一致。
type File struct {
Path string `json:"path"`
Hash string `json:"hash"`
Size int64 `json:"size"`
Deps []string `json:"deps,omitempty"`
}
// Manifest 描述一个完整可用的资源版本。
type Manifest struct {
Version int `json:"version"`
Files map[string]File `json:"files"`
}
// Build 根据文件内容生成清单,哈希只使用文件字节。
func Build(version int, files map[string][]byte) (*Manifest, error) {
if version < 1 {
return nil, fmt.Errorf("%w: %d", ErrInvalidVersion, version)
}
m := &Manifest{Version: version, Files: make(map[string]File, len(files))}
for path, data := range files {
if path == "" {
return nil, ErrEmptyPath
}
sum := sha256.Sum256(data)
m.Files[path] = File{
Path: path,
Hash: hex.EncodeToString(sum[:]),
Size: int64(len(data)),
}
}
return m, nil
}
// SetDeps 记录依赖;依赖必须也在清单里,传入切片会被复制并排序。
func (m *Manifest) SetDeps(path string, deps ...string) error {
if m == nil {
return ErrInvalidManifest
}
f, ok := m.Files[path]
if !ok {
return fmt.Errorf("%w: %s", ErrMissingFile, path)
}
if len(deps) == 0 {
return nil
}
sorted := append([]string(nil), deps...)
sort.Strings(sorted)
for _, dep := range sorted {
if _, ok := m.Files[dep]; !ok {
return fmt.Errorf("%w: %s -> %s", ErrUnknownDep, path, dep)
}
}
f.Deps = sorted
m.Files[path] = f
return nil
}
// Validate 校验文件内容;清单外的额外文件被忽略。
func (m *Manifest) Validate(files map[string][]byte) error {
if m == nil || m.Version < 1 || m.Files == nil {
return ErrInvalidManifest
}
for path, f := range m.Files {
data, ok := files[path]
if !ok {
return fmt.Errorf("%w: %s", ErrMissingFile, path)
}
if int64(len(data)) != f.Size {
return fmt.Errorf("%w: %s size=%d want=%d",
ErrSizeMismatch, path, len(data), f.Size)
}
sum := sha256.Sum256(data)
if hex.EncodeToString(sum[:]) != f.Hash {
return fmt.Errorf("%w: %s", ErrHashMismatch, path)
}
}
return nil
}
// ValidateExact 在 Validate 基础上拒绝清单外的文件。
func (m *Manifest) ValidateExact(files map[string][]byte) error {
if err := m.Validate(files); err != nil {
return err
}
for path := range files {
if _, ok := m.Files[path]; !ok {
return fmt.Errorf("%w: %s", ErrExtraFile, path)
}
}
return nil
}
// Digest 返回清单规范化 JSON 的 SHA-256。
func (m *Manifest) Digest() (string, error) {
if m == nil {
return "", ErrInvalidManifest
}
data, err := json.Marshal(m)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidManifest, err)
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}
// Load 解析并做结构校验;JSON 编码会排序 map 键,摘要因此稳定。
func Load(data []byte) (*Manifest, error) {
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidManifest, err)
}
if m.Version < 1 || m.Files == nil {
return nil, ErrInvalidManifest
}
for path, f := range m.Files {
if path == "" || f.Path != path || f.Size < 0 {
return nil, fmt.Errorf("%w: entry=%q path=%q",
ErrInvalidManifest, path, f.Path)
}
if len(f.Hash) != sha256.Size*2 {
return nil, fmt.Errorf("%w: hash=%q", ErrInvalidManifest, f.Hash)
}
if _, err := hex.DecodeString(f.Hash); err != nil {
return nil, fmt.Errorf("%w: hash=%q", ErrInvalidManifest, f.Hash)
}
for _, dep := range f.Deps {
if _, ok := m.Files[dep]; !ok {
return nil, fmt.Errorf("%w: %s -> %s", ErrUnknownDep, path, dep)
}
}
}
return &m, nil
}
// Delta 描述从 From 到 To 的变更;Downloads 只含字节变化的文件。
type Delta struct {
From int `json:"from"`
To int `json:"to"`
Added []string `json:"added,omitempty"`
Changed []string `json:"changed,omitempty"`
Removed []string `json:"removed,omitempty"`
Downloads []string `json:"downloads,omitempty"`
Bytes int64 `json:"bytes"`
}
// Diff 计算差量;old 为 nil 表示全量更新,版本必须严格递增。
func Diff(old, new *Manifest) (Delta, error) {
if new == nil || new.Version < 1 {
return Delta{}, ErrInvalidManifest
}
d := Delta{To: new.Version}
if old != nil {
if old.Version < 1 {
return Delta{}, ErrInvalidManifest
}
if new.Version <= old.Version {
return Delta{}, fmt.Errorf("%w: from=%d to=%d",
ErrDeltaMismatch, old.Version, new.Version)
}
d.From = old.Version
}
for path, nf := range new.Files {
var of File
ok := false
if old != nil {
of, ok = old.Files[path]
}
switch {
case !ok:
d.Added = append(d.Added, path)
d.Downloads = append(d.Downloads, path)
d.Bytes += nf.Size
case of.Hash != nf.Hash || of.Size != nf.Size:
d.Changed = append(d.Changed, path)
d.Downloads = append(d.Downloads, path)
d.Bytes += nf.Size
case !equalStrings(of.Deps, nf.Deps):
d.Changed = append(d.Changed, path)
}
}
if old != nil {
for path := range old.Files {
if _, ok := new.Files[path]; !ok {
d.Removed = append(d.Removed, path)
}
}
}
sort.Strings(d.Added)
sort.Strings(d.Changed)
sort.Strings(d.Removed)
sort.Strings(d.Downloads)
return d, nil
}
// Apply 应用差量,download 必须恰好覆盖 Downloads,最后用目标清单校验。
func (d Delta) Apply(base map[string][]byte, download map[string][]byte, target *Manifest) (map[string][]byte, error) {
if target == nil || target.Version != d.To || target.Version < 1 {
return nil, fmt.Errorf("%w: target=%v delta_to=%d",
ErrDeltaMismatch, target, d.To)
}
out := make(map[string][]byte, len(target.Files))
for path, data := range base {
out[path] = data
}
for _, path := range d.Removed {
delete(out, path)
}
needed := make(map[string]struct{}, len(d.Downloads))
for _, path := range d.Downloads {
data, ok := download[path]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrMissingDownload, path)
}
needed[path] = struct{}{}
out[path] = data
}
for path := range download {
if _, ok := needed[path]; !ok {
return nil, fmt.Errorf("%w: %s", ErrUnexpectedDownload, path)
}
}
if err := target.Validate(out); err != nil {
return nil, err
}
return out, nil
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
实现刻意让 Apply 对多传和少传都报错。多传通常意味着客户端拿着另一份差量,少传则是下载中断或服务端漏发;两种情况都不应该“尽量应用”。回滚在这个模型里也很直接:保留旧清单和旧文件快照,切回旧清单后重新执行 ValidateExact,不通过就不允许启动。
测试覆盖哈希不匹配、大小不匹配、缺失文件、额外文件、全量更新、差量覆盖完整、仅依赖变化、下载集合不完整以及回滚到旧版本。运行方式:
cd .local/checks/gs-practice-lab
gofmt -l ch17/manifest
go vet ./ch17/manifest/
go test ./ch17/manifest/ -count=1
大小检查几乎零成本,能在读完整文件之前排除明显损坏;哈希检查成本与文件大小成正比,放在后面可以少做无用功。两者都通过后,客户端才把文件放进正式目录。这个顺序不影响正确性,但能显著降低弱网设备在更新高峰期的 CPU 与电量消耗。
八、和 17.2 的衔接
客户端资源准备就绪后,服务端自身的逻辑也可能需要在不重启进程的情况下更新。下一篇 /game-server-in-action-17-2/ 会把同样的“版本、校验、切换、卸载”思路搬到脚本层,讨论 Lua/WASM 在途请求与旧版本释放的时序问题。
小结
资源热更的可靠性来自三个不变量:清单与文件同源、下载后必须校验、旧版本在回滚窗口内可用。差量更新只是在这三个不变量之上节省流量;如果顺序、校验或回滚缺一个,差量越精细,故障面反而越大。先把全量兜底和发布顺序做对,再引入按块差量与更激进的灰度策略。
常见错误与边界
- 清单与文件分开发布,且没有版本前缀,CDN 缓存不一致时客户端无法判断哪一边是旧的。
- 只比文件修改时间或大小,不校验哈希,内容被替换但元数据没变时会把损坏文件当成正常资源。
- 回滚只切清单,不保留旧文件,已经更新过的客户端在回滚后反而缺少资源。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。