一次改名,回滚后读不到数据
《群岛纪元》的联盟表有个字段叫 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 两个字段,用于新的匹配算法。按三阶段走:
- 扩:新增可空列
mmr_uncertainty,默认值由服务端在写入时补;新代码读取时如果为空,按固定值处理。旧代码完全不知道这一列。 - 双写:新代码同时写
mmr和mmr_uncertainty,读匹配分时仍然以mmr为准;此时两个版本对匹配结果的影响一致,可以安全共存。 - 回填:按赛季分批给历史玩家补
mmr_uncertainty,只填空值,不覆盖双写已经写入的数据;每批完成后抽样比对两列的分布。 - 切换读路径:新代码改为读新字段,同时保留旧字段写入;这一步要观察匹配时长和段位分布,确认没有系统性偏移。
- 收:窗口结束后删除旧读取逻辑,再下一个版本删除
mmr列。删除列和切换读路径不在同一个版本里,任何一步出问题都能停。
这个例子里的关键不是 SQL,而是第 3 步和第 4 步的顺序:先回填再切读,还是先切读再回填,决定了回滚时旧代码看到的是什么。北辰的选择是先回填、后切读,因为回填是幂等的,切读是有状态的。
回填的三个约束
回填看起来只是“跑一个脚本”,实际上是最容易造成数据损坏的一步。三个约束必须同时满足:
- 只填空值。回填的更新条件要带
WHERE new_field IS NULL或版本条件,不能无条件覆盖。双写期间新写入的数据比回填脚本更新,覆盖会丢更新。 - 可暂停、可重入。按主键区间或赛季分批,每批记录进度;失败后从上一批继续,而不是从头再来。重跑同一批的结果必须一致。
- 有比对。每批完成后抽样比对旧值和新值,差异率超过阈值就停下来。没有比对的回填只是“脚本跑完了”,不是“数据对了”。
回填还要考虑数据库压力。全表扫描会拖慢在线查询,批次大小和间隔要按只读副本的延迟调整;在只读副本上回填、再切主,是常见做法,但切主本身也要纳入窗口计算。
兼容窗口有多长
窗口不是拍脑袋定的,它由三个时钟决定,取最大值:
| 时钟 | 含义 | 为什么是下界 |
|---|---|---|
| 最长对局 | 一局从开始到结算落库 | 对局中途不能换规则,旧版本要活到最后一局结束 |
| 最长离线 | 玩家可能多久后回来 | 回流玩家带着旧客户端的预期读新数据 |
| 最长回滚时间 | 从决定回滚到旧版本全量服务 | 窗口内必须允许旧版本重新接管 |
三款产品的示例值:
| 产品 | 最长对局 | 最长离线 | 最长回滚 | 兼容窗口 |
|---|---|---|---|---|
| 《裂隙战场》 | 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 回滚、开关与灰度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。