一次热更,两种“已经生效”
《裂隙战场》调整 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 活动状态、回滚与审计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。