《游戏服务端编程实践》17.3 回滚与安全策略

讨论热更失败后的回滚窗口、版本保留数量、数据向前兼容与双写双读,以及发布包签名、来源校验和防降级攻击;通过一个带 HMAC 签名与保留窗口的 Go 实现,演示回滚到上一版、签名失败拒绝、超出窗口拒绝和存储被篡改时重新验签的行为,并整理常见错误与边界。

一、回滚窗口:保留几个版本

回滚不是“把代码换回去”这么简单。一次发布可能同时改变脚本、配置、资源清单和数据结构,回滚必须让这些部分一起回到一个已知可用的组合。保留窗口就是允许回退的版本范围:窗口太短,出问题时已经没有旧版本可用;窗口太长,存储、迁移脚本和兼容代码会不断累积。

保留策略适用对象优点风险
只保留当前版本无状态、可快速重建的服务存储最省,逻辑最简单一旦发布即无法回滚
保留最近 2 个版本大多数在线玩法与配置覆盖“上一版一定可用”的常见诉求连续两次坏发布时没有退路
保留最近 3 到 5 个版本核心活动、经济、支付链路给排查和补偿留出时间旧版本的数据兼容成本上升
按时间窗口保留高频发布的配置与发布节奏解耦窗口内版本数不可控,需要额外清理

保留数量要和发布频率、故障发现时间匹配。每天发布多次的活动配置,保留两三个版本可能只够覆盖几个小时;一周发布一次的核心系统,保留两个版本就足以应对大多数事故。真正决定窗口的是“从发布到确认无异常需要多久”,而不是团队拍脑袋定的数字。

回滚窗口内还要保留对应的元数据:版本号、签名、发布时间、发布人、变更说明、依赖的配置和数据结构版本。只留一个包、不留上下文,回滚时仍然要靠人回忆“这个版本当时配的是什么”。

二、数据兼容:回滚不是换代码

代码可以瞬间切回旧版本,数据却不能瞬间变回去。新版本写入的新字段、新枚举值、新表结构,旧版本可能完全不认识。回滚能否成功,取决于发布期间有没有坚持向前兼容。

向前兼容的核心约束是:新版本可以读旧数据,旧版本也能读新版本写出的数据。做到这一点通常需要三个手段。第一,只做加法:新增字段给默认值,不删除、不改名、不改变已有字段的含义。第二,双写双读:灰度期间新旧字段同时写,读取时优先新字段、缺失时回退旧字段。第三,延迟清理:确认新版本稳定并过了回滚窗口后,才删除旧字段和迁移代码。

变更类型可直接回滚需要额外处理
新增可选字段是旧版本忽略该字段即可
新增枚举值通常可以旧版本要有未知值兜底,不能直接 panic
字段改名否双写新旧字段,读取做兼容
删除字段否先停止写入,等窗口结束再删
修改字段类型否新增字段并存,迁移完成后再切换
修改唯一键或索引否需要在线迁移与回滚预案

回滚后的补偿也要提前设计。新版本可能已经发放了奖励、扣除了货币、推进了任务进度,旧版本读不懂这些记录时,不能简单忽略。常见做法是让新版本把变更写成可识别的操作日志,回滚后由补偿任务按日志修正;或者让新旧版本共用同一套账本接口,保证回滚后仍能解释已经发生的变更。

三、签名、来源与防降级

热更包从构建系统到玩家服务器,中间会经过对象存储、CDN、发布平台和运维终端。任何一个环节被替换,注入的脚本都会以“正常更新”的名义执行。签名解决的是来源与完整性:只有持有私钥的发布系统能生成合法签名,服务器用公钥或共享密钥验签后才加载。

签名必须覆盖版本号和包内容,不能只签文件哈希。只签内容时,攻击者可以把旧版本的合法包重新发给服务器,诱导系统“回滚”到存在漏洞的版本;把版本号一起签进去,旧包就无法伪装成新版本。验签还要在真正加载前做,不能“先加载再校验”,否则恶意代码已经进入进程。

防降级是回滚机制的另一面。正常的回滚由运维显式发起,目标是保留窗口内的已知版本;恶意的降级则是把系统推到窗口外或未授权的旧版本。服务端应维护“已发布版本”集合,拒绝低于当前安全基线、从未发布或已滑出窗口的版本,即使它的签名合法。签名证明“谁发的”,保留窗口和版本顺序证明“现在允不允许”。

检查项防的是什么失败时的动作
签名包被替换或篡改拒绝加载并告警
版本号参与签名旧包冒充新包拒绝,记录来源
发布者身份非发布系统签发的包拒绝,轮换密钥
版本顺序回放旧包降级拒绝,保留当前版本
保留窗口回滚到不可用或未验证版本拒绝,提示可用版本
加载后哈希存储或传输损坏拒绝,重新拉取

四、失败场景:回滚包没有签名校验

某次紧急回滚,运维从备份目录取出上一版脚本,跳过发布系统直接放到服务器热更目录并重启加载。这个包本身是合法的,但备份目录曾被其他人员写入过一个同名文件;服务器只检查了文件名和修改时间,加载了被替换的脚本。回滚没有恢复服务,反而把问题扩大到了另一条链路。

这类事故的根因是把“回滚”当成“绕过发布流程的例外”。回滚包同样是发布包,同样要验签、校验版本、检查保留窗口。紧急情况下可以简化审批,但不能简化校验;否则攻击面最大的时刻,恰好是防护最松的时刻。

五、可运行实现:带签名的保留窗口

下面的实现把签名、版本顺序、保留窗口和回滚放在同一个存储里。Sign 对版本号与载荷一起做 HMAC-SHA256,Verify 拒绝空密钥和空签名;Publish 只在验签通过后保留版本,并按 retain 裁掉最旧的条目;Rollback 先确认版本曾经发布过,再区分“从未发布”和“已滑出窗口”,最后对存储中的包重新验签后才切换活动版本。

// file: ch17/rollback/rollback.go
// tier: L1
// verify: go test ./ch17/rollback/

// Package rollback 管理带签名的版本保留窗口与回滚操作。
package rollback

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/binary"
	"errors"
	"fmt"
	"sync"
	"time"
)

var (
	ErrEmptyKey          = errors.New("rollback: empty signing key")
	ErrInvalidRetention  = errors.New("rollback: retention must be positive")
	ErrInvalidVersion    = errors.New("rollback: invalid version")
	ErrDuplicateVersion  = errors.New("rollback: duplicate version")
	ErrVersionOrder      = errors.New("rollback: version is older than latest")
	ErrUnknownVersion    = errors.New("rollback: unknown version")
	ErrOutsideWindow     = errors.New("rollback: version outside retention window")
	ErrSignatureMismatch = errors.New("rollback: signature mismatch")
	ErrNoActiveVersion   = errors.New("rollback: no active version")
)

// Artifact 是一个已签名的发布包。Signature 对 Version 与 Payload
// 一起签名,单独替换其中任何一个都会验签失败。
type Artifact struct {
	Version     int       `json:"version"`
	Payload     []byte    `json:"payload"`
	Signature   []byte    `json:"signature"`
	PublishedAt time.Time `json:"published_at"`
}

// Sign 计算版本号与载荷的 HMAC-SHA256。版本号按大端序拼在载荷之前,
// 防止把 v1 的签名挪用到 v2。
func Sign(key []byte, version int, payload []byte) []byte {
	if len(key) == 0 || version < 1 {
		return nil
	}
	mac := hmac.New(sha256.New, key)
	var buf [8]byte
	binary.BigEndian.PutUint64(buf[:], uint64(version))
	mac.Write(buf[:])
	mac.Write(payload)
	return mac.Sum(nil)
}

// Verify 校验签名。空密钥或空签名一律视为失败,避免“未签名即通过”。
func Verify(key []byte, version int, payload, signature []byte) bool {
	if len(key) == 0 || len(signature) == 0 {
		return false
	}
	want := Sign(key, version, payload)
	return len(want) == len(signature) && hmac.Equal(want, signature)
}

// Store 保留最近若干个版本,并记录所有曾经出现过的版本号,以便
// 区分“从未发布”和“已滑出保留窗口”。
type Store struct {
	mu       sync.Mutex
	key      []byte
	retain   int
	now      func() time.Time
	known    map[int]struct{}
	retained []*Artifact
	active   int
}

// New 创建存储。key 不能为空,retain 至少为 1。
func New(key []byte, retain int) (*Store, error) {
	return NewWithClock(key, retain, time.Now)
}

// NewWithClock 允许注入时钟,便于测试稳定地构造发布时间。
func NewWithClock(key []byte, retain int, now func() time.Time) (*Store, error) {
	if len(key) == 0 {
		return nil, ErrEmptyKey
	}
	if retain < 1 {
		return nil, ErrInvalidRetention
	}
	if now == nil {
		now = time.Now
	}
	return &Store{
		key:    append([]byte(nil), key...),
		retain: retain,
		now:    now,
		known:  make(map[int]struct{}),
	}, nil
}

// Publish 使用存储内的密钥签名并发布一个新版本。发布成功后该版本
// 立即成为活动版本。
func (s *Store) Publish(version int, payload []byte) (Artifact, error) {
	if version < 1 {
		return Artifact{}, fmt.Errorf("%w: %d", ErrInvalidVersion, version)
	}
	a := Artifact{
		Version:     version,
		Payload:     append([]byte(nil), payload...),
		Signature:   Sign(s.key, version, payload),
		PublishedAt: s.now(),
	}
	return s.publish(a)
}

// PublishArtifact 接收外部签名好的发布包,先验签再保留。回滚包也
// 必须走这里或 Rollback,不能绕过签名检查。
func (s *Store) PublishArtifact(a Artifact) (Artifact, error) {
	if a.Version < 1 {
		return Artifact{}, fmt.Errorf("%w: %d", ErrInvalidVersion, a.Version)
	}
	return s.publish(a)
}

func (s *Store) publish(a Artifact) (Artifact, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if !Verify(s.key, a.Version, a.Payload, a.Signature) {
		return Artifact{}, fmt.Errorf("%w: version=%d", ErrSignatureMismatch, a.Version)
	}
	if _, ok := s.known[a.Version]; ok {
		return Artifact{}, fmt.Errorf("%w: %d", ErrDuplicateVersion, a.Version)
	}
	if n := len(s.retained); n > 0 && a.Version <= s.retained[n-1].Version {
		return Artifact{}, fmt.Errorf("%w: latest=%d incoming=%d",
			ErrVersionOrder, s.retained[n-1].Version, a.Version)
	}
	if a.PublishedAt.IsZero() {
		a.PublishedAt = s.now()
	}
	stored := cloneArtifact(a)
	s.known[a.Version] = struct{}{}
	s.retained = append(s.retained, &stored)
	s.active = a.Version
	if len(s.retained) > s.retain {
		s.retained = s.retained[len(s.retained)-s.retain:]
	}
	return cloneArtifact(stored), nil
}

// Rollback 把活动版本切回指定版本。目标必须在保留窗口内,并且
// 在切换前重新验签;这样即使存储被篡改,也不会回滚到被改过的包。
func (s *Store) Rollback(version int) (Artifact, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.known[version]; !ok {
		return Artifact{}, fmt.Errorf("%w: %d", ErrUnknownVersion, version)
	}
	var target *Artifact
	for _, a := range s.retained {
		if a.Version == version {
			target = a
			break
		}
	}
	if target == nil {
		return Artifact{}, fmt.Errorf("%w: %d retained=%v",
			ErrOutsideWindow, version, s.retainedVersionsLocked())
	}
	if !Verify(s.key, target.Version, target.Payload, target.Signature) {
		return Artifact{}, fmt.Errorf("%w: version=%d", ErrSignatureMismatch, target.Version)
	}
	s.active = version
	return cloneArtifact(*target), nil
}

// Active 返回当前活动版本的副本。
func (s *Store) Active() (Artifact, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.active == 0 {
		return Artifact{}, false
	}
	for _, a := range s.retained {
		if a.Version == s.active {
			return cloneArtifact(*a), true
		}
	}
	return Artifact{}, false
}

// Retained 返回保留窗口内的版本号,按发布顺序排列。
func (s *Store) Retained() []int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.retainedVersionsLocked()
}

func (s *Store) retainedVersionsLocked() []int {
	out := make([]int, len(s.retained))
	for i, a := range s.retained {
		out[i] = a.Version
	}
	return out
}

// Known 表示该版本是否曾经发布过,无论是否仍在保留窗口内。
func (s *Store) Known(version int) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	_, ok := s.known[version]
	return ok
}

func cloneArtifact(a Artifact) Artifact {
	return Artifact{
		Version:     a.Version,
		Payload:     append([]byte(nil), a.Payload...),
		Signature:   append([]byte(nil), a.Signature...),
		PublishedAt: a.PublishedAt,
	}
}

实现中有两个有意的设计。第一,known 记录所有曾经出现过的版本号,即使包已经被裁掉,也能在回滚时返回“超出保留窗口”而不是含糊的“未知版本”,运维因此知道是窗口问题还是版本号写错。第二,Rollback 不信任内存中的签名,而是重新计算并比较;这样即使存储被篡改,也不会把被改过的包当成合法回滚目标。

测试覆盖:发布后自动激活、回滚到上一版、签名错误拒绝、未签名包拒绝、重复版本与降级版本拒绝、超出保留窗口拒绝,以及篡改存储后回滚被拒绝。运行方式:

cd .local/checks/gs-practice-lab
gofmt -l ch17/rollback
go vet ./ch17/rollback/
go test ./ch17/rollback/ -count=1

六、把回滚纳入发布流程

回滚能力应该在发布前就被验证,而不是事故中第一次使用。发布流水线至少要产出:带签名的包、版本清单、依赖的数据结构版本、回滚步骤和补偿脚本。灰度阶段要实际演练一次回滚,确认旧版本能读懂新版本已经写入的数据;只验证“新版本能启动”,不足以证明回滚可用。

发布、灰度和回滚应该共用同一套版本元数据。发布系统记录“当前活动版本”,回滚只是把活动版本改成窗口内的另一个值;审计日志记录谁在什么时间因为什么原因发起了回滚。这样,回滚不再是一次手工文件替换,而是一次可追踪、可复核、可重复的版本切换。

小结

回滚的可靠性由三部分共同决定:窗口内保留足够多且带完整元数据的版本,数据结构坚持向前兼容,所有包包括回滚包都经过签名与来源校验。只做其中一项都不够——有窗口但数据不兼容,回滚后旧版本读不了新数据;有签名但不限制版本顺序,合法旧包仍可被用来降级;有兼容但没保留旧包,出事时无路可退。

常见错误与边界

  • 只向前兼容,不验证旧版本读取新数据,回滚后出现字段缺失、枚举越界或任务进度错乱。
  • 回滚包不走签名校验,认为“内部备份一定可信”,把最危险的时刻变成防护最松的时刻。
  • 保留窗口只按数量配置,不结合发布频率和故障发现时间,窗口实际覆盖不到一次完整的观察期。
  • 签名只覆盖文件内容,不覆盖版本号,旧版本的合法签名可以被复用来实施降级。
  • 回滚只切代码,不处理新版本已经产生的奖励、扣费和进度,补偿逻辑缺失导致玩家资产不一致。
  • 发布系统允许任意历史版本回滚,却没有安全基线,带漏洞的旧版本被重新启用。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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