《MMO 服务器开发》5.3 线上风控与回滚

改配置前先算影响面,灰度期盯着错误率,一旦越线就自动熔断并回滚到上一个已知良好版本——这是线上变更的三道护栏。本节把护栏判定与一键回滚写成可编译的 Go 代码,并真连本机 etcd 跑通「发布坏配置 → 熔断 → 回滚 → 内容复原」的完整断言。

5.3 线上风控与回滚

前两节把配置送上了线、也划清了能热更的边界。但能改不等于该随便改:一个把暴击率从 0.15 误填成 1.5 的配置,能在几秒内让全服玩家变成秒杀一切的超人;一个错误的活动开关,能在开服高峰期把付费入口关掉。热更的便利,恰恰放大了误操作的影响面——因为它绕过了发版评审这道天然的人肉闸门。

所以线上配置变更必须配三道护栏:改之前算影响面,灰度期盯错误率,越线时自动熔断并回滚。这一节把这三道护栏写成一个可编译、可测的 Guardrails,并真连本机 etcd 跑通「发布一版坏配置 → 护栏熔断 → 一键回滚 → 内容复原」的完整链路。

5.3.1 三道护栏:改之前、改之后、出事时

把一次配置变更的生命周期拆开,护栏分别卡在三个位置:

  • 改之前(影响面检查):这次变更会碰到多少玩家?是不是热路径(战斗、登录)?能不能回滚?不可回滚的变更——协议结构、数据模型——直接拒掉,让它走版本发布而不是热更;影响面超预算的,要求分批灰度而不是一次全量。
  • 改之后(灰度观察):放量之后先别急着推全量,盯一个观察窗口的错误率。样本还没攒够就先「继续观察」,错误率一旦越过阈值立即判「熔断」。
  • 出事时(自动回滚):熔断的下一步是回到上一个已知良好版本。这就是上一节说的「用旧内容发新版本」——回滚本身也是一次正常发布,版本号继续前进,差量只是把被改坏的键改回去。

三道护栏对应三个判定结果:allow(继续)、hold(再观察)、trip(熔断回滚)。下面这段是 L2,go test 可以直接跑:

// file: internal/config/guard.go
// tier: L2
// verify: go test ./internal/config/ -run TestGuard -v
package config

import (
	"context"
	"fmt"
)

// Action 是护栏对一次变更的判定结果。
type Action uint8

const (
	ActionAllow Action = iota + 1 // 允许继续:放量或完成发布
	ActionHold                    // 继续观察:样本不足,不放大也不回滚
	ActionTrip                    // 熔断:中止发布并回滚
)

// String 便于日志与断言输出。
func (a Action) String() string {
	switch a {
	case ActionAllow:
		return "allow"
	case ActionHold:
		return "hold"
	case ActionTrip:
		return "trip"
	}
	return "unknown"
}

// Impact 描述一次配置变更的影响面,用于「改之前」的检查。
type Impact struct {
	Key        string
	PlayerPct  int  // 本次变更预计影响的玩家百分比
	HotPath    bool // 是否命中战斗 / 登录等热路径
	Reversible bool // 是否可回滚:数值类可,协议 / 数据结构不可
}

// Guardrails 是一组护栏阈值。
type Guardrails struct {
	MaxImpactPct int     // 单次变更影响面上限(%)
	HotPathPct   int     // 热路径变更更严的上限(%)
	ErrorRateMax float64 // 灰度观察期错误率上限
	MinSample    int     // 判定前的最小样本数
}

// DefaultGuardrails 给出一组《晨曦大陆》线上用的默认阈值。
func DefaultGuardrails() Guardrails {
	return Guardrails{MaxImpactPct: 20, HotPathPct: 5, ErrorRateMax: 0.01, MinSample: 1000}
}

// CheckImpact 是「改之前」的影响面检查:不可回滚的变更直接拒,
// 影响面超限的变更要求分批,热路径更严。
func (g Guardrails) CheckImpact(im Impact) (Action, string) {
	if !im.Reversible {
		return ActionTrip, fmt.Sprintf("变更 %s 不可回滚(协议/数据结构),须走版本发布而非热更", im.Key)
	}
	limit := g.MaxImpactPct
	label := "影响面"
	if im.HotPath {
		limit = g.HotPathPct
		label = "热路径影响面"
	}
	if im.PlayerPct > limit {
		return ActionTrip, fmt.Sprintf("%s %d%% 超过上限 %d%%,须分批灰度", label, im.PlayerPct, limit)
	}
	return ActionAllow, fmt.Sprintf("%s %d%% 在预算内", label, im.PlayerPct)
}

// Sample 是灰度观察期的一个采样窗口。
type Sample struct {
	Requests int
	Errors   int
}

// ErrorRate 返回错误率;样本为 0 时返回 0。
func (s Sample) ErrorRate() float64 {
	if s.Requests == 0 {
		return 0
	}
	return float64(s.Errors) / float64(s.Requests)
}

// Observe 是「改之后」的灰度观察:样本不足先继续观察,错误率超阈值立即熔断。
func (g Guardrails) Observe(s Sample) (Action, string) {
	if s.Requests < g.MinSample {
		return ActionHold, fmt.Sprintf("样本 %d < %d,继续观察", s.Requests, g.MinSample)
	}
	if s.ErrorRate() > g.ErrorRateMax {
		return ActionTrip, fmt.Sprintf("错误率 %.2f%% 超过阈值 %.2f%%,熔断回滚",
			s.ErrorRate()*100, g.ErrorRateMax*100)
	}
	return ActionAllow, fmt.Sprintf("错误率 %.2f%% 正常,可继续放量", s.ErrorRate()*100)
}

// AutoRollback 在护栏熔断时把配置回滚到最近一次标记为良好的版本。
// 返回本次回滚产生的差量与目标版本。action 非 Trip 时不动。
func AutoRollback(ctx context.Context, st *Store, action Action) (Delta, Version, error) {
	if action != ActionTrip {
		return Delta{}, 0, nil
	}
	good, ok, err := st.LastGood(ctx)
	if err != nil {
		return Delta{}, 0, err
	}
	if !ok {
		return Delta{}, 0, fmt.Errorf("config: no known-good version to roll back to")
	}
	d, err := st.Rollback(ctx, good)
	if err != nil {
		return Delta{}, 0, err
	}
	return d, good, nil
}

5.3.2 一键回滚:用旧内容发新版本

AutoRollback 的两块拼图在 Store 上:一个「已知良好版本」的标记,和一次基于历史快照的重新发布。标记由运维在每次发布观察通过后手动打,回滚则直接读标记指的那个版本、把它的内容当作新一版的期望状态发出去。下面这段是 L2 文件里的片段(// snippet:,完整见 internal/config/history.go):

// snippet: internal/config/history.go
// MarkGood 把某个版本标记为「已知良好」,回滚的默认目标。
func (s *Store) MarkGood(ctx context.Context, v Version) error {
	_, err := s.cli.Put(ctx, s.goodKey(), strconv.FormatUint(uint64(v), 10))
	return err
}

// LastGood 返回最近一次被标记为良好的版本;未标记时返回 (0,false)。
func (s *Store) LastGood(ctx context.Context) (Version, bool, error) {
	resp, err := s.cli.Get(ctx, s.goodKey())
	if err != nil {
		return 0, false, err
	}
	if len(resp.Kvs) == 0 {
		return 0, false, nil
	}
	n, _ := strconv.ParseUint(string(resp.Kvs[0].Value), 10, 64)
	return Version(n), true, nil
}

// Rollback 回到某个历史版本:把它当作新一版的期望状态重新发布。
// 版本号继续前进,差量是「当前 → 历史内容」的真实变化。
func (s *Store) Rollback(ctx context.Context, to Version) (Delta, error) {
	hist, err := s.History(ctx, to)
	if err != nil {
		return Delta{}, err
	}
	desired := make(map[string]string, len(hist.Items))
	for k, it := range hist.Items {
		desired[k] = it.Value
	}
	return s.Publish(ctx, desired)
}

注意 Rollback 复用了 Publish:它同样只推「当前 → 历史内容」的差量,同样写新版本号与历史快照。回滚不是一条特殊路径,而是配置中心最普通的发布动作之一——这一点让回滚本身也能被审计、被灰度、被再次回滚。

5.3.3 完整断言:从坏配置到内容复原

护栏和回滚各自跑通还不够,要看它们在一条链路上是否真的咬合。下面这段是 L2,真连本机 etcd,go test 可以直接跑。它发布一版好配置并标记,再发布一版坏配置,用护栏判定熔断,最后自动回滚,断言内容复原:

// file: internal/config/guard_test.go
// tier: L2
// verify: go test ./internal/config/ -run TestGuard -v
package config

import (
	"context"
	"testing"
	"time"
)

func TestGuardrailsAndRollback(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	cli := newTestClient(t)
	defer cli.Close()
	st := NewStore(cli, testPrefix)
	_ = st.Cleanup(ctx)
	defer func() { _ = st.Cleanup(context.Background()) }()

	// 1) 发布一版已知良好配置并标记。
	good := map[string]string{"battle.damage_scale": "1.0", "battle.crit_rate": "0.15"}
	if _, err := st.Publish(ctx, good); err != nil {
		t.Fatalf("publish good: %v", err)
	}
	if err := st.MarkGood(ctx, 1); err != nil {
		t.Fatalf("mark good: %v", err)
	}

	g := DefaultGuardrails()

	// 2) 改之前:不可回滚的变更被拒。
	if act, _ := g.CheckImpact(Impact{Key: "proto.login_resp", PlayerPct: 100, Reversible: false}); act != ActionTrip {
		t.Fatalf("irreversible change should trip, got %v", act)
	}
	// 影响面超限被拒。
	if act, _ := g.CheckImpact(Impact{Key: "battle.damage_scale", PlayerPct: 50, HotPath: true, Reversible: true}); act != ActionTrip {
		t.Fatalf("over-impact change should trip, got %v", act)
	}
	// 预算内放行。
	if act, _ := g.CheckImpact(Impact{Key: "battle.damage_scale", PlayerPct: 3, HotPath: true, Reversible: true}); act != ActionAllow {
		t.Fatalf("in-budget change should allow, got %v", act)
	}

	// 3) 发布一版坏配置(伤害系数写错,导致错误率飙升)。
	if _, err := st.Publish(ctx, map[string]string{"battle.damage_scale": "12.0", "battle.crit_rate": "0.15"}); err != nil {
		t.Fatalf("publish bad: %v", err)
	}

	// 4) 改之后:样本不足先 hold。
	if act, _ := g.Observe(Sample{Requests: 100, Errors: 5}); act != ActionHold {
		t.Fatalf("low sample should hold, got %v", act)
	}
	// 错误率超阈值 → 熔断。
	act, reason := g.Observe(Sample{Requests: 2000, Errors: 80})
	if act != ActionTrip {
		t.Fatalf("high error rate should trip, got %v (%s)", act, reason)
	}

	// 5) 自动回滚到最近良好版本。
	d, target, err := AutoRollback(ctx, st, act)
	if err != nil {
		t.Fatalf("auto rollback: %v", err)
	}
	if target != 1 {
		t.Fatalf("rollback target = %d, want 1", target)
	}
	// 回滚的差量必须只包含被改坏的那个键。
	if d.Size() != 1 || d.Changed[0].Key != "battle.damage_scale" {
		t.Fatalf("rollback delta = %+v", d)
	}
	snap, err := st.Snapshot(ctx)
	if err != nil {
		t.Fatalf("snapshot: %v", err)
	}
	if snap.Items["battle.damage_scale"].Value != "1.0" {
		t.Fatalf("after rollback damage_scale = %q, want 1.0", snap.Items["battle.damage_scale"].Value)
	}
	// 版本号继续前进(回滚是新版本,不是倒退)。
	if snap.Version != 3 {
		t.Fatalf("version after rollback = %d, want 3", snap.Version)
	}
}

真跑的结果:

=== RUN   TestGuardrailsAndRollback
--- PASS: TestGuardrailsAndRollback (0.09s)
PASS
ok  	aegis/internal/config	0.727s

这个用例把三道护栏串成了一条闭环:第 2 步证明「改之前」能拦住不可回滚与超影响面的变更;第 4 步证明「改之后」样本不足先 hold、错误率越线即 trip;第 5 步证明「出事时」自动回滚只把坏掉的那个键改回去、内容复原成 1.0,且版本号从 2 前进到 3 而不是倒退。

本节小结

  1. 三道护栏卡在三个位置。 改之前算影响面、灰度期盯错误率、出事时自动回滚;不可回滚的协议/数据模型变更,热更这道门直接拒。
  2. 热路径更严。 战斗、登录这类热路径的影响面上限要显著低于普通配置,因为它们出错的代价是实时的、全服的。
  3. 回滚就是一次普通发布。 用旧内容发新版本,版本号继续前进,差量只改被改坏的键——因此回滚本身也可审计、可灰度。
  4. 护栏要用端到端断言证明咬合。 发布好版本、发布坏版本、熔断、回滚、验内容复原,一条链路跑通才算数。

一句话收束:线上风控的价值不在于「防止有人改错」,而在于「改错了能在几十秒内自己收回来」——护栏负责发现,已知良好版本负责兜底,两者合起来才是可长期运营的配置变更。 下一节换一个话题:把线上产生的行为变成可分析的指标,先从指标体系与口径开始。

阅读导航:上一节:5.2 热重载的边界 · 下一节:6.1 指标体系与口径 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标