《游戏服务端架构·平台化与架构治理》10.1 绞杀者与增量迁移

旧战斗服务不能停机重写。本节把绞杀者迁移拆成影子只读、双写、切读、停旧四个阶段,比较按接口、按数据、按玩家分桶三种迁移单元,说明兼容层、双写对账、回滚开关与监控口径,给出旧路径零流量、对账一致、回滚窗口结束、旧代码删除四条完成判据,并附可运行的 Go 迁移状态机与对账代码。

迁移不是重写,是换轨

《潮汐要塞》是一款 2019 年上线的 SLG,战斗、行军、资源产出和联盟逻辑都在同一个进程里。到第四个赛季,团队想把战斗结算抽成独立服务,因为每次改公式都要和行军、地图、活动一起发版。最初方案是“停服两周重写”,评审时被运营一句话否掉:赛季中间停两周,联盟战进度和付费活动全部作废。

最后采用的不是重写,而是换轨:旧进程继续跑,新服务从一条结算接口开始接管流量,旧代码逐步失去调用方,最后删除。这个过程就是绞杀者迁移。它的难点不在写新服务,而在迁移期间两套系统必须同时正确。

本节不重复卷一的绞杀者概念,而是给一份可执行的迁移计划:迁移单元怎么选、四个阶段怎么过、兼容层要做什么、什么条件下才允许删旧代码。文中容量、比例与窗口均为示例值或设计目标,不是线上测量。

迁移单元:接口、数据还是玩家分桶

选错迁移单元,后面所有阶段都会变形。常见三种切法:

单元切什么适合主要风险
按接口一个 RPC 或一组命令边界清晰、读写比例稳定接口背后仍共享数据,切完还是耦合
按数据一张表、一类实体或一个赛季数据归属明确、可离线校验跨表事务被拆开,业务语义不完整
按玩家分桶玩家 ID 的稳定哈希区间在线灰度、可精确控制比例公会、跨服玩法被拆散

《潮汐要塞》用的是组合切法:战斗结算按接口切,战斗录像与战报按数据切,灰度流量按玩家分桶。三者分别回答“切哪段逻辑”“搬哪份数据”“让谁先走新路”。如果只按接口切而不迁数据,新服务仍要反向读旧库,旧库就永远删不掉;如果只按数据切而不切接口,旧进程仍会写同一张表,双写一致性无从谈起。

迁移单元还要满足一个约束:一个单元必须能独立回滚。按玩家分桶天然满足,因为回滚只需把桶路由回旧路径;按公会包迁也满足,只要包内原子;按整张表迁通常不满足,除非有快照和反向同步。

四个阶段,不能跳

把迁移看成一次状态机,而不是一次发布。四个阶段的读写路径不同:

阶段写旧写新读旧读新进入条件
影子只读是镜像是否新服务能消费旧事件,且不影响主链路
双写是是是否影子结果与旧结果抽样一致
切读是是按桶按桶双写错误率与延迟在预算内
停旧否是否是旧路径零流量、对账一致、回滚窗口结束

四个阶段不能跳。跳过影子只读,等于让一个没经过真实流量校验的新服务直接接写;跳过双写,切读后新路径没有历史数据;跳过切读直接停旧,回滚时没有可退回的读路径。状态机代码会把“跳阶段”判为非法转移,而不是靠值班同学记住规范。

回滚也不是任意跳。切读后回到双写,双写后回到影子只读,每次只退一格;已经停旧就不能“回滚”,只能发起一次新的迁移。原因很简单:旧代码删掉以后,回滚目标已经不存在了。

兼容层要做的四件事

兼容层不是一层薄薄的转发,它要同时解决四个问题:

能力要解决的问题失败表现最低要求
适配器新旧接口字段、错误码、分页语义不同客户端收到旧错误码,重试风暴双向映射有契约测试
双写两次写的顺序、幂等与部分失败一旧一新,长期对不上写操作带幂等键,失败可重放
回滚开关按玩家桶而不是全局切换一开全开,故障面扩大开关粒度到桶,变更留审计
监控两套路径的成功率、延迟、差异只看到总成功率,定位不到路径旧/新路径分开打点

双写最常见的错误是先写新再写旧,且新写成功、旧写失败时直接返回成功。这样旧路径会静默缺数据,等到切读才发现。正确顺序取决于哪边是事实源:迁移期间旧路径仍是事实源,应先写旧、再写新;新写失败进入重试队列,不能反向覆盖旧值。

回滚开关必须能按桶关闭。全局开关在故障时只有两个选择:全量回滚,或者继续观察。按桶关闭可以把爆炸半径限制在已经切过去的那部分玩家。

流量切换:按稳定分桶,不按时间

灰度比例不要用随机数或“每十个请求放一个”。随机数会让同一个玩家在两次请求间来回切换,缓存、会话和战斗状态都可能裂脑。正确做法是对稳定键做哈希,落到固定桶:

  1. 选定稳定键:玩家 ID 优先;没有玩家上下文时用账号 ID,不能用请求 ID。
  2. 固定桶数,例如 100 个桶,整个迁移期不变。
  3. 新路径比例等于“前 N 个桶”,从 0 单调增加到 100。
  4. 每次调整比例都记录版本,回滚时回到上一个版本而不是“大概 30%”。

桶数一旦改变,所有键的落点都会重排,灰度比例失去意义。这也是为什么比例是桶的数量,而不是一个可以随时调的浮点数。

对账:快照口径比算法更重要

对账不是“跑个 diff”。先要定义在哪个逻辑点上比较:

  • 同点快照:旧库和新库在同一业务时点导出,适合可停写或可加锁的实体。
  • 追平后比较:允许新库落后一个已知延迟,先追平再比,适合高频写的玩家状态。
  • 抽样三方比对:旧库、新库、事件日志三方各取样本,适合怀疑双写丢事件的场景。

对账结果分三类:新库缺失、新库多余、值不一致。三类的处理方式不同。缺失通常是双写失败或消费滞后;多余通常是旧库删除后新库残留,或重放导致;值不一致才需要逐字段看业务语义。把三类混成一个“对账失败”数字,值班同学无法判断该重放还是该回滚。

还要承认对账的盲区:快照之后的写不在比较范围内。双写日志必须能覆盖“快照点之后到切读完成”的窗口,否则对账通过不代表数据正确。

完成判据与回滚窗口

迁移完成不是“新服务上线”,而是同时满足四条:

判据证据不满足时
旧路径零流量路由统计连续多个窗口为 0继续切读,不进入停旧
数据对账一致最近一次全量或约定抽样无差异重放、修复后再对账
回滚窗口结束约定观察期已过,且无未决事故保留旧代码与旧库只读副本
旧代码删除代码库与部署清单中无旧入口标记技术债,不宣称完成

回滚窗口的长度由业务风险决定,不由团队疲惫程度决定。赛季结算、重大活动、版本更新前后都应延长窗口。窗口内旧代码不删、旧库不写但保留只读,窗口结束后再走删除流程。删除本身也要检查:配置、定时任务、离线脚本、客服工具是否还引用旧入口。只删主服务而留下一个 nightly job,旧路径会在半夜复活。

代码:状态机与对账

下面的代码把阶段转移、桶路由、对账和停旧条件做成可测试的纯函数。它不依赖任何游戏框架,可以直接放进迁移工具或 CI 检查。

// file: internal/stranglermigration/stranglermigration.go
// tier: L1
// verify: go test ./internal/stranglermigration/
package stranglermigration

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

// Stage is the lifecycle position of one migration unit.
type Stage string

const (
	// StageShadowRead mirrors writes and reads only from the old path.
	StageShadowRead Stage = "shadow_read"
	// StageDualWrite writes both paths; reads still come from the old path.
	StageDualWrite Stage = "dual_write"
	// StageCutoverRead sends an increasing share of reads to the new path.
	StageCutoverRead Stage = "cutover_read"
	// StageRetired means the old path carries no traffic and can be removed.
	StageRetired Stage = "retired"
)

var (
	ErrUnknownStage      = errors.New("stranglermigration: unknown stage")
	ErrIllegalTransition = errors.New("stranglermigration: illegal stage transition")
	ErrBadBuckets        = errors.New("stranglermigration: buckets must be positive")
	ErrBadRatio          = errors.New("stranglermigration: ratio must be within 0..100")
	ErrRetireBlocked     = errors.New("stranglermigration: retirement blocked")
)

var stageOrder = []Stage{StageShadowRead, StageDualWrite, StageCutoverRead, StageRetired}

func stageIndex(s Stage) (int, bool) {
	for i, cur := range stageOrder {
		if cur == s {
			return i, true
		}
	}
	return 0, false
}

// NextStage returns the forward transition. Retirement has no successor.
func NextStage(s Stage) (Stage, bool) {
	i, ok := stageIndex(s)
	if !ok || i+1 >= len(stageOrder) {
		return "", false
	}
	return stageOrder[i+1], true
}

// ValidateTransition accepts only a single forward step. Skipping a stage
// hides a failed verification, so it is rejected even when the operator is in a
// hurry.
func ValidateTransition(from, to Stage) error {
	fi, okFrom := stageIndex(from)
	ti, okTo := stageIndex(to)
	if !okFrom || !okTo {
		return ErrUnknownStage
	}
	if fi+1 != ti {
		return fmt.Errorf("%w: %s -> %s", ErrIllegalTransition, from, to)
	}
	return nil
}

// ValidateRollback accepts a single step back, except from retired. Once the
// old code is deleted a rollback is a new migration, not a state transition.
func ValidateRollback(from, to Stage) error {
	fi, okFrom := stageIndex(from)
	ti, okTo := stageIndex(to)
	if !okFrom || !okTo {
		return ErrUnknownStage
	}
	if from == StageRetired {
		return fmt.Errorf("%w: retired is terminal", ErrIllegalTransition)
	}
	if ti+1 != fi {
		return fmt.Errorf("%w: rollback %s -> %s", ErrIllegalTransition, from, to)
	}
	return nil
}

// Target names the read path chosen for one key.
type Target string

const (
	TargetOld Target = "old"
	TargetNew Target = "new"
)

// Bucket maps a key to a stable bucket. Callers must keep the bucket count
// fixed for the whole migration: changing it re-shuffles every key and turns a
// canary into a second migration.
func Bucket(key string, buckets int) (int, error) {
	if buckets <= 0 {
		return 0, ErrBadBuckets
	}
	h := fnv.New32a()
	if _, err := h.Write([]byte(key)); err != nil {
		return 0, err
	}
	return int(h.Sum32() % uint32(buckets)), nil
}

// Route picks the read path for a key at the given new-path percentage.
// Percentages are bucket counts, not wall-clock promises.
func Route(key string, newReadPercent int) (Target, error) {
	if newReadPercent < 0 || newReadPercent > 100 {
		return "", ErrBadRatio
	}
	b, err := Bucket(key, 100)
	if err != nil {
		return "", err
	}
	if b < newReadPercent {
		return TargetNew, nil
	}
	return TargetOld, nil
}

// ValidateSplit makes the old/new read shares explicit and total 100.
func ValidateSplit(oldPercent, newPercent int) error {
	if oldPercent < 0 || newPercent < 0 || oldPercent > 100 || newPercent > 100 {
		return ErrBadRatio
	}
	if oldPercent+newPercent != 100 {
		return fmt.Errorf("%w: old=%d new=%d", ErrBadRatio, oldPercent, newPercent)
	}
	return nil
}

// Mismatch records one key whose two copies disagree.
type Mismatch struct {
	Key      string
	OldValue string
	NewValue string
}

// Report is the result of one reconciliation pass. Slices are sorted so two
// runs over the same state produce the same report.
type Report struct {
	MissingInNew []string
	ExtraInNew   []string
	Mismatched   []Mismatch
}

func (r Report) Equal() bool {
	return len(r.MissingInNew) == 0 && len(r.ExtraInNew) == 0 && len(r.Mismatched) == 0
}

func (r Report) Summary() string {
	return fmt.Sprintf("missing=%d extra=%d mismatched=%d",
		len(r.MissingInNew), len(r.ExtraInNew), len(r.Mismatched))
}

// Reconcile compares value snapshots taken at the same logical point. It says
// nothing about writes that land after the snapshot; the dual-write log has to
// cover that gap.
func Reconcile(oldState, newState map[string]string) Report {
	var report Report
	oldKeys := make([]string, 0, len(oldState))
	for k := range oldState {
		oldKeys = append(oldKeys, k)
	}
	sort.Strings(oldKeys)
	for _, k := range oldKeys {
		nv, ok := newState[k]
		switch {
		case !ok:
			report.MissingInNew = append(report.MissingInNew, k)
		case nv != oldState[k]:
			report.Mismatched = append(report.Mismatched, Mismatch{Key: k, OldValue: oldState[k], NewValue: nv})
		}
	}
	newKeys := make([]string, 0, len(newState))
	for k := range newState {
		newKeys = append(newKeys, k)
	}
	sort.Strings(newKeys)
	for _, k := range newKeys {
		if _, ok := oldState[k]; !ok {
			report.ExtraInNew = append(report.ExtraInNew, k)
		}
	}
	return report
}

// RetireCheck is the evidence required before deleting the old path.
type RetireCheck struct {
	OldReadPercent   int
	DualWriteStopped bool
	ReconcileEqual   bool
	RollbackClosed   bool
}

// CanRetire lists every unmet condition instead of failing on the first one.
func CanRetire(c RetireCheck) error {
	var problems []string
	if c.OldReadPercent != 0 {
		problems = append(problems, fmt.Sprintf("old path still reads %d%%", c.OldReadPercent))
	}
	if !c.DualWriteStopped {
		problems = append(problems, "dual write still active")
	}
	if !c.ReconcileEqual {
		problems = append(problems, "reconciliation not equal")
	}
	if !c.RollbackClosed {
		problems = append(problems, "rollback window still open")
	}
	if len(problems) > 0 {
		return fmt.Errorf("%w: %s", ErrRetireBlocked, strings.Join(problems, "; "))
	}
	return nil
}

代码有三个刻意设计。第一,ValidateTransition 只允许前进一步,跳阶段直接报错;ValidateRollback 只允许退一步,停旧后不可回滚。第二,Route 用 100 个固定桶和 FNV 哈希,保证同一玩家在比例调整时不会来回跳。第三,CanRetire 一次列出所有未满足条件,而不是遇到第一个就返回,值班同学能一眼看到还差什么。

测试覆盖了合法与非法转移、比例边界、对账三类差异和停旧四条件。把这些函数接到迁移工具后,迁移计划就不再是一张口头 checklist,而是一组能在发布前跑过的断言。

失败路径

失败早期信号错误处置正确处置
双写新路径持续失败新路径错误率单独升高全局回滚按桶暂停,保留旧路径,重放失败写
对账出现大量缺失新库写入延迟上升直接补数据先查双写日志与消费位点,再重放
切读后错误率升高新路径 p99 与错误码变化继续加比例冻结比例,回退到上一桶版本
旧代码删早了旧路径仍有零星调用重新部署旧版本恢复入口并补迁移,不掩盖调用方

上线检查表

  • 迁移单元能独立回滚,且回滚粒度到玩家桶。
  • 新旧接口有契约测试,错误码与分页语义有映射。
  • 双写顺序以旧路径为事实源,写操作带幂等键。
  • 回滚开关按桶生效,变更留审计。
  • 旧/新路径分开打点,能看到各自成功率与延迟。
  • 对账口径写清楚,三类差异分别有处置人。
  • 停旧四条判据全部有证据,回滚窗口未提前关闭。
  • 删除旧代码前检查配置、定时任务、离线脚本与客服工具。

绞杀者迁移的产出不是“新服务上线”,而是“旧路径可以安全消失”。只要旧路径还在接流量、还在被脚本调用、还没有对账证据,迁移就仍在进行中。下一节讨论另一种演进:当一款游戏成功以后,哪些能力应该抽成平台,哪些必须留在游戏内。

阅读导航:上一节:9.3 组织变化如何反映到架构 · 下一节:10.2 单体到平台、单游戏到多游戏 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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