《游戏服务端编程实践》14.3 状态恢复与副本同步

从主备、主从与多副本的差异讲起,比较同步、异步与半同步复制的延迟和一致性取舍,说明检查点与日志复制、落后副本追赶限流、切换时的脑裂与 fencing,给出 RPO/RTO 的游戏场景口径,并附一个可测试的主备日志复制实现与完整的失败场景分析。

一、状态副本解决的是“故障后还能继续”

游戏服务端的状态至少有三类:角色存档、背包与订单等持久数据;房间、副本与战斗过程等在线状态;匹配队列、排行榜与公会列表等聚合视图。三类状态的丢失代价不同,但故障时的目标相同:在可接受的时间内恢复到可接受的数据点。这句话拆开就是 RPO 与 RTO,前者决定最多丢多少数据,后者决定多久恢复服务。

状态类型例子丢失代价常见恢复手段
交易与订单充值、商城扣款、拍卖成交极高,涉及资金与审计同步复制、事务日志、幂等重放
角色存档等级、背包、任务进度高,玩家可感知回档检查点加增量日志,秒级 RPO
在线房间房间成员、战斗帧、临时 buff中,可重建或让玩家重连副本内主备、状态快照、快速重建

二、主备、主从与多副本

主备(active-passive)是同一时刻只有一个节点接收写请求,备节点持续接收日志但不对外服务。主从(active-read replicas)是主节点接收写,从节点分担读。多副本通常指三副本以上的共识组,写请求需要多数派确认,任意少数派故障都不影响可用性。

模式写路径读扩展故障切换主要风险
主备只写主,备节点被动接收备节点通常不读提升备节点并切换路由备节点落后、切换期间不可写
主从只写主,从节点可读可以横向扩展读选一个从节点提升读写延迟差、从节点读到旧数据
多副本多数派确认后提交可从任意副本读自动选主实现复杂、跨机房延迟高

三、同步复制、异步复制与半同步复制

同步复制要求主节点收到至少一个备节点的确认后才向客户端返回成功。异步复制是主节点本地写完就返回,后台再把日志推给备节点。半同步介于两者之间:备节点把日志写到操作系统缓冲或落盘后即确认,但不等待所有备节点。

模式客户端确认时机典型延迟故障丢失窗口适用场景
同步复制多数派或指定备节点确认后受最慢副本影响已确认写入不丢交易、订单、关键资产
半同步一个备节点接收后中等未落盘部分可能丢角色存档、公会数据
异步复制主节点本地完成后最低复制滞后期间的数据排行榜、日志、可重算视图

3.1 “异步复制零丢失”是错误承诺

异步复制的 RPO 等于主节点最后一次成功推送之后产生的数据量。只要主节点在推送前崩溃,这部分数据就不在任何备节点上。把异步复制包装成“实时同步”“零丢失”,本质上是在赌主节点不会在两次推送之间故障。线上真正能承诺的,是“在复制正常时延迟小于某个值”,而不是“任何时刻都零丢失”。

半同步也只保证“日志已经被某个备节点接收”,不保证“已经落盘”。如果备节点把日志放在页缓存里就确认,随后整机断电,仍然可能丢。需要零丢失时,确认必须发生在持久化之后,并且要明确是单副本落盘还是多数派落盘;确认口径不写清楚,半同步就会变成“看起来更可靠的异步”。

四、检查点与日志复制

日志复制负责增量,检查点负责基线。新副本加入或落后太多时,如果从第一条日志开始重放,恢复时间会随运行时长线性增长;检查点把某个时刻的完整状态固化下来,副本只需加载检查点,再重放其后的日志。

检查点策略恢复时间对主节点影响适用情况
每小时全量最长一小时日志重放短时 CPU 与 I/O 峰值状态较小、恢复不敏感
每十分钟增量十分钟日志重放持续少量写入大多数在线服务
不做检查点日志无限增长存储持续上涨仅适合短生命周期房间

日志里的命令应当描述“发生了什么”,而不是“当前值是多少”。前者可以重放,后者只能覆盖。命令还必须幂等,或者在重放时携带版本号与去重键,否则一次重试就可能把奖励发两遍。检查点完成并确认后,旧日志才能截断;截断过早会让落后副本失去追赶依据。

五、落后副本的追赶与限流

副本落后有四种常见原因:进程重启、网络分区、磁盘变慢、切换后从旧位点继续。追赶不是“把日志全推一遍”这么简单,而是要先判断落后程度,再选择增量复制还是检查点加增量。

落后程度追赶方式风险
少量日志直接增量复制影响小,注意不要与实时复制抢带宽
中等落后提高批量、限制速率追赶流量可能压垮主节点磁盘
大量落后传输检查点再重放增量检查点传输占用带宽,需限速

限流的目标是“追赶不能让正常写入变慢”。可以给追赶单独设置带宽上限、每次复制的最大条数、以及低于实时复制的优先级。追赶任务应当在主节点负载低时加速,在写延迟上升时自动降速。没有限流的追赶,本质上是把一次副本重启变成一次主节点故障演练。

5.1 失败场景:重启后的追赶风暴

某个房间服有二十个备节点,一次发布让它们同时重启。恢复后每个备节点都从自己的旧位点开始追赶,主节点瞬间收到二十路全量日志请求。磁盘队列被打满,正常玩家的存档写入开始超时,随后客户端重试又把请求量放大。最终不是备节点追不上,而是主节点先被追赶流量压垮。

正确的处理是错峰重启、限制同时追赶的副本数量、给追赶流量设置独立配额,并在主节点写延迟超过阈值时暂停追赶。副本可以晚几分钟追上,主节点不能因为追赶而失去服务能力。

六、切换、脑裂与 fencing

切换的目标不是“把备节点变成主节点”,而是“保证同一时刻只有一个节点能写”。如果旧主节点只是网络分区,它仍然认为自己活着,客户端也可能仍然连得上它。此时新主节点已经开始接受写入,两个主节点各自写入,就是脑裂。

防护手段原理局限
多数派选主只有拿到多数票的节点才能成为主跨机房网络分区时可能失去多数派
租约主节点持有带期限的租约,过期即失效依赖时钟,时钟回拨会破坏租约
fencing 令牌每次切换签发更大的令牌,存储只接受更大令牌所有写入路径都必须检查令牌
STONITH直接关闭旧主节点需要带外管理能力,云上不一定可用
路由摘除把旧主节点从服务发现中移除传播有延迟,旧连接仍可能到达

最稳妥的组合是“选主 + fencing + 路由摘除”。选主决定谁来写,fencing 保证旧主即使还活着也写不进去,路由摘除减少客户端继续连到旧主的概率。三者缺一,都会留下脑裂窗口。

6.1 失败场景:两个主节点同时扣库存

某活动服的主节点与备节点之间网络抖动,健康检查误判主节点失联,备节点被提升。旧主节点的客户端连接没有断开,仍然向旧主发送购买请求;新主节点也在处理新连接。两个主节点各自扣减同一份库存,最后超卖。事后看日志,两条写入路径都“成功”了,因为库存服务没有校验 fencing 令牌。

修复不是“让健康检查更灵敏”,而是让库存写入携带令牌,并拒绝小于已见最大值的令牌。切换越快,越需要 fencing;否则只是把故障从“不可用”换成了“数据错乱”。

七、RPO/RTO 在游戏场景的口径

RPO 和 RTO 必须落到具体业务,而不是抽象成“高可用”。对玩家来说,“丢多少秒的存档”比“几个副本”更容易理解;对运营来说,“故障后多久能继续充值”比“切换耗时”更关键。

数据类别可接受 RPO(示例值)目标 RTO(示例值)主要手段
充值订单0分钟级同步复制、事务日志、对账
角色存档数秒分钟级半同步或同步、检查点
背包与资产数秒分钟级幂等命令、版本号、防重放
在线房间可重建秒级到分钟级主备、快速重建、客户端重连

RTO 不只是一次选主的时间。它还包括故障检测、提升备节点、更新路由、客户端重连、缓存预热。很多团队只统计“选主用了两秒”,却忽略了服务发现传播和重连风暴,真实恢复时间可能是分钟级。压测时要按完整链路计时,而不是只测数据库切换。

八、可运行实现:主备日志复制与 fencing

下面的实现刻意保留三个关键点。第一,写入必须同时满足“当前是主节点”和“令牌等于集群当前 epoch”,旧主节点在切换后写入会得到 ErrFenced。第二,ReplicateTo 接受每次最多复制的条数,调用方可以据此给落后副本限流。第三,Failover 选择日志最完整的副本,并且不降级旧主节点,从而真实模拟网络分区下的脑裂风险。

// file: ch14/replica/replica.go
// tier: L1
// verify: go test ./ch14/replica/

package replica

import (
	"errors"
	"sort"
	"sync"
)

var (
	ErrNotPrimary     = errors.New("replica: not primary")
	ErrFenced         = errors.New("replica: stale fencing token")
	ErrUnknownReplica = errors.New("replica: unknown replica")
	ErrNoCandidate    = errors.New("replica: no failover candidate")
)

// Entry 是一条复制日志,Index 从 1 连续递增,Term 在切换时递增。
type Entry struct {
	Index   uint64
	Term    uint64
	Command string
}

// View 是一次加锁读取出的副本快照。
type View struct {
	Epoch   uint64
	Log     []Entry
	Commit  uint64
	Applied []string
}

type Replica struct {
	primary bool
	term    uint64
	epoch   uint64
	log     []Entry
	commit  uint64
	applied []string
}

// Cluster 用单调递增的 epoch 作为 fencing 令牌:旧主节点持有的令牌
// 与新主节点不同,写入会被拒绝。
type Cluster struct {
	mu      sync.Mutex
	epoch   uint64
	term    uint64
	primary string
	nodes   map[string]*Replica
	order   []string
}

func NewCluster(names ...string) *Cluster {
	c := &Cluster{
		nodes: make(map[string]*Replica, len(names)),
		order: append([]string(nil), names...),
	}
	for _, name := range names {
		c.nodes[name] = &Replica{}
	}
	return c
}

// PrimaryName 返回当前主节点名称,空字符串表示尚未选主。
func (c *Cluster) PrimaryName() string {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.primary
}

// View 返回指定副本的快照,第二个返回值表示副本是否存在。
func (c *Cluster) View(name string) (View, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	r, ok := c.nodes[name]
	if !ok {
		return View{}, false
	}
	return View{
		Epoch:   r.epoch,
		Log:     append([]Entry(nil), r.log...),
		Commit:  r.commit,
		Applied: append([]string(nil), r.applied...),
	}, true
}

// Elect 把指定副本提升为主节点并签发新令牌。
func (c *Cluster) Elect(name string) (uint64, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	node, ok := c.nodes[name]
	if !ok {
		return 0, ErrUnknownReplica
	}
	c.term++
	c.epoch++
	for _, other := range c.nodes {
		other.term = c.term
		other.primary = other == node
		if other == node {
			other.epoch = c.epoch
		}
	}
	c.primary = name
	return c.epoch, nil
}

// Failover 选出日志最完整的副本接管。旧主节点不会被降级,它仍以为
// 自己是主,但令牌已经过期,写入会被拒绝。
func (c *Cluster) Failover() (string, uint64, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	chosen, best := "", 0
	for _, name := range c.order {
		if name == c.primary {
			continue
		}
		if n := len(c.nodes[name].log); chosen == "" || n > best {
			chosen, best = name, n
		}
	}
	if chosen == "" {
		return "", 0, ErrNoCandidate
	}
	c.term++
	c.epoch++
	node := c.nodes[chosen]
	node.term = c.term
	node.primary = true
	node.epoch = c.epoch
	c.primary = chosen
	return chosen, c.epoch, nil
}

// Write 向主节点写入。可选的 target 用于向指定节点写入,主要用来
// 验证旧主节点在切换后会被 fencing 拒绝。
func (c *Cluster) Write(command string, target ...string) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	name := c.primary
	if len(target) > 0 {
		name = target[0]
	}
	node, ok := c.nodes[name]
	if !ok {
		return ErrUnknownReplica
	}
	if !node.primary {
		return ErrNotPrimary
	}
	if node.epoch != c.epoch {
		return ErrFenced
	}
	node.log = append(node.log, Entry{
		Index:   uint64(len(node.log)) + 1,
		Term:    node.term,
		Command: command,
	})
	return nil
}

func matchedLog(a, b []Entry) int {
	n := 0
	for n < len(a) && n < len(b) && a[n].Index == b[n].Index && a[n].Term == b[n].Term {
		n++
	}
	return n
}

// ReplicateTo 把主节点日志复制到指定副本,最多复制 max 条;
// max <= 0 表示不限制,返回本次复制的条数以便调用方限流。
func (c *Cluster) ReplicateTo(name string, max int) (int, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.primary == "" {
		return 0, ErrNotPrimary
	}
	target, ok := c.nodes[name]
	if !ok {
		return 0, ErrUnknownReplica
	}
	if name == c.primary {
		return 0, nil
	}
	primaryLog := c.nodes[c.primary].log
	common := matchedLog(target.log, primaryLog)
	missing := len(primaryLog) - common
	if max > 0 && missing > max {
		missing = max
	}
	target.log = append([]Entry(nil), primaryLog[:common+missing]...)
	if target.commit > uint64(len(target.log)) {
		target.commit = uint64(len(target.log))
	}
	c.advanceCommitLocked()
	return missing, nil
}

func (c *Cluster) advanceCommitLocked() {
	primaryLog := c.nodes[c.primary].log
	acks := make([]int, 0, len(c.nodes))
	for name, node := range c.nodes {
		if name == c.primary {
			acks = append(acks, len(primaryLog))
		} else {
			acks = append(acks, matchedLog(node.log, primaryLog))
		}
	}
	sort.Sort(sort.Reverse(sort.IntSlice(acks)))
	committed := uint64(acks[len(acks)/2])
	for _, node := range c.nodes {
		limit := committed
		if limit > uint64(len(node.log)) {
			limit = uint64(len(node.log))
		}
		for i := node.commit; i < limit; i++ {
			node.applied = append(node.applied, node.log[i].Command)
		}
		if limit > node.commit {
			node.commit = limit
		}
	}
}

实现先用 matchedLog 找到主备日志的公共前缀,截断备节点的分歧后缀,再按 max 复制缺失条目。提交位点按多数派匹配到的日志长度推进,而不是简单看日志总长度,避免把分歧条目误算成已复制。Write 的 target 参数只用于测试和运维排查,正常写入走当前主节点。

8.1 测试覆盖

测试用例覆盖五个边界:落后副本按批次追赶并在每次复制后推进提交位点;Follower 直接写入返回 ErrNotPrimary;主节点故障后选择日志最完整的副本接管,旧主节点写入返回 ErrFenced 且日志不再增长;新主节点用更高任期覆盖旧主节点的分歧日志;只有一个节点时 Failover 返回 ErrNoCandidate。

cd .local/checks/gs-practice-lab
gofmt -l ch14/replica
go vet ./ch14/replica/
go test ./ch14/replica/ -count=1

这段代码是内存实现,没有持久化、真实网络和租约过期。生产环境至少要补:日志在确认前落盘、检查点与日志截断、租约续期与时钟校验、追赶带宽配额、切换后的路由更新,以及所有写入路径对 fencing 令牌的强制校验。

九、和 14.4 的衔接

状态副本解决的是“故障后还有数据、还能切过去”,但没有回答“主动重启时如何让玩家无感”。下一篇 /game-server-in-action-14-4/ 会把视角从副本切换到进程生命周期,讨论优雅停机的顺序、会话迁移、滚动发布的容量预留,以及超时后如何强制退出并保留最后一份快照。

小结

状态恢复的核心是 RPO 与 RTO 的业务口径,而不是副本数量。主备、主从与多副本分别适合不同的状态类型;同步、异步与半同步复制在延迟和丢失窗口之间做取舍;检查点提供基线,日志提供增量;落后副本追赶必须限流,否则会把副本重启放大成主节点故障;切换必须配合 fencing,保证旧主节点即使还活着也写不进去。

常见错误与边界

  • 把异步复制宣传成零丢失,故障时才发现复制滞后区间的数据不在任何备节点上。
  • 副本重启后不限速追赶,二十路全量复制同时打到主节点,把正常写入拖垮。
  • 切换只做选主、不做 fencing,旧主节点仍然接受写入,造成双主与数据错乱。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist