《游戏服务端编程实践》17.1 客户端资源热更流程

围绕客户端资源热更,讲清版本清单的字段设计、哈希与大小的双层校验、按文件与按块的差量策略、CDN 灰度发布与回滚,以及服务端如何下发版本协商和强制更新指令;最后给出可运行的 Go 清单生成与差量应用实现,并覆盖哈希不匹配拒绝、差量覆盖完整和回滚到旧版本的测试。

一、资源版本清单记录什么

资源热更的核心不是“下载文件”,而是让客户端、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 缓存不一致时客户端无法判断哪一边是旧的。
  • 只比文件修改时间或大小,不校验哈希,内容被替换但元数据没变时会把损坏文件当成正常资源。
  • 回滚只切清单,不保留旧文件,已经更新过的客户端在回滚后反而缺少资源。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist