错误率正常,退款率先抬头
《裂隙战场》的新结算逻辑按 30% 玩家灰度上线。前两个小时错误率、P99 延迟、匹配时长都在基线内,值班同学准备按计划放量到 50%。第三个小时,客服群里开始出现“结算奖励少了”的反馈,退款率从 0.2% 升到 0.9%。
问题出在回滚判据上:自动回滚只接了错误率和延迟两个指标,经济指标被归为“人工判断”。这不是疏忽,而是当时有意为之——经济指标波动大,怕误报。但结果是,系统在技术指标完全正常的情况下,对着一批玩家持续少发奖励,直到人工发现。
这一节讨论三个问题:开关分几级、灰度按什么维度放、什么条件下自动回滚、什么条件必须交给人。核心判断是:自动回滚只应该处理“确定是故障”的情况,凡是需要解释的指标,都必须留给人。
开关分四级
开关不是越多越好,但只有一级一定不够。北辰把开关按影响范围分四级:
| 级别 | 作用范围 | 典型用途 | 打开所需角色 | 关闭权限 |
|---|---|---|---|---|
| 总开关 | 全服 | 新结算、新匹配、新经济 | 工程负责人 | 任何值班角色 |
| 区服开关 | 单个区服或赛区 | 按区服放量、隔离问题区 | SRE | 任何值班角色 |
| 玩法开关 | 一种模式或房间类型 | 排位、休闲、活动玩法 | 运营负责人 | 任何值班角色 |
| 玩家白名单 | 指定玩家 | 内测、主播、问题复现 | 客服主管 | 任何值班角色 |
这张表里最重要的不是“谁能打开”,而是“谁能关闭”:打开需要对应负责人,关闭不需要。故障发生时,任何值班同学都应该能立刻关掉一个功能,哪怕他无权打开它。把关闭也纳入审批,等于在事故里加了一道人工延迟。
代码里用角色等级表达这一点:Flip 在 on=true 时要求操作者等级不低于开关 owner,on=false 时只要求是合法角色且填写原因。原因不是形式主义——回滚复盘时,“谁在几点因为什么关掉了什么”是唯一能还原现场的记录。
开关的默认值要按功能性质分:
- 新功能:默认关闭。开关服务不可用时,玩家看不到新功能,但旧功能正常。
- 保护性开关(限流、降级、关闭聊天):默认开启。开关服务不可用时,保护仍然生效。
- 实验性开关:默认关闭,且带过期时间。过期未清理的开关本身就是技术债。
“默认开启还是默认关闭”不能由开关框架统一决定,必须由每个开关声明。统一默认关闭会让保护性开关在故障时失效,统一默认开启会让新功能在故障时意外暴露。
灰度维度:区服、比例、房间类型、渠道
| 维度 | 适合验证 | 风险 |
|---|---|---|
| 区服 / 赛区 | 完整链路、跨服交互、地区差异 | 区服内玩家全部暴露,影响面偏大 |
| 玩家比例 | 统计显著性、指标对比 | 同一区服内体验不一致,社交场景割裂 |
| 房间类型 | 特定玩法、特定结算路径 | 房间类型与玩家分层相关,样本可能有偏 |
| 渠道 | 包体差异、渠道 SDK 兼容 | 渠道包更新节奏不同,回滚更慢 |
北辰的默认顺序是“先区服、再比例”:先用一个小赛区验证完整链路,确认没有系统性问题,再在同一赛区里按玩家比例细分。只按比例不按区服,会让同一个战队、同一个好友列表里的人看到不同版本,社交体验先出问题;只按区服不按比例,一旦出问题就是整个赛区。
比例灰度要保证同一个玩家稳定落在同一侧:
bucket = fnv32a(rampKey + ":" + playerID) % 100
include = bucket < stagePercent
用玩家 ID 做哈希而不是随机数,原因有两个:同一玩家在 5% 和 20% 阶段的结果必须一致,否则他会在“有功能”和“没功能”之间反复横跳;比例扩大时,已经在 5% 里的玩家必须仍然在 20% 里,否则每次放量都是一次重新洗牌,指标无法对比。fnv32a 不是唯一选择,但必须是稳定哈希,不能用带随机种子的实现。
渠道维度要单独考虑。《泡泡工坊》的海外发行按渠道包拆分,不同渠道的审核和更新节奏不同:应用商店版本可能比官网包晚三天。如果灰度只按玩家比例、不按渠道,会出现“新服务端已经上线、旧渠道包还在请求旧协议”的情况。渠道灰度的顺序通常是:官网包 → 测试渠道 → 主渠道,每一档都要等渠道侧的版本覆盖率达到阈值。
房间类型维度适合验证“某一条结算路径”,不适合验证“整体稳定性”。排位房间和休闲房间的玩家分层不同,用排位房间灰度得到的留存数据,不能直接外推到休闲玩家。按房间类型灰度时,观察指标要按房间类型分别取基线,不能拿全局基线对比。
基线怎么定
回滚判据里的阈值都是相对基线说的,基线错了,阈值就没有意义:
- 同环比结合:错误率看“与上一版本同时段对比”,延迟看“与本周同一时段对比”。只看绝对值,会把晚高峰的正常上升当成故障。
- 分维度取基线:区服、房间类型、渠道各自有基线。用全局 P99 去判断一个东南亚小赛区,永远看不出问题。
- 排除已知事件:活动开场、版本更新后的登录洪峰、支付通道维护,都要在基线里标注。否则每次活动都会触发一轮误报。
- 基线要随版本滚动:上一个版本稳定运行一周后,它的指标才成为新基线。拿半年前的基线对比,架构早就变了。
基线不是一条线,而是一组“同时段、同维度、同版本”的参照。灰度系统如果只存一个全局阈值,值班同学最终会学会忽略告警,这比没有告警更危险。
放量节奏与观察窗口
| 阶段 | 比例(示例值) | 最短观察 | 观察重点 |
|---|---|---|---|
| 金丝雀 | 1% | 30 分钟 | 启动、登录、结算是否报错 |
| 小批 | 5% | 1 小时 | 错误率、匹配时长与基线对比 |
| 中批 | 20% | 2 小时 | 经济指标、客诉、跨服交互 |
| 大批 | 50% | 2 小时 | 容量、排队、长尾延迟 |
| 全量 | 100% | 24 小时 | 留存、ARPU、次日复盘 |
观察窗口要覆盖至少一个业务高峰。凌晨放量、早上就宣布“观察通过”,样本里没有高峰期的匹配压力和结算峰值,指标好看但没有代表性。窗口的长度应该由业务周期决定,而不是由值班同学的排班决定。
每两个阶段之间要有一次明确的“继续 / 暂停 / 回滚”判断,判断依据是下面这组指标。不能因为“没出大问题”就自动进入下一阶段;没有明确结论的放量,等于把决策推给了下一个人。
回滚判据:四类指标,两种处理
| 指标 | 阈值(示例值) | 连续窗口 | 处理方式 |
|---|---|---|---|
| 服务端错误率 | > 2% | 2 个观察轮次 | 自动回滚 |
| 接口 P99 延迟 | > 180 ms | 3 个观察轮次 | 自动回滚 |
| 匹配失败率 | > 1% | 2 个观察轮次 | 自动回滚 |
| 客诉量 | > 基线 3 倍 | 1 小时 | 人工评估 |
| 退款率 / 产出异常 | > 基线 2 倍 | 1 小时 | 人工评估 + 经济核对 |
| 指标缺失 | 任一关键指标无数据 | 立即 | 冻结放量,不自动回滚 |
前三条是“确定是故障”:错误率、延迟、匹配失败率都有明确的方向和基线,超过阈值就是坏了,自动回滚不会误伤。后两条是“需要解释”:退款率上升可能是新版本少发奖励,也可能是同期竞品活动或支付通道问题;客诉量上升可能是版本问题,也可能是一次舆论事件。把这两类交给自动系统,误回滚的代价和漏回滚的代价一样高。
“指标缺失”单独一行,处理方式是冻结而不是回滚。监控断流时,系统既不知道好也不知道坏,此时继续放量是赌博,自动回滚则可能把一个正常的版本撤掉。冻结放量、叫人来查,是唯一合理的选择。
自动回滚的边界
自动回滚能做什么、不能做什么,要在设计时就写清楚:
- 只能回到上一个已知良好版本。回滚目标必须是发布单里记录过的版本,不能是“任意历史版本”;数据兼容性由 11.2 的窗口保证,窗口之外的版本不能作为回滚目标。
- 要有次数上限。同一次发布连续自动回滚两次,就应该停止自动重试并升级为人工处理。版本本身有问题时,自动回滚 + 自动重发会形成循环。
- 只处理技术指标。经济、留存、口碑这类慢性指标,变化慢、归因难,自动系统没有能力判断,交给人。
- 回滚也要记录原因和影响面。自动回滚不是“没发生事故”,它同样影响了一批玩家,需要用同一套补偿和对账流程。
- 开关服务本身要有兜底。开关服务不可用时,按每个开关声明的默认值执行,而不是让所有请求失败;默认值的选择见上一节。
还有一条容易被忽略:自动回滚只能撤销“开关和版本”,不能撤销“已经写进数据库的数据”。如果新版本已经写入了旧版本无法理解的状态,回滚只会把问题从“新版本算错”变成“旧版本读不懂”。所以自动回滚的前置条件是兼容窗口仍然有效,这一点在发布前就要确认。
决策链:谁能拍板
灰度期间最容易卡住的不是技术,而是“谁来决定”。北辰把决策链固定成三层:
| 情形 | 决策人 | 时限 | 记录 |
|---|---|---|---|
| 技术指标自动越界 | 系统自动执行 | 立即 | 自动回滚单,含指标快照 |
| 技术指标人工发现异常 | 当班 SRE | 15 分钟内 | 事件单 + 开关操作记录 |
| 经济、客诉、口碑异常 | 产品 + 运营负责人 | 1 小时内 | 复盘单,含归因结论 |
第三层不能省。经济指标的异常往往需要跨团队解释:是版本少发了,还是活动发多了,还是支付通道结算延迟。自动系统只能看到数字,看不到原因;把这类判断交给自动回滚,会把“奖励少发”变成“奖励发两遍”。
决策链还要提前写好“回滚后怎么办”:已经发放的奖励是否追回、已经产生的对局是否有效、受影响的玩家如何补偿。这些问题在事故里现场讨论,通常会做出不一致的决定,第二天又要再修一次。
决策链本身也要演练。每季度至少做一次“无预告回滚演练”:随机挑一个已上线的开关,模拟指标越界,看系统是否在规定时限内给出 Rollback、值班是否按流程执行、补偿是否触发。演练的目标不是证明流程存在,而是找出“文档里写了、实际没人会做”的环节。演练记录和真实回滚记录放在同一个复盘库里,下一节全球化案例分析时会直接引用这些记录。
灰度与对账
灰度期间的数据要单独对账,不能等全量之后才算总账:
- 按桶对账:实验桶和对照桶的收入、产出、消耗分别统计,差异超过阈值就停下来看。
- 按小时对账:经济指标的异常往往先体现在小时级,日级报表看不出来。
- 按版本对账:同一玩家在灰度前后的行为变化,比跨玩家对比更能说明问题,但要看同一批玩家的前后差异,而不是新老玩家对比。
- 保留原始事件:灰度期间的事件日志要保留到窗口结束,否则回滚后无法解释差异。
对账的粒度和第 6 章的补偿任务是一套基础设施,但目的不同:补偿任务处理“已经错了怎么补”,灰度对账处理“是不是错了”。两者共用数据源,不共用结论。
代码:灰度评估与回滚决策
下面的 internal/rollbackcanary/eval.go 把指标、连续窗口、最小样本和决策优先级放在一个文件里。Observe 每轮返回一个 Decision,只有技术指标连续越界才给出 Rollback;经济类指标标记为 Manual,越界只会升级为 Escalate;关键指标缺失时返回 Hold,冻结放量。
// file: internal/rollbackcanary/eval.go
// tier: L1
// verify: go test ./internal/rollbackcanary/
package rollbackcanary
import (
"fmt"
"sort"
"time"
)
type Metric struct {
Name string
Max float64
// Manual means a breach is evidence for a human decision, never for an
// automatic rollback. Economy metrics belong here: a revenue dip can be a
// promotion, not a regression.
Manual bool
}
type Policy struct {
MinSamples int // observation rounds before any action is allowed
Consecutive int // consecutive breaching rounds before acting
Metrics []Metric
}
type Sample struct {
Name string
Value float64
}
type Action string
const (
Continue Action = "continue"
Hold Action = "hold" // not enough data, or a metric is missing
Rollback Action = "rollback" // automatic rollback is allowed
Escalate Action = "escalate" // needs a human decision
)
type Decision struct {
Action Action
Reasons []string
}
type Evaluator struct {
policy Policy
rounds int
streak map[string]int
}
func NewEvaluator(p Policy) (*Evaluator, error) {
if p.MinSamples < 1 || p.Consecutive < 1 || len(p.Metrics) == 0 {
return nil, fmt.Errorf("%w: policy thresholds or metrics missing", ErrInvalidRamp)
}
seen := map[string]bool{}
for _, m := range p.Metrics {
if m.Name == "" || seen[m.Name] {
return nil, fmt.Errorf("%w: empty or duplicate metric %q", ErrInvalidRamp, m.Name)
}
seen[m.Name] = true
}
return &Evaluator{policy: p, streak: map[string]int{}}, nil
}
func (e *Evaluator) Rounds() int { return e.rounds }
// Observe evaluates one observation round. Missing data holds the rollout
// instead of passing it: a dashboard gap is not evidence of health.
func (e *Evaluator) Observe(samples []Sample) Decision {
e.rounds++
observed := make(map[string]float64, len(samples))
for _, s := range samples {
observed[s.Name] = s.Value
}
metrics := append([]Metric(nil), e.policy.Metrics...)
sort.Slice(metrics, func(i, j int) bool { return metrics[i].Name < metrics[j].Name })
var auto, manual, missing []string
for _, m := range metrics {
v, ok := observed[m.Name]
if !ok {
e.streak[m.Name] = 0
missing = append(missing, m.Name+": no data")
continue
}
if v <= m.Max {
e.streak[m.Name] = 0
continue
}
e.streak[m.Name]++
if e.streak[m.Name] < e.policy.Consecutive {
continue
}
reason := fmt.Sprintf("%s: %.4f > %.4f for %d rounds", m.Name, v, m.Max, e.streak[m.Name])
if m.Manual {
manual = append(manual, reason)
} else {
auto = append(auto, reason)
}
}
d := Decision{Action: Continue}
switch {
case e.rounds < e.policy.MinSamples:
d.Action = Hold
d.Reasons = []string{fmt.Sprintf("min-samples: %d/%d", e.rounds, e.policy.MinSamples)}
case len(auto) > 0:
d.Action = Rollback
d.Reasons = auto
case len(manual) > 0:
d.Action = Escalate
d.Reasons = manual
case len(missing) > 0:
d.Action = Hold
d.Reasons = missing
}
sort.Strings(d.Reasons)
return d
}
// Advance decides whether the ramp moves to the next stage. A non-continue
// decision freezes the ramp; the caller decides whether to roll back or page.
func (r *Ramp) Advance(stage int, entered, at time.Time, d Decision) (int, string, error) {
if stage < 0 || stage >= len(r.stages) {
return stage, "", fmt.Errorf("%w: stage %d", ErrInvalidRamp, stage)
}
if d.Action != Continue {
return stage, "hold:" + string(d.Action), nil
}
if at.Sub(entered) < r.stages[stage].Soak {
return stage, "soak", nil
}
if stage == len(r.stages)-1 {
return stage, "complete", nil
}
return stage + 1, "advance", nil
}
几个和判据直接对应的设计:
Consecutive是连续窗口,不是累计次数。指标在阈值附近抖动时,累计次数会把正常波动算成故障;连续窗口要求每一轮都越界,尖峰会被自然过滤。MinSamples防止小样本误判。金丝雀阶段只有几百个请求,一次超时就可能把错误率推过阈值;样本不足时只冻结,不动作。- 决策优先级是
Rollback>Escalate>Hold。技术指标和经济指标同时越界时,先回滚止损,再让人看经济数据。 - 关键指标缺失时不跳过,直接
Hold。把“没数据”当成“没问题”,是灰度系统最常见的自欺。 Advance在决策不是Continue时冻结阶段,并把原因写进返回值;放量推进和回滚判断共用同一个决策对象,避免两套逻辑打架。
配套的 canary.go 负责灰度阶段校验和稳定分桶,switches.go 负责四级开关的权限与原因校验。三者合起来才是完整的灰度链路:开关决定“能不能开”,分桶决定“开给谁”,评估决定“要不要收回”。
开关也要下线
开关是有生命周期的,长期存在的开关会变成隐形分支:
- 创建时登记:每个开关记录 owner、用途、预期下线时间、默认值。
- 全量后观察:全量稳定运行一个版本周期后,把开关固定到最终状态,并删除另一条分支。
- 到期未删要升级:过期开关进入技术债清单,由 owner 的上级确认是否续期。
- 禁止复用 key:删除后的 key 不能再用于新功能,否则历史日志和配置无法解释。
常见的情况是,最难删的不是代码分支,而是“运营想留着随时关”的心理。判断标准应该反过来:如果一个开关的存在理由是“万一要关”,那它应该由发布回滚来承担,而不是永久留在代码里。真正需要长期保留的只有保护性开关和按区服的运营开关。
失败模式与兜底
- 开关服务故障:所有请求按声明默认值执行。新功能默认关,保护性开关默认开;不能因为开关服务挂了就让登录失败。
- 分桶不稳定:用随机数或带随机种子的哈希,玩家每轮被重新分桶,指标无法对比。分桶函数必须固定,且写进测试。
- 只按比例、不按区服:同一战队、同一好友列表被切开,社交体验先坏,指标却看不出来。
- 自动回滚抖动:同一版本连续回滚又重发,形成循环。加次数上限,并把第二次回滚升级为人工。
- 回滚目标数据不兼容:新版本写入的状态旧版本读不懂,回滚后问题变形。回滚目标必须落在兼容窗口内。
- 灰度期间改配置:灰度版本和配置同时变,出问题时无法归因。配置变更走独立开关,不和版本灰度叠加。
- 白名单泄漏:内测白名单被当成永久特权,版本全量后仍然生效,形成一批“特殊玩家”。白名单要绑定版本或活动,到期自动失效。
- 回滚后开关状态不一致:版本回滚了,开关还停留在灰度阶段的值,旧版本读到新开关配置的行为与预期不同。回滚清单要包含开关状态。
- 观察窗口内改阈值:看到指标要越界,临时把阈值调高,回滚判据被绕过。阈值变更要单独审批并记录,不能由值班当场改。
- 灰度流量与真实流量混在一起:实验桶和对照桶共用缓存、共用匹配池,互相污染。灰度要保证桶之间的隔离,至少要做到可归因。
- 只看均值不看分布:平均延迟正常,P99 已经翻倍;平均值会掩盖长尾玩家的体验。
常见错误与边界
- 只有总开关,出问题时只能全服回滚,影响面被放大。
- 打开和关闭都要审批,故障时没人敢动。
- 观察窗口不覆盖业务高峰,指标好看但样本没有代表性。
- 把经济指标接进自动回滚,一次促销活动就触发误回滚。
- 指标断流时继续放量,把“看不见”当成“没问题”。
- 灰度桶用随机数,每次请求都可能落到不同分支。
- 回滚只回代码,不回开关和配置,旧版本带着新配置运行。
- 自动回滚没有次数上限,版本问题和重试形成循环。
- 白名单没有过期时间,全量后仍有一批玩家走特殊路径。
- 灰度方案只写了怎么放量,没写怎么停、停了之后怎么补。
- 把开关数量当成灵活性指标,开关越多,组合状态越难验证。
- 对照桶被实验版本间接影响:共享的世界服状态让对照玩家也发生变化,差异无法归因。
- 回滚演练只在文档里做过,真实事故时没人知道开关在哪、谁有权关。
小结
灰度不是“按比例放量”这么简单,它是开关权限、分桶稳定性、观察窗口和回滚判据四件事的组合。开关要分四级,打开需要负责人、关闭不需要;分桶要稳定且单调,保证同一玩家体验一致;观察窗口要覆盖业务高峰;回滚判据要把“确定是故障”和“需要解释”分开,技术指标可以自动,经济指标必须留给人。自动回滚的边界不在技术能力,而在归因能力:系统能确定的,自动做;系统需要解释的,交给人。
开关的数量本身也值得控制。每多一个开关,就多一种组合状态,验证成本按组合数增长;真正需要独立控制的维度应该收敛到区服和玩法两级,其余的用配置版本整体切换。
到这里,发布、兼容窗口、回滚与灰度三件事连成了一条完整的变更链路:排空保证变更不伤正在进行的对局,兼容窗口保证新旧版本能共存,灰度与回滚保证出问题时能收得住。下一章把视角拉到全球化,看《裂隙战场》如何用赛区和延迟预算组织这张网。
阅读导航:上一节:11.2 兼容窗口与数据迁移 · 下一节:12.1 《裂隙战场》的赛区与延迟 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。