《游戏服务端架构·平台化与架构治理》2.2 热更新与灰度

同一次热更,控制台显示成功,玩家却还在打旧伤害。问题通常不在推送通道,而在生效水位、分桶身份和回滚语义。本文区分可热更与必须重启的配置,比较按服、按渠道、按玩家和按百分比四种灰度维度,说明灰度与活动开服的先后关系,并附 internal/hotupdategray 的 Go 实现与表驱动测试。

一次热更,两种“已经生效”

《裂隙战场》调整 Boss 掉落率。运维在控制台点下发布,页面显示“全部节点成功”。不久后客服收到反馈:同一场战斗,有的玩家掉落变多了,有的玩家还是旧数值。排查发现,其中一台节点在推送时正在进行长连接迁移,配置写入了本地缓存,但没有重新加载内存里的活动表。控制台收到的是“文件已落盘”,不是“新版本已生效”。

另一款产品试图热更战斗协议,给结算消息加了一个字段。旧节点解析新消息时直接报错,战斗结算回滚,玩家被踢出副本。团队原以为“配置能热更,代码也能”,但协议字段不是配置,它改变了消息的二进制布局。

还有一次,活动配置按玩家百分比灰度。实现用角色 ID 做哈希,玩家换角色后从灰度组掉回对照组,同一天内先看到新活动、后又看不到。运营以为灰度在抖动,实际是分桶身份选错了。

这三个问题的共同点:团队把“推送成功”当成“已经生效”,把“配置”当成所有变更的统一载体,把“分桶”当成一个无关紧要的哈希。本文讨论热更与灰度的边界、维度和生效判据,不讨论通用发布流水线;有状态服务的版本发布已经在卷三写过,这里不重复。

热更边界:能改、该改、不能改

热更的第一条规则不是“怎么做”,而是“什么东西允许进入热更通道”。按数据结构可以把变更分成四类。

类别例子能否热更失败后果正确做法
数值与开关掉落率、活动开关、倍率可以可回退,影响面可控版本化推送 + 灰度
活动配置时间窗、奖励表、互斥组可以部分玩家看到不同活动快照 + 水位确认
协议与序列化字段增删、枚举值、压缩格式不可以新旧节点互相读不懂兼容窗口或重启
结算与状态算法伤害公式、背包合并规则通常不可以同一玩家前后规则不一致正常发布 + 数据迁移

“能热更”不等于“应该热更”。只影响未来读取的倍率,最坏结果是部分玩家晚几分钟看到新值;会写入背包的合并规则,可能让同一批玩家按旧规则合并、按新规则展示,事后无法判断哪份数据是对的。凡是会改变已落库状态解释方式的逻辑,都不应该走热更。协议变更还有一层成本:即使服务端全部加载新代码,客户端仍有旧版本在线,热更解决不了客户端版本分布。

灰度维度:四种切法,四种风险

灰度不是“随机放一小部分”,而是选择一个与风险匹配的分桶维度。示例值中的百分比只用于说明放量节奏,实际阈值按产品配置。

维度分桶依据优点风险适用
按服服务器 ID影响边界清楚,回滚快一个服内所有玩家同时命中玩法、经济、开服活动
按渠道渠道 ID渠道之间天然隔离渠道包版本差异会混入结果SDK、支付、渠道活动
按玩家稳定账号 ID + salt小流量精确,可跨服观察分桶实现错误会导致玩家来回跳组数值、体验、UI 实验
按百分比稳定 ID 哈希后取模可逐步放量必须有稳定身份,否则比例失真大范围配置放量

按服灰度的影响边界清楚,但样本小,一个服出问题时其他服可能已经全量;按玩家灰度要求分桶身份稳定,账号 ID 比角色 ID 稳,salt 必须与实验一一对应,否则同一批玩家会反复落在同一个桶里;按百分比不是独立维度,而是前三种维度之上的放量比例,桶的计算必须在所有节点一致,不能依赖启动时间、随机种子或本地时区。

灰度与活动开服的先后

活动配置和普通数值的区别在于,它有一个明确的“到点开服”时刻。灰度与开服是两个闸门,顺序错了会出现“活动已开、配置没到”的空窗。

顺序做法优点风险
配置先行开服前把配置灰度到全部节点,到点由服务端判断时间窗到点即开,玩家体验一致配置提前泄露,需要客户端不展示
活动先行到点后才开始推送配置不提前暴露内容水位未达标前部分玩家看不到入口
双闸门配置水位达标且到达开始时间,才对外可见一致性和保密性都较好需要两个状态都可见,运维复杂度高

大活动采用双闸门:配置在冻结窗口前完成灰度,服务端检查“本地已应用版本 >= 活动要求版本”且“当前时间进入时间窗”,两个条件同时满足才展示入口。紧急修正可以走活动先行,但必须接受一段时间内玩家看到的活动状态不一致,并在公告或客服口径里说明。

一致性:生效水位而不是控制台绿灯

热更系统最容易撒谎的地方是“成功”的定义。控制台通常统计推送响应,节点返回 200 就记为成功;但节点可能落盘后解析失败,也可能根本没有重新加载。可靠的做法是让每个节点上报已应用版本,平台取所有存活节点的最小值作为生效水位,控制台绿灯由水位驱动,而不是由推送响应驱动。

概念含义不能替代什么
推送成功节点收到并落盘不能证明已加载
节点已应用节点上报的版本号不能证明其他节点也到了
生效水位所有存活节点已应用版本的最小值不能证明玩家已经读取到新值
玩家可见配置被下一次请求实际读取受缓存、会话和客户端版本影响

水位之外还有两个时间:配置缓存 TTL 和会话生命周期。一个玩家在热更前建立了长会话,热更后仍可能读旧值,直到会话重建或配置被主动刷新。对活动开关这类必须立刻生效的字段,服务端应在关键路径上按版本号判断,而不是只依赖进程内缓存。

回退:撤销版本,而不是删除版本

回退有三种做法:删除新版本会丢失审计记录,节点也不知道该退回哪一版;回写旧文件会绕过版本号,让上报版本与实际内容不一致;推荐做法是标记版本撤销,版本仍然存在但不再被选中,节点重新解析时落到上一个未撤销且匹配的版本。撤销只影响未来读取,已经写入玩家状态的奖励不会自动追回,那属于下一节的发奖回滚。

灰度实现:gray.go

下面的实现把三件事分开:选择器负责判断一个目标是否在灰度范围,注册表负责在多个版本中选出最新的未撤销版本,集群负责计算生效水位。三者互不依赖,测试可以分别构造。

// file: internal/hotupdategray/gray.go
// tier: L1
// verify: go test ./internal/hotupdategray/
package hotupdategray

import (
	"errors"
	"fmt"
	"hash/fnv"
	"sort"
	"sync"
)

// FullPercent is 100 percent expressed in basis points. Basis points keep
// integer arithmetic exact and avoid a float comparison at the bucket edge.
const FullPercent uint32 = 10_000

// Target is the set of facts a rollout selector may use. PlayerID is the only
// stable identity for percentage bucketing; server and channel are coarse
// dimensions that can move when a player migrates.
type Target struct {
	PlayerID string
	ServerID string
	Channel  string
}

// Selector is one rollout scope. Empty server or channel lists mean "all".
// PercentBP is applied only after the coarse dimensions match.
type Selector struct {
	Servers   []string
	Channels  []string
	PercentBP uint32
	Salt      string
}

func (s Selector) Validate() error {
	if s.PercentBP > FullPercent {
		return fmt.Errorf("hotupdategray: percent %d exceeds %d basis points", s.PercentBP, FullPercent)
	}
	for _, group := range [][]string{s.Servers, s.Channels} {
		seen := make(map[string]bool, len(group))
		for _, v := range group {
			if v == "" {
				return errors.New("hotupdategray: selector contains an empty value")
			}
			if seen[v] {
				return fmt.Errorf("hotupdategray: selector repeats %q", v)
			}
			seen[v] = true
		}
	}
	return nil
}

// Match answers whether one target is inside the scope. A percentage below
// full requires a player identity; falling back to server or channel would
// make every player on a node flip together, which is the opposite of a
// per-player canary.
func (s Selector) Match(t Target) bool {
	if !contains(s.Servers, t.ServerID) || !contains(s.Channels, t.Channel) {
		return false
	}
	if s.PercentBP >= FullPercent {
		return true
	}
	if t.PlayerID == "" {
		return false
	}
	return Bucket(t.PlayerID, s.Salt) < s.PercentBP
}

func contains(list []string, value string) bool {
	if len(list) == 0 {
		return true
	}
	for _, v := range list {
		if v == value {
			return true
		}
	}
	return false
}

// Bucket maps a player and a salt to a stable point in [0, FullPercent).
// Changing the salt reshuffles membership, so a new experiment must use a new
// salt rather than reusing the one from the previous rollout.
func Bucket(key, salt string) uint32 {
	h := fnv.New32a()
	h.Write([]byte(salt))
	h.Write([]byte{0})
	h.Write([]byte(key))
	return h.Sum32() % FullPercent
}

// Version is one immutable configuration revision. Revoked is a separate
// flag rather than a new version so that the audit trail keeps the fact that
// the revision existed and was withdrawn.
type Version struct {
	ID       int64
	Name     string
	Selector Selector
	Revoked  bool
}

// Registry holds rollout revisions in ascending ID order. Resolve walks from
// the newest downwards, so a rollback is just "mark the newest revoked" and
// the previous matching revision becomes visible again.
type Registry struct {
	mu       sync.RWMutex
	versions []Version
	seen     map[int64]bool
}

func NewRegistry() *Registry {
	return &Registry{seen: make(map[int64]bool)}
}

func (r *Registry) Add(v Version) error {
	if v.ID <= 0 {
		return errors.New("hotupdategray: version id must be positive")
	}
	if err := v.Selector.Validate(); err != nil {
		return err
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.seen[v.ID] {
		return fmt.Errorf("hotupdategray: version %d already exists", v.ID)
	}
	r.seen[v.ID] = true
	r.versions = append(r.versions, v)
	sort.Slice(r.versions, func(i, j int) bool { return r.versions[i].ID < r.versions[j].ID })
	return nil
}

func (r *Registry) Revoke(id int64) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	for i := range r.versions {
		if r.versions[i].ID == id {
			r.versions[i].Revoked = true
			return nil
		}
	}
	return fmt.Errorf("hotupdategray: version %d not found", id)
}

func (r *Registry) Resolve(t Target) (Version, bool) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	for i := len(r.versions) - 1; i >= 0; i-- {
		v := r.versions[i]
		if v.Revoked {
			continue
		}
		if v.Selector.Match(t) {
			return v, true
		}
	}
	return Version{}, false
}

func (r *Registry) Versions() []Version {
	r.mu.RLock()
	defer r.mu.RUnlock()
	out := make([]Version, len(r.versions))
	copy(out, r.versions)
	return out
}

// Fleet tracks the highest version each node reports as applied. The minimum
// is the watermark: a version is not globally visible until the slowest live
// node has reached it. This is deliberately weaker than "the registry says
// so", because a node that missed a push is invisible from the registry.
type Fleet struct {
	mu      sync.RWMutex
	applied map[string]int64
}

func NewFleet() *Fleet {
	return &Fleet{applied: make(map[string]int64)}
}

// Join is idempotent. A node that restarts keeps its last reported version
// until it reports again, which is why the operator must watch staleness too.
func (f *Fleet) Join(node string) error {
	if node == "" {
		return errors.New("hotupdategray: node name is empty")
	}
	f.mu.Lock()
	defer f.mu.Unlock()
	if _, ok := f.applied[node]; !ok {
		f.applied[node] = 0
	}
	return nil
}

// Report accepts a lower version as well as a higher one. Rollback is a
// legitimate reason for the applied version to move backwards.
func (f *Fleet) Report(node string, version int64) error {
	if version < 0 {
		return errors.New("hotupdategray: applied version must not be negative")
	}
	f.mu.Lock()
	defer f.mu.Unlock()
	if _, ok := f.applied[node]; !ok {
		return fmt.Errorf("hotupdategray: unknown node %q", node)
	}
	f.applied[node] = version
	return nil
}

// Watermark returns the lowest applied version and whether any node exists.
// An empty fleet has no watermark; treating it as zero would claim a rollout
// succeeded before a single node reported.
func (f *Fleet) Watermark() (int64, bool) {
	f.mu.RLock()
	defer f.mu.RUnlock()
	first := true
	var min int64
	for _, v := range f.applied {
		if first || v < min {
			min = v
			first = false
		}
	}
	return min, !first
}

func (f *Fleet) IsEffective(version int64) bool {
	min, ok := f.Watermark()
	return ok && min >= version
}

// Pending lists nodes below a version in stable order. The list is the
// operator's action queue; an aggregate count is not enough to chase them.
func (f *Fleet) Pending(version int64) []string {
	f.mu.RLock()
	defer f.mu.RUnlock()
	var out []string
	for node, applied := range f.applied {
		if applied < version {
			out = append(out, node)
		}
	}
	sort.Strings(out)
	return out
}

func (f *Fleet) Snapshot() map[string]int64 {
	f.mu.RLock()
	defer f.mu.RUnlock()
	out := make(map[string]int64, len(f.applied))
	for k, v := range f.applied {
		out[k] = v
	}
	return out
}

三个可测试的决策点

第一,百分比灰度必须有稳定身份。Match 在比例小于 100% 且没有玩家 ID 时直接返回 false,不回退到服务器 ID。回退会让一个节点上的所有玩家同时命中,失去按玩家灰度的意义。

第二,撤销不是删除。Revoke 只把版本标记为不可选,Resolve 从最新版本向下找第一个匹配且未撤销的版本。回退后旧版本重新可见,审计里仍能查到新版本曾经存在。

第三,空集群没有水位。Watermark 在没有节点时返回 false,而不是 0;否则一个尚未上报的集群会被误判为“版本 0 已全局生效”。Pending 返回节点列表而不是数量,运维需要知道具体是哪几台。

失败模式与兜底

失败直接后果兜底
节点收到文件但解析失败该节点仍跑旧版本上报应用失败,水位不前进,告警
分桶身份不稳定玩家在灰度组和对照组之间跳固定账号 ID + 固定 salt,禁止用角色 ID
新旧协议混跑解析错误、结算失败兼容窗口或强制客户端升级,不热更协议
撤销后节点未重新加载水位停在旧版本主动触发重载,等待水位下降后再确认
缓存 TTL 过长玩家长时间读旧值关键开关按版本判断,缩短活动配置 TTL
控制台只看推送响应误判全量成功以水位和待办节点列表作为发布完成条件

兜底的目标不是让所有节点“看起来一致”,而是让不一致可见。一个明确停留在旧版本的节点,比一个上报成功但实际未加载的节点安全得多。

上线检查表

  • 变更已归类:数值、活动配置、协议还是状态算法;协议与状态算法没有进入热更通道。
  • 灰度维度与风险匹配,按服或按玩家的影响面已评估;分桶身份是稳定账号 ID,salt 与实验一一对应。
  • 活动开服采用双闸门:配置水位达标且到达时间窗。
  • 发布完成条件由水位驱动,待办节点列表为空。
  • 回退路径是撤销版本,不删除、不回写文件;已写入玩家状态的变更另有补偿或回滚方案。

常见错误

第一,把推送响应当成生效确认。节点落盘不等于加载,加载不等于下一次请求会读到。

第二,用角色 ID 做灰度分桶。玩家换角色就换组,实验数据会被污染。

第三,把协议字段塞进配置热更,或回退时删除版本。前者旧节点无法解析,后者审计链断裂、节点不知道该用哪一版。

第四,活动开始后才推配置。水位没到之前,部分玩家看不到入口,客服无法解释。

第五,忽略缓存和长会话。水位到了,玩家仍可能读旧值,关键开关需要按版本判断。

小结

热更与灰度的核心不是推送通道,而是三个判断:这次变更是否允许热更,灰度切在哪个维度,什么时刻才算对所有节点生效。把协议和状态算法挡在热更通道之外,把分桶身份固定下来,把生效判据从“推送成功”改成“水位达标”,大部分“热更了但没生效”的问题会在发布阶段暴露,而不是在玩家客诉里暴露。下一节处理热更之后仍然出事的情况:配置回滚、发奖回滚和补偿是三件不同的事,谁改了哪个字段、依据哪张工单,也需要一条不可篡改的审计链。

阅读导航:上一节:2.1 活动编排与配置 · 下一节:2.3 活动状态、回滚与审计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查