《游戏服务端架构·规模化、全球化与成本》8.2 主备、双活与故障切换

《群岛纪元》的一次主库故障让全球同服停了二十分钟,《裂隙战场》的房间服则靠重建而不是切换活了下来。本节比较主备、双活与多活的代价,拆解检测、仲裁、提升、回切四步,讨论脑裂、fencing token 与数据损失边界,并用可运行的 Go 状态机演示切换判定与旧主写入拦截。

主库挂了,玩家看到什么

《群岛纪元》全球同服,账号库与联盟库在同一个区域。一次存储节点故障让主库不可写,从库虽然还在,但切换脚本没有自动执行——它需要人工确认,而值班同学正在另一个事故的电话里。二十分钟后切换完成,玩家看到的是:登录转圈、联盟聊天发不出去、已经派出的行军停在半路。数据没丢,但“服务回来”的时间由人的响应速度决定,而不是由架构决定。

《裂隙战场》的故障则是另一种形态。一台房间服宿主机宕机,上面跑着几十场对局。他们没有给房间做跨机热备,而是让客户端在超时后重新匹配、重开一局。对 5v5 竞技来说,一局十几分钟,重开的体验损失小于维护一套房间热备的成本。同一个“故障切换”问题,在两个品类里有完全不同的答案。

《泡泡工坊》的大厅是无状态的,前面挂负载均衡,坏一台摘一台,玩家几乎无感。真正需要保护的是支付与账号,而这两块的数据量小、写入频率低,用主备加半同步复制就够。

这一节讨论:主备、双活、多活各自买到了什么,切换的四个步骤分别会出什么错,以及为什么“旧主还在写”是切换里最危险的一分钟。

三种形态,三种代价

形态平时资源RTO(示例目标)RPO(示例目标)主要代价
主备备机空闲或只读1–5 分钟秒级到分钟级备机利用率低,切换要演练
双活两边都承载流量秒级到分钟级取决于同步方式冲突解决、数据归属复杂
多活多区域同时服务区域级故障接近无感跨区同步延迟成本最高,一致性最难

“双活”经常被当成一个可以买的开关,但它首先是一个数据问题:同一份数据允许两边同时写吗?如果允许,冲突怎么解?如果不允许,那所谓双活只是“读双活、写单点”,切换时仍然要选主。

游戏里真正适合双活的系统比想象中少。登录票据、匹配队列、聊天、观战分发这类“请求可路由、状态可重建”的服务适合双活;账号资产、订单、联盟占领关系这类“必须只有一个权威写入者”的数据,更适合主备或单写多读。把后者硬做成双活,最后一定要在应用层做冲突合并,而冲突合并的规则往往比故障本身更难维护。

游戏系统逐个看

系统推荐形态理由切换时玩家看到
登录与账号主备 + 半同步写少、强一致、不可冲突短暂无法登录
支付与订单主备 + 独立对账资损不可接受,宁停不重下单失败,可重试
匹配队列双活或可重建队列状态可丢,重新排队即可匹配变慢,需重新排队
房间服单点 + 重建生命周期短,热备成本高于重开本局中断,重开
世界状态单写 + 异步备冲突代价高,权威必须唯一回档到最近位点
聊天与社交双活消息可重发,弱一致可接受短暂延迟或重连

这张表的用法不是照抄,而是逼团队回答两个问题:这个系统的状态能不能重建?不能重建时,能不能接受“只有一个写入者”?两个都否定,才值得为它做真正的多活。

切换四步:检测、仲裁、提升、回切

检测决定“多快开始”,也决定“会不会误切”。心跳连续丢失 N 次才判定故障,N 太小会被网络抖动触发,N 太大则 RTO 变长。示例值:3 次心跳、每次 2 秒,检测窗口约 6 秒。检测只能产生“怀疑”,不能直接提升——这是很多自研切换脚本的第一个错误。

仲裁决定“谁有资格提升”。单机脚本没有仲裁能力,网络分区时两边的脚本都会认为自己该切。至少要有奇数个投票节点,多数派同意才能进入下一步。仲裁的意义不是选出一个更聪明的节点,而是保证同一时刻最多只有一个候选主。

提升之前必须先 fencing:让旧主确定不能再写。fencing 可以是撤销旧主的存储挂载、吊销它的数据库凭证、或者在数据面校验 epoch。只发一条“你下线吧”的消息不算 fencing,因为消息可能丢、旧主可能卡住、进程可能还在跑。

回切是最容易被忽略的一步。故障主恢复后,它上面的数据可能比新主旧,也可能有未同步的写入。直接把它挂回主库会造成数据错乱。正确做法是:先以从库身份接入、追上位点、人工确认没有分叉,再决定是否回切;多数团队会选择“不回切”,让新主继续服务,把旧主降级为备。

脑裂与 fencing:旧主还在写怎么办

脑裂的定义很简单:两个节点都认为自己有写权限。它通常发生在网络分区,而不是进程崩溃——进程崩溃的节点不会继续写,网络隔离的节点会。

防线有三层,缺一不可:

  1. 仲裁层:多数派才能发起提升,少数派一侧不允许产生新主。
  2. 数据层:写入携带 fencing token(epoch),低于当前 epoch 的写入被拒绝。
  3. 运维层:切换期间冻结高风险操作(手动改库、批量补偿),避免人在两边同时动手。

第二层最容易被跳过,因为“旧主已经挂了”是大多数时候的事实。但只要有一次它没挂,前两层就决定了事故是“短暂不可用”还是“数据分叉”。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 有状态服务的灾难恢复 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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