主库挂了,玩家看到什么
《群岛纪元》全球同服,账号库与联盟库在同一个区域。一次存储节点故障让主库不可写,从库虽然还在,但切换脚本没有自动执行——它需要人工确认,而值班同学正在另一个事故的电话里。二十分钟后切换完成,玩家看到的是:登录转圈、联盟聊天发不出去、已经派出的行军停在半路。数据没丢,但“服务回来”的时间由人的响应速度决定,而不是由架构决定。
《裂隙战场》的故障则是另一种形态。一台房间服宿主机宕机,上面跑着几十场对局。他们没有给房间做跨机热备,而是让客户端在超时后重新匹配、重开一局。对 5v5 竞技来说,一局十几分钟,重开的体验损失小于维护一套房间热备的成本。同一个“故障切换”问题,在两个品类里有完全不同的答案。
《泡泡工坊》的大厅是无状态的,前面挂负载均衡,坏一台摘一台,玩家几乎无感。真正需要保护的是支付与账号,而这两块的数据量小、写入频率低,用主备加半同步复制就够。
这一节讨论:主备、双活、多活各自买到了什么,切换的四个步骤分别会出什么错,以及为什么“旧主还在写”是切换里最危险的一分钟。
三种形态,三种代价
| 形态 | 平时资源 | RTO(示例目标) | RPO(示例目标) | 主要代价 |
|---|---|---|---|---|
| 主备 | 备机空闲或只读 | 1–5 分钟 | 秒级到分钟级 | 备机利用率低,切换要演练 |
| 双活 | 两边都承载流量 | 秒级到分钟级 | 取决于同步方式 | 冲突解决、数据归属复杂 |
| 多活 | 多区域同时服务 | 区域级故障接近无感 | 跨区同步延迟 | 成本最高,一致性最难 |
“双活”经常被当成一个可以买的开关,但它首先是一个数据问题:同一份数据允许两边同时写吗?如果允许,冲突怎么解?如果不允许,那所谓双活只是“读双活、写单点”,切换时仍然要选主。
游戏里真正适合双活的系统比想象中少。登录票据、匹配队列、聊天、观战分发这类“请求可路由、状态可重建”的服务适合双活;账号资产、订单、联盟占领关系这类“必须只有一个权威写入者”的数据,更适合主备或单写多读。把后者硬做成双活,最后一定要在应用层做冲突合并,而冲突合并的规则往往比故障本身更难维护。
游戏系统逐个看
| 系统 | 推荐形态 | 理由 | 切换时玩家看到 |
|---|---|---|---|
| 登录与账号 | 主备 + 半同步 | 写少、强一致、不可冲突 | 短暂无法登录 |
| 支付与订单 | 主备 + 独立对账 | 资损不可接受,宁停不重 | 下单失败,可重试 |
| 匹配队列 | 双活或可重建 | 队列状态可丢,重新排队即可 | 匹配变慢,需重新排队 |
| 房间服 | 单点 + 重建 | 生命周期短,热备成本高于重开 | 本局中断,重开 |
| 世界状态 | 单写 + 异步备 | 冲突代价高,权威必须唯一 | 回档到最近位点 |
| 聊天与社交 | 双活 | 消息可重发,弱一致可接受 | 短暂延迟或重连 |
这张表的用法不是照抄,而是逼团队回答两个问题:这个系统的状态能不能重建?不能重建时,能不能接受“只有一个写入者”?两个都否定,才值得为它做真正的多活。
切换四步:检测、仲裁、提升、回切
检测决定“多快开始”,也决定“会不会误切”。心跳连续丢失 N 次才判定故障,N 太小会被网络抖动触发,N 太大则 RTO 变长。示例值:3 次心跳、每次 2 秒,检测窗口约 6 秒。检测只能产生“怀疑”,不能直接提升——这是很多自研切换脚本的第一个错误。
仲裁决定“谁有资格提升”。单机脚本没有仲裁能力,网络分区时两边的脚本都会认为自己该切。至少要有奇数个投票节点,多数派同意才能进入下一步。仲裁的意义不是选出一个更聪明的节点,而是保证同一时刻最多只有一个候选主。
提升之前必须先 fencing:让旧主确定不能再写。fencing 可以是撤销旧主的存储挂载、吊销它的数据库凭证、或者在数据面校验 epoch。只发一条“你下线吧”的消息不算 fencing,因为消息可能丢、旧主可能卡住、进程可能还在跑。
回切是最容易被忽略的一步。故障主恢复后,它上面的数据可能比新主旧,也可能有未同步的写入。直接把它挂回主库会造成数据错乱。正确做法是:先以从库身份接入、追上位点、人工确认没有分叉,再决定是否回切;多数团队会选择“不回切”,让新主继续服务,把旧主降级为备。
脑裂与 fencing:旧主还在写怎么办
脑裂的定义很简单:两个节点都认为自己有写权限。它通常发生在网络分区,而不是进程崩溃——进程崩溃的节点不会继续写,网络隔离的节点会。
防线有三层,缺一不可:
- 仲裁层:多数派才能发起提升,少数派一侧不允许产生新主。
- 数据层:写入携带 fencing token(epoch),低于当前 epoch 的写入被拒绝。
- 运维层:切换期间冻结高风险操作(手动改库、批量补偿),避免人在两边同时动手。
第二层最容易被跳过,因为“旧主已经挂了”是大多数时候的事实。但只要有一次它没挂,前两层就决定了事故是“短暂不可用”还是“数据分叉”。fencing token 的关键是单调递增且由权威方发放:新主拿到 epoch 2,旧主持有 epoch 1,存储层只接受不小于当前值的 token。旧主恢复后第一次写入就会被拒绝,而不是悄悄写进去。
切换窗口与数据损失边界
异步复制下,切换必然丢数据,丢多少取决于复制延迟。示例目标可以写成:
- 同城异步复制:正常延迟 10–50 毫秒,切换 RPO 目标 1 秒以内。
- 跨城异步复制:正常延迟 30–100 毫秒,切换 RPO 目标 5 秒以内。
- 半同步复制:至少一个备机确认才算提交,RPO 接近 0,但写入延迟增加一个往返。
这些是设计目标,不是实测值。真实数字必须在目标机房的链路上量,并且要区分“平均延迟”和“故障前的尾部延迟”——切换往往发生在网络已经不稳的时候,那时的复制延迟远高于平时。
切换窗口内玩家看到什么,要提前定义,而不是等事故时临场决定:
| 阶段 | 玩家侧表现 | 产品侧动作 |
|---|---|---|
| 检测中 | 请求变慢或超时 | 客户端展示“网络不稳定” |
| 切换中 | 登录失败、写入报错 | 禁止重复提交,保留本地操作 |
| 提升后 | 恢复服务,可能回档 | 公告补偿口径 |
| 回切/降级 | 短暂二次抖动 | 避免在同一时段做运营活动 |
“禁止重复提交”不是技术细节,是产品契约。切换期间玩家点了三次购买,恢复后扣了三次款,技术上是“请求重试”,玩家感知是“游戏抢钱”。幂等键必须从客户端发起时就有,参见对账、补偿与定时任务 。
可运行片段:切换状态机
internal/failoverplan 把切换拆成两个可测试的部分。plan.go 是单方向状态机:健康 → 怀疑 → fencing → 提升 → 活跃,回切必须显式触发;非法迁移返回错误而不是被悄悄纠正。它把超时也做成输入(Tick),因此测试不依赖真实时钟。
// file: internal/failoverplan/plan.go
// tier: L1
// verify: go test ./internal/failoverplan/
package failoverplan
import (
"errors"
"fmt"
"time"
)
// State 是切换状态机的对外状态,必须能被值班同学一眼读懂。
type State string
const (
Healthy State = "healthy"
Suspect State = "suspect"
Fencing State = "fencing"
Promoting State = "promoting"
Active State = "active"
Standby State = "standby"
)
// EventKind 是驱动状态机的外部事实,不是“操作按钮”。
type EventKind string
const (
HeartbeatMiss EventKind = "heartbeat_miss"
HeartbeatOK EventKind = "heartbeat_ok"
VoteGranted EventKind = "vote_granted"
VoteDenied EventKind = "vote_denied"
FenceDone EventKind = "fence_done"
FenceFailed EventKind = "fence_failed"
PromoteDone EventKind = "promote_done"
PromoteFailed EventKind = "promote_failed"
Demote EventKind = "demote"
)
// Config 是切换的判定参数;默认值让零值配置也能工作。
type Config struct {
MissThreshold int
Quorum int
FenceTimeout time.Duration
PromoteTimeout time.Duration
}
func (c Config) withDefaults() Config {
if c.MissThreshold <= 0 {
c.MissThreshold = 3
}
if c.Quorum <= 0 {
c.Quorum = 1
}
if c.FenceTimeout <= 0 {
c.FenceTimeout = 10 * time.Second
}
if c.PromoteTimeout <= 0 {
c.PromoteTimeout = 60 * time.Second
}
return c
}
// Event 带时间戳,超时判定不依赖真实时钟,测试可以完全复现。
type Event struct {
Kind EventKind
At time.Duration
}
var (
ErrTransition = errors.New("invalid transition")
ErrTimeout = errors.New("phase timeout")
)
// Machine 是单方向的切换状态机:健康 → 怀疑 → fencing → 提升。
// 回切由运维显式 Demote,不自动发生。
type Machine struct {
cfg Config
state State
misses int
votes int
epoch int64
deadline time.Duration
}
func New(cfg Config) *Machine {
return &Machine{cfg: cfg.withDefaults(), state: Healthy}
}
func (m *Machine) State() State { return m.state }
// Epoch 是 fencing token 的当前值;每次成功 fencing 后递增。
func (m *Machine) Epoch() int64 { return m.epoch }
// Apply 消费一个外部事实并推进状态机。非法迁移返回 ErrTransition,
// 由调用方决定是告警还是丢弃,状态机本身不做隐式纠正。
func (m *Machine) Apply(ev Event) (State, error) {
switch ev.Kind {
case HeartbeatMiss:
switch m.state {
case Healthy:
m.misses++
if m.misses >= m.cfg.MissThreshold {
m.state = Suspect
m.votes = 0
}
case Suspect:
// 已经怀疑,继续计数没有额外语义,保持原状。
default:
return m.state, fmt.Errorf("%w: heartbeat_miss in %s", ErrTransition, m.state)
}
case HeartbeatOK:
if m.state != Healthy && m.state != Suspect {
return m.state, fmt.Errorf("%w: heartbeat_ok in %s", ErrTransition, m.state)
}
m.state = Healthy
m.misses = 0
m.votes = 0
case VoteGranted:
if m.state != Suspect {
return m.state, fmt.Errorf("%w: vote_granted in %s", ErrTransition, m.state)
}
m.votes++
if m.votes >= m.cfg.Quorum {
m.state = Fencing
m.deadline = ev.At + m.cfg.FenceTimeout
}
case VoteDenied:
if m.state != Suspect {
return m.state, fmt.Errorf("%w: vote_denied in %s", ErrTransition, m.state)
}
m.votes = 0
case FenceDone:
if m.state != Fencing {
return m.state, fmt.Errorf("%w: fence_done in %s", ErrTransition, m.state)
}
m.epoch++
m.state = Promoting
m.deadline = ev.At + m.cfg.PromoteTimeout
case FenceFailed:
if m.state != Fencing {
return m.state, fmt.Errorf("%w: fence_failed in %s", ErrTransition, m.state)
}
m.state = Suspect
m.votes = 0
case PromoteDone:
if m.state != Promoting {
return m.state, fmt.Errorf("%w: promote_done in %s", ErrTransition, m.state)
}
m.state = Active
m.deadline = 0
case PromoteFailed:
if m.state != Promoting {
return m.state, fmt.Errorf("%w: promote_failed in %s", ErrTransition, m.state)
}
m.state = Standby
m.deadline = 0
case Demote:
if m.state != Active {
return m.state, fmt.Errorf("%w: demote in %s", ErrTransition, m.state)
}
m.state = Standby
default:
return m.state, fmt.Errorf("%w: unknown event %q", ErrTransition, ev.Kind)
}
return m.state, nil
}
// Tick 检查当前阶段是否超时;超时后放弃本次切换,回到 Suspect 重新取证。
// 注意:Active 状态没有超时。
func (m *Machine) Tick(now time.Duration) (State, error) {
if m.deadline == 0 || now < m.deadline {
return m.state, nil
}
switch m.state {
case Fencing, Promoting:
m.state = Suspect
m.votes = 0
m.deadline = 0
return m.state, ErrTimeout
default:
return m.state, nil
}
}
同一包的 fence.go 提供仲裁与数据面拦截:Decide 在剩余票数不足以凑齐多数派时立即放弃,避免无限等待;Guard.Write 只接受当前 epoch 的写入,旧 token 返回 ErrFenced,超前 token 返回 ErrNotIssued。状态机负责“什么时候切”,Guard 负责“切了以后旧主写不进来”。
本机验证命令与结果:
cd .local/checks/gsarch-lab
gofmt -l internal/failoverplan
go vet ./internal/failoverplan/
go test -count=1 ./internal/failoverplan/
gofmt -l 无输出,go vet 退出码 0,go test 输出 ok gsarchlab/internal/failoverplan,退出码 0。该包有 5 个表驱动测试,覆盖正常切换链路、非法迁移、fencing 与提升超时、仲裁判定和旧主写入拦截。
常见错误
错误一:把检测当决策。 心跳丢了就直接提升,网络抖动会变成一次真实切换。检测只产生怀疑,仲裁才产生资格。
错误二:没有 fencing 就提升。 旧主只是“看起来挂了”。只要它还能写一次,就可能产生两份互相冲突的账。
错误三:双活不做冲突模型。 两边都能写,却没有定义“同一联盟同一时刻被两边修改”以谁为准。冲突不会消失,只会推迟到对账时爆发。
错误四:回切当默认动作。 故障主恢复后直接挂回,等于把旧数据带回主路径。回切应该是需要论证的例外,不是自动流程。
错误五:切换窗口没有产品口径。 技术团队在切库,运营在发活动,玩家在重复点击。切换预案必须包含公告、补偿与操作冻结。
小结
主备买的是简单与可控,双活买的是可用性与资源利用率,多活买的是区域级容灾,代价依次上升。游戏系统要逐个判断:状态能否重建、是否允许两个写入者、回档是否可解释。切换的四个步骤里,检测只产生怀疑,仲裁产生资格,fencing 保证旧主写不进来,回切则应是例外而非常态。异步复制下切换一定丢数据,丢多少要在目标链路上量,并把玩家侧的表现写进产品契约。
下一节进入有状态服务:房间与世界状态怎么从快照和事件日志里恢复,重连如何补偿,重复结算如何拦截。
阅读导航:上一节:8.1 备份、恢复与演练 · 下一节:8.3 有状态服务的灾难恢复 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。