《游戏服务端架构·规模化、全球化与成本》11.2 兼容窗口与数据迁移

《群岛纪元》一次联盟字段改名,让回滚后的旧服务端读不到数据。这一节把兼容拆成新读旧、旧读新两个方向,给出扩、双写、收三阶段迁移的状态机与回填约束,用最长对局、最长离线与最长回滚时间取最大值来确定兼容窗口,并附可运行的迁移阶段、窗口计算与回滚判定代码。

一次改名,回滚后读不到数据

《群岛纪元》的联盟表有个字段叫 alliance_tag。跨服战改版时,工程侧决定统一叫 guild_tag,迁移脚本直接改了列名,代码同步发版。发布本身很顺利,问题出在第二天:新版本的一个聚合查询超时,值班同学按预案回滚到旧版本。

旧版本启动后,联盟列表全部为空。原因是旧代码读 alliance_tag,而数据库里已经没有这一列;更糟的是,新版本在改名后写入的数据只有 guild_tag,即使把列名改回去,这部分数据也补不回来。一次本来只影响单个查询的回滚,变成了全服联盟功能不可用。

这次事故的根因不是“回滚失败”,而是“回滚从来没有被当成迁移的一部分”。兼容不是新版本能不能跑,而是新旧两个版本能不能同时读写同一份数据。

兼容有两个方向

讨论数据兼容时,最容易混淆的是方向。把它拆成两个问题,后面的规则就清楚了:

方向问题典型场景
向前(Forward)新代码能不能读旧数据发布中,新进程读到迁移前写入的行
向后(Backward)旧代码能不能读新数据回滚后,旧进程读到新版本写入的行

两个方向都安全,才叫兼容。只做向前兼容,回滚就会踩空;只做向后兼容,新功能上线时历史数据会读不出来。事故里最常见的错误是只验证了“新版本能启动”,而没有验证“旧版本还能不能启动”。

还有一个常被忽略的第三个方向:新旧代码同时写。双写期间,两个版本可能同时更新同一行,后写的覆盖先写的。兼容解决的是“读得到”,并发写解决的是“写得对”,后者要靠版本号或条件更新,不能指望字段兼容自动解决。

三阶段迁移

把一次不兼容的变更拆成三步,每一步都保持两个方向可读:

expand --部署新读者--> dual-write --回填完成--> contract --观察期--> done

扩(expand):只增加,不删除、不改名。新增可空列或新表,旧代码完全不知道它的存在;新代码读取时对缺失值有回退。扩阶段结束时,数据库同时具备新旧两种形状,但只有旧形状在被写。

双写(dual-write):新代码同时写新旧两个位置,读仍然优先旧位置或做双读比对。回填(backfill)通常放在这个阶段:分批把历史数据从旧位置写到新位置,回填必须可重入、可暂停,并且不能覆盖双写已经写入的更新。

收(contract):确认窗口已过、没有旧版本写入者之后,停止写旧位置,删除旧列或旧表。收是不可逆的一步,必须在有观察期和明确 owner 的前提下执行。

工程上常把回填单列成第三步,形成“扩、双写、回填、收”四步。语义上它仍属于双写阶段的一个子步骤:真正的阶段边界只有“开始双写”和“停止双写”。代码里把 Backfill 单列为一个阶段,是为了让迁移状态可查询、可暂停,而不是因为它是一个独立的兼容状态。

三阶段的价值在于:任何一步失败,都可以停在原地,两个方向都还能读。扩阶段失败,旧代码不受影响;双写阶段失败,新字段可以丢弃重来;只有收阶段失败是不可逆的,所以它需要最严格的准入条件。

需要兼容窗口的不只是数据库

容易被当成“只有数据库才要迁移”的地方,往往在发布后出问题:

载体迁移形态不兼容时的表现
数据库列 / 表扩、双写、收旧版本读不到列,或新版本读到空值
服务端配置新旧字段并存,按版本解析旧进程解析新配置失败,启动即崩
客户端配置热更文件保留旧字段老客户端读到新格式,界面空白或报错
内部 RPC 消息新增字段可选,旧字段保留跨版本调用反序列化失败
缓存键与结构双 key 写入,旧 key 保留到窗口结束回滚后缓存命中旧结构,类型断言失败
搜索索引 / 报表新旧字段同时建索引运营看到的报表口径和游戏内不一致

这份清单要在迁移评审时逐项过。数据库做了三阶段,缓存却直接换了 key 前缀,回滚后旧代码读新缓存,第一次类型断言就会 panic;这类问题在预发环境很难复现,因为预发的缓存是空的。

一个具体例子:段位分拆

《裂隙战场》把单一的 mmr 拆成 mmr 和 mmr_uncertainty 两个字段,用于新的匹配算法。按三阶段走:

  1. 扩:新增可空列 mmr_uncertainty,默认值由服务端在写入时补;新代码读取时如果为空,按固定值处理。旧代码完全不知道这一列。
  2. 双写:新代码同时写 mmr 和 mmr_uncertainty,读匹配分时仍然以 mmr 为准;此时两个版本对匹配结果的影响一致,可以安全共存。
  3. 回填:按赛季分批给历史玩家补 mmr_uncertainty,只填空值,不覆盖双写已经写入的数据;每批完成后抽样比对两列的分布。
  4. 切换读路径:新代码改为读新字段,同时保留旧字段写入;这一步要观察匹配时长和段位分布,确认没有系统性偏移。
  5. 收:窗口结束后删除旧读取逻辑,再下一个版本删除 mmr 列。删除列和切换读路径不在同一个版本里,任何一步出问题都能停。

这个例子里的关键不是 SQL,而是第 3 步和第 4 步的顺序:先回填再切读,还是先切读再回填,决定了回滚时旧代码看到的是什么。北辰的选择是先回填、后切读,因为回填是幂等的,切读是有状态的。

回填的三个约束

回填看起来只是“跑一个脚本”,实际上是最容易造成数据损坏的一步。三个约束必须同时满足:

  1. 只填空值。回填的更新条件要带 WHERE new_field IS NULL 或版本条件,不能无条件覆盖。双写期间新写入的数据比回填脚本更新,覆盖会丢更新。
  2. 可暂停、可重入。按主键区间或赛季分批,每批记录进度;失败后从上一批继续,而不是从头再来。重跑同一批的结果必须一致。
  3. 有比对。每批完成后抽样比对旧值和新值,差异率超过阈值就停下来。没有比对的回填只是“脚本跑完了”,不是“数据对了”。

回填还要考虑数据库压力。全表扫描会拖慢在线查询,批次大小和间隔要按只读副本的延迟调整;在只读副本上回填、再切主,是常见做法,但切主本身也要纳入窗口计算。

兼容窗口有多长

窗口不是拍脑袋定的,它由三个时钟决定,取最大值:

时钟含义为什么是下界
最长对局一局从开始到结算落库对局中途不能换规则,旧版本要活到最后一局结束
最长离线玩家可能多久后回来回流玩家带着旧客户端的预期读新数据
最长回滚时间从决定回滚到旧版本全量服务窗口内必须允许旧版本重新接管

三款产品的示例值:

产品最长对局最长离线最长回滚兼容窗口
《裂隙战场》15 分钟7 天30 分钟7 天
《群岛纪元》异步结算,无对局14 天2 小时14 天
《泡泡工坊》5 分钟3 天10 分钟3 天

为什么取最大值而不是相加:三个时钟覆盖的是同一段时间里的不同玩家,不是三段必须串行的时间。一个 offline 七天的玩家回来时,对局早就结束了,回滚也可能已经发生过;窗口只要不短于其中任何一个,就能覆盖所有情况。相加会把窗口放大到没有必要的长度,代价是字段多留几周、双写多跑几周。

为什么不能用平均值:最长离线是长尾指标。用平均离线时长算窗口,会漏掉回流玩家;而回流玩家恰恰是最容易触发旧客户端兼容问题的人群。窗口要按 P99.9 甚至最大值定,不能按均值定。

窗口也不是越长越好。窗口越长,双写和旧字段的维护成本越高,收阶段越容易拖成“永远不收”。合理的做法是:窗口取三个时钟的最大值,再留一个固定的观察余量(例如 24 小时),到点必须收,收不了要重新评审,而不是默认续期。

这三个数从哪里来

三个时钟都不能靠估:

  • 最长对局:从对局开始到结算落库的 P99.9,不是平均时长。取平均会把长尾对局排除在窗口之外,而长尾对局恰恰是最容易跨版本结算的那些。
  • 最长离线:登录间隔分布的 P99.9。数据从登录日志里算,按品类和渠道分开看;回流活动期间的间隔会明显变长,窗口要覆盖活动后的回流潮。
  • 最长回滚:靠演练计时,不靠文档。从“决定回滚”到“旧版本全量服务”包含构建产物拉取、配置回退、数据检查、排空和流量切换,任何一项没算进去,窗口都会偏短。

三个数都会随版本变化。赛季制产品在赛季末期离线时长上升,大版本更新后回滚时间变长,窗口要定期重算,不能写死一次用一年。代码里把三个输入显式列出来,就是为了让每次评审都能看到“这次窗口为什么是 14 天”。

迁移和发布是两条时间线

数据迁移的每一步都比发布早一个节拍,而不是和发布同一个节拍:

迁移阶段对应发布版本数量
扩发布 N:新代码能读旧数据N-1 与 N 共存
双写发布 N+1:开始写新位置N 与 N+1 共存
回填不绑定发布,独立任务至少 N 与 N+1 共存
切读发布 N+2:读切换到新位置N+1 与 N+2 共存
收发布 N+3 或更晚:删除旧位置只剩新版本

把“扩”和“双写”塞进同一个版本,等于让新代码第一天就依赖一个可能还没回填完的字段;把“收”和“切读”放在一起,回滚时旧代码既没有旧字段可读,也不知道新字段的含义。每一步之间留一个版本,是为了让回滚有落脚点。

回滚时的数据兼容

回滚判定不能只看“代码回滚成功”,要看“旧代码读到新数据会发生什么”。按变更类型逐项检查:

变更新读旧(向前)旧读新(向后)必要条件
新增可空字段旧行没有值,新代码要能回退旧代码忽略未知字段,安全新读者有 fallback 或默认值
新增必填字段旧行缺值旧代码忽略,安全服务端默认值或回填完成
字段改名新代码要能读旧名旧代码要能读旧名双读 + 双写,旧名保留到收阶段
删除字段新代码不再读,安全旧代码仍在读旧字段双写到收阶段之后
类型放宽(int32→int64)新代码能读窄类型旧代码解码宽类型可能溢出保留旧类型列,双写期间不切换

这张表可以直接当作回滚检查单。任何一行不满足,回滚就不是“切回旧版本”这么简单,而是“切回旧版本 + 数据修复”,后者的时间和风险都要按事故处理,不能按发布处理。

还有一个容易漏的点:枚举和配置值。给状态字段新增一个枚举值,数据库层面完全兼容,但旧代码的 switch 没有这个分支,可能走进默认分支把玩家状态改成未知。字段兼容检查要覆盖取值域,而不只是列是否存在。

回滚兼容必须演练。演练不是“把旧镜像拉起来看一眼”,而是用一份包含新版本写入数据的快照,让旧版本完整跑一遍核心链路:登录、读联盟、进对局、结算、领奖。演练频率和迁移频率一致,每次收阶段之前至少做一次。演练通过的标准不是“服务能启动”,而是“读到的值和写之前一致”。

演练还要覆盖“回滚一半”的情况:一半机器旧版本、一半新版本。这正是兼容窗口存在的意义——窗口内的混合状态必须是可运行的,而不是只在新旧各自独立时才成立。

代码:迁移阶段、窗口与回滚准入

下面的 internal/compatwindow/window.go 把阶段推进、收阶段准入和窗口计算放在一个文件里。Advance 只允许相邻阶段,防止从扩直接跳到收;Contractable 同时检查窗口和旧写入者数量;Window 取三个时钟的最大值。

// file: internal/compatwindow/window.go
// tier: L1
// verify: go test ./internal/compatwindow/

package compatwindow

import (
	"errors"
	"fmt"
	"time"
)

var (
	ErrInvalidPlan = errors.New("compatwindow: invalid plan")
	ErrOutOfOrder  = errors.New("compatwindow: phase out of order")
	ErrWindowOpen  = errors.New("compatwindow: compatibility window still open")
	ErrOldWriters  = errors.New("compatwindow: old-version writers still active")
)

// Phase is the three-stage migration, plus the optional backfill between the
// two writes and the final state. The order is fixed: expand, dual-write,
// backfill, contract, done. Skipping a stage is how data gets lost.
type Phase string

const (
	Expand    Phase = "expand"
	DualWrite Phase = "dual-write"
	Backfill  Phase = "backfill"
	Contract  Phase = "contract"
	Done      Phase = "done"
)

func (p Phase) index() int {
	switch p {
	case Expand:
		return 0
	case DualWrite:
		return 1
	case Backfill:
		return 2
	case Contract:
		return 3
	case Done:
		return 4
	default:
		return -1
	}
}

type Migration struct {
	name    string
	phase   Phase
	entered map[Phase]time.Time
}

func NewMigration(name string, at time.Time) (*Migration, error) {
	if name == "" {
		return nil, fmt.Errorf("%w: empty migration name", ErrInvalidPlan)
	}
	return &Migration{name: name, phase: Expand, entered: map[Phase]time.Time{Expand: at}}, nil
}

func (m *Migration) Name() string { return m.name }
func (m *Migration) Phase() Phase { return m.phase }
func (m *Migration) Entered(p Phase) (time.Time, bool) {
	t, ok := m.entered[p]
	return t, ok
}

// Advance moves to the immediately following phase. The strict adjacency is
// deliberate: letting a caller jump from expand to contract skips the dual
// write, and that is the mistake this state machine exists to prevent.
func (m *Migration) Advance(to Phase, at time.Time) error {
	if to.index() < 0 {
		return fmt.Errorf("%w: unknown phase %q", ErrInvalidPlan, to)
	}
	if m.phase == Done || to.index() != m.phase.index()+1 {
		return fmt.Errorf("%w: %s -> %s", ErrOutOfOrder, m.phase, to)
	}
	m.phase = to
	m.entered[to] = at
	return nil
}

// Contractable is the gate before dropping the old field. Two conditions must
// both hold: the window has elapsed since dual-write started, and no old-version
// writer is still alive. Time alone is not enough; a long-lived old process
// will happily write the old shape after the window.
func (m *Migration) Contractable(at time.Time, window time.Duration, oldWriters int) error {
	since, ok := m.entered[DualWrite]
	if !ok {
		return fmt.Errorf("%w: dual-write never started", ErrOutOfOrder)
	}
	if oldWriters > 0 {
		return fmt.Errorf("%w: %d", ErrOldWriters, oldWriters)
	}
	if at.Sub(since) < window {
		return fmt.Errorf("%w: %s of %s", ErrWindowOpen, at.Sub(since), window)
	}
	return nil
}

// Timings are the three clocks that decide how long old and new must coexist.
type Timings struct {
	LongestSession time.Duration // one match, from start to settlement
	LongestOffline time.Duration // a player may return after this long
	MaxRollback    time.Duration // time to redeploy the previous version
}

// Window is the maximum of the three clocks. The longest one wins because the
// window must cover a player who was offline before the change, came back after
// it, and still needs the old contract; it must also leave room to roll back
// without rewriting data again.
func Window(t Timings) (time.Duration, error) {
	if t.LongestSession <= 0 || t.LongestOffline <= 0 || t.MaxRollback <= 0 {
		return 0, fmt.Errorf("%w: timings must be positive", ErrInvalidPlan)
	}
	w := t.LongestSession
	if t.LongestOffline > w {
		w = t.LongestOffline
	}
	if t.MaxRollback > w {
		w = t.MaxRollback
	}
	return w, nil
}

几个设计取舍:

  • 阶段推进只允许相邻一步。迁移脚本重试是常态,但“重试”应该是重复当前阶段的操作,而不是跳过中间阶段;Advance 把跳过直接判为 ErrOutOfOrder。
  • Contractable 先检查旧写入者,再检查时间。顺序有意义:有旧写入者时,时间再长也不能收;先报 ErrOldWriters 能让人立刻去查是哪台机器还没升级。
  • Window 对三个输入都要求正数。允许传 0 会让“没有对局”的产品误以为窗口可以是 0,而它仍然要覆盖离线与回滚。
  • 回填没有单独的状态机,只有阶段标记。回填的进度、批次、失败重试属于任务系统,不属于兼容状态机;把两者混在一起会让状态机变成任务队列。

配套的 changes.go 做逐字段的双向检查,Assess 返回向前和向后两组阻断项。它的用途不是替代人工评审,而是在发布前把“这个字段没有故事”的情况自动挑出来。

进入收阶段之前,检查单至少包含五项:

  • 窗口从 DualWrite 进入时间算起,已经超过三个时钟的最大值。
  • 集群里没有旧版本写入者,包括离线任务、运营后台和临时脚本。
  • 回填比对连续三批差异率为零,且最后一批完成时间早于窗口结束。
  • 用包含新数据的快照演练过旧版本回滚,核心链路通过。
  • 旧字段的最后引用已经删除,删除后一个版本内没有回滚计划。

五项里任何一项不满足,收阶段就应该推迟,而不是“先删了再说”。推迟的成本是多留一个字段,提前的成本可能是一次数据修复。

失败模式与兜底

  • 只扩不收:字段留了两年,所有人都以为它还在用。收阶段要有 owner 和截止日,到期未收要升级,而不是默默续期。
  • 双写不双读:只写新字段、读还读旧字段,回填完成后新字段永远读不到;或者反过来,新代码只读新字段,旧数据全是空。双写阶段必须明确读路径。
  • 回填覆盖新写入:批量回填把旧值写到新字段,覆盖了双写已经写入的最新值。回填要带版本条件或时间条件,只填空值,不覆盖非空值。
  • 窗口按平均离线算:长尾回流玩家被漏掉,回滚时旧客户端读到不认识的数据。
  • 配置与数据库不同步:数据库做了三阶段,客户端配置却直接热更成新格式,旧客户端解析失败。配置迁移也要走同一套窗口。
  • 回滚后枚举越界:新版本写入的状态值旧代码不认识,走进默认分支。字段兼容检查要覆盖取值域。
  • 缓存先于数据库切换:数据库还在双写,缓存已经只写新 key,回滚后旧代码读不到缓存,直接穿透到数据库。
  • 离线报表按新口径重算:历史报表被新口径覆盖,运营无法对比版本前后的变化;报表迁移要保留旧口径到窗口结束。
  • 回填与在线写入竞争:回填任务和双写同时更新同一行,没有版本条件,最后一次写入决定了谁赢。回填必须只填空值。

常见错误与边界

  • 把“新版本能启动”当成兼容验证通过。
  • 改名时只改列名,不保留旧列,回滚直接失效。
  • 兼容窗口按发布周期定,而不是按玩家行为定。
  • 收阶段和回填放在同一个发布里,失败后无法判断是回填错还是删除错。
  • 只考虑数据库,不考虑缓存、搜索索引和离线报表里的同一份数据。
  • 把兼容窗口当成“延期删除”的借口,没有截止日和负责人。
  • 收阶段前没有用真实快照演练回滚,只在空库上验证过启动。
  • 双写的两个位置没有对账,直到收阶段才发现某类写入一直只写一边。

小结

兼容窗口的本质是“给旧版本留出继续正确工作的时间”。它由最长对局、最长离线和最长回滚三个时钟的最大值决定,三阶段迁移把一次不兼容变更拆成三个都能停下来的状态,收阶段则用“窗口已过 + 无旧写入者”双条件把关。回滚不是发布的反向操作,而是迁移设计的一部分:设计时没给旧代码留读路径,回滚时就只剩数据修复一条路。

窗口确定之后,下一个问题是窗内怎么放量、出问题谁来决定回滚。下一节把开关分级、灰度维度和回滚判据放在一起,讨论自动回滚到底能自动到什么程度。

阅读导航:上一节:11.1 有状态服务的发布 · 下一节:11.3 回滚、开关与灰度 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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