一、回滚窗口:保留几个版本
回滚不是“把代码换回去”这么简单。一次发布可能同时改变脚本、配置、资源清单和数据结构,回滚必须让这些部分一起回到一个已知可用的组合。保留窗口就是允许回退的版本范围:窗口太短,出问题时已经没有旧版本可用;窗口太长,存储、迁移脚本和兼容代码会不断累积。
| 保留策略 | 适用对象 | 优点 | 风险 |
|---|---|---|---|
| 只保留当前版本 | 无状态、可快速重建的服务 | 存储最省,逻辑最简单 | 一旦发布即无法回滚 |
| 保留最近 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
六、把回滚纳入发布流程
回滚能力应该在发布前就被验证,而不是事故中第一次使用。发布流水线至少要产出:带签名的包、版本清单、依赖的数据结构版本、回滚步骤和补偿脚本。灰度阶段要实际演练一次回滚,确认旧版本能读懂新版本已经写入的数据;只验证“新版本能启动”,不足以证明回滚可用。
发布、灰度和回滚应该共用同一套版本元数据。发布系统记录“当前活动版本”,回滚只是把活动版本改成窗口内的另一个值;审计日志记录谁在什么时间因为什么原因发起了回滚。这样,回滚不再是一次手工文件替换,而是一次可追踪、可复核、可重复的版本切换。
小结
回滚的可靠性由三部分共同决定:窗口内保留足够多且带完整元数据的版本,数据结构坚持向前兼容,所有包包括回滚包都经过签名与来源校验。只做其中一项都不够——有窗口但数据不兼容,回滚后旧版本读不了新数据;有签名但不限制版本顺序,合法旧包仍可被用来降级;有兼容但没保留旧包,出事时无路可退。
常见错误与边界
- 只向前兼容,不验证旧版本读取新数据,回滚后出现字段缺失、枚举越界或任务进度错乱。
- 回滚包不走签名校验,认为“内部备份一定可信”,把最危险的时刻变成防护最松的时刻。
- 保留窗口只按数量配置,不结合发布频率和故障发现时间,窗口实际覆盖不到一次完整的观察期。
- 签名只覆盖文件内容,不覆盖版本号,旧版本的合法签名可以被复用来实施降级。
- 回滚只切代码,不处理新版本已经产生的奖励、扣费和进度,补偿逻辑缺失导致玩家资产不一致。
- 发布系统允许任意历史版本回滚,却没有安全基线,带漏洞的旧版本被重新启用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。