《MMO 服务器开发》8.2 多活与故障切换

多活不是把服务复制到两个机房就完事,而要回答三个问题:切换由谁决定、旧主怎么办、切完数据对不对。本节对照主备、同城双活、异地多活三种形态,用一个可跑的故障切换状态机演示主节点宕机后的选主、流量切换、围栏旧主与一致性检查,并真跑断言新主接管、旧主被 fence。

上一节把数据备份做到了「能恢复到一致状态」,但备份解决的是「事后」,恢复一次动辄几十分钟。对一款在线 MMO 来说,主节点宕机后的黄金窗口只有几十秒——这段时间里玩家在打 Boss、在交易、在排队进副本。备份救不回这几十秒,只有故障切换能。

多活(active-active / active-passive)听起来是「多跑一份」,真正的难点却在切换的那一瞬间:谁来判定主挂了?新主怎么选?流量怎么切过去不出乱子?最危险的是脑裂(split-brain)——旧主在网络分区里「以为」自己还是主,继续接受写入,和新主各写各的,等分区恢复时两份数据已经对不上。本节把这些做成一个可跑的状态机,并给出围栏旧主与一致性检查的实现。

8.2.1 多活的三种形态:主备、同城双活、异地多活

先把名词摆正。多活的形态不是「越复杂越好」,而是按 RTO / RPO 与成本取舍:

形态写者典型 RTO典型 RPO成本适用
主备(active-passive)单写者,备待命分钟级秒~分钟低中小规模、能容忍短暂停服
同城双活单写者,备可读秒级接近 0中主力方案,机房内低延迟同步
异地多活分片各写 / 单写者秒级跨地域有损高跨地域容灾、全球同服

三种形态的共同点是:数据层始终有「单写者」——哪怕读可以多活,写也必须收敛到一个点,否则就要面对合并冲突。这就是为什么卷三讲过的 Leader 选举与所有权,在容灾里会以「故障切换」的形式再出现一次。

《晨曦大陆》的落点是同城双活 + 异地冷备:同城主备之间做同步复制,RTO 目标 30 秒;异地只做异步全量复制,接受更大的 RPO,用来兜「整个城市级故障」。这个组合在成本和风险之间最平衡。

形态决定了拓扑,但拓扑不会自己切换。真正要写的,是切换流程本身。

8.2.2 切换状态机(L1)

把「主挂了 → 恢复」拆成一条显式状态机,好处是每一步都可观测、可断言、可中断。下面这段是 L1,go test 可以直接跑,它把切换写成一张状态转移表加副作用:

// file: internal/dr/statemachine.go
// tier: L1
// verify: go test ./internal/dr/ -run TestFailover -v
package dr

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

// ErrBadTransition:状态机收到了当前状态不该有的事件。
var ErrBadTransition = errors.New("dr: invalid state transition")

// State 是故障切换状态机的状态。
type State string

const (
	StateSteady    State = "steady"    // 稳定态:主在任
	StateDetecting State = "detecting" // 检测到主异常
	StateElecting  State = "electing"  // 选主
	StateSwitching State = "switching" // 流量切换 + 围栏旧主 + 一致性检查
	StateDone      State = "done"      // 切换完成
	StateAborted   State = "aborted"   // 放弃(如失去多数派)
)

// Event 是驱动状态机的外部事件。
type Event string

const (
	EvPrimaryDown     Event = "primary_down"
	EvCandidateReady  Event = "candidate_ready"
	EvTrafficSwitched Event = "traffic_switched"
	EvConsistencyOK   Event = "consistency_ok"
	EvConsistencyFail Event = "consistency_fail"
	EvQuorumLost      Event = "quorum_lost"
)

// transitions 是状态转移表:state -> event -> next state。
var transitions = map[State]map[Event]State{
	StateSteady: {
		EvPrimaryDown: StateDetecting,
		EvQuorumLost:  StateAborted,
	},
	StateDetecting: {
		EvCandidateReady: StateElecting,
		EvQuorumLost:     StateAborted,
	},
	StateElecting: {
		EvTrafficSwitched: StateSwitching,
		EvQuorumLost:      StateAborted,
	},
	StateSwitching: {
		EvConsistencyOK:   StateDone,
		EvConsistencyFail: StateAborted,
	},
}

// TimelineEntry 记录一次状态迁移,供事故复盘使用。
type TimelineEntry struct {
	At    time.Time
	From  State
	Event Event
	To    State
}

// Controller 驱动一次故障切换。
type Controller struct {
	cluster     *Cluster
	state       State
	newPrimary  string
	consistency ConsistencyReport
	Timeline    []TimelineEntry
}

// NewController 在集群上创建一个切换控制器。
func NewController(c *Cluster) *Controller {
	return &Controller{cluster: c, state: StateSteady}
}

// State 返回当前状态;NewPrimary 返回选出的新主;Consistency 返回最近一次检查结果。
func (c *Controller) State() State                   { return c.state }
func (c *Controller) NewPrimary() string             { return c.newPrimary }
func (c *Controller) Consistency() ConsistencyReport { return c.consistency }

// Step 推进状态机。非法事件返回 ErrBadTransition,不会改变状态。
func (c *Controller) Step(ev Event) (State, error) {
	next, ok := transitions[c.state][ev]
	if !ok {
		return c.state, fmt.Errorf("%w: state=%s event=%s", ErrBadTransition, c.state, ev)
	}
	c.Timeline = append(c.Timeline, TimelineEntry{At: time.Now(), From: c.state, Event: ev, To: next})
	c.state = next
	if err := c.effect(next); err != nil {
		return c.state, err
	}
	return c.state, nil
}

// effect 执行进入某状态时的副作用。
func (c *Controller) effect(s State) error {
	switch s {
	case StateElecting:
		// 选主:挑数据最全的备节点。
		cand, err := c.cluster.bestStandby()
		if err != nil {
			return err
		}
		c.newPrimary = cand.ID
	case StateSwitching:
		// 切换:先把旧主围栏掉(挡住它醒来的写),再把流量指到新主。
		old := c.cluster.PrimaryID
		c.cluster.Fence(old)
		cand := c.cluster.Nodes[c.newPrimary]
		cand.Role = RolePrimary
		cand.Term = c.cluster.Term
		c.cluster.PrimaryID = c.newPrimary
		c.cluster.TrafficTo = c.newPrimary
		// 切换完成后立刻做一致性检查。
		c.consistency = c.cluster.CheckConsistency()
	}
	return nil
}

状态机把「切换」这件事拆成了四步,每步只做一件事:检测(确认主真的挂了,而不是一次抖动)、选主(从备里挑数据最全的那个)、切换(围栏旧主 + 改流量指向 + 跑一致性检查)、收尾或放弃(一致性通过就完成,失去多数派就放弃)。EvQuorumLost 是刻意留的:宁可切换失败停在 aborted,也不能在凑不齐多数派时硬切出一个新主——那正是脑裂的起点。

状态转移用一张表而不是一堆 if/else,好处是非法事件会被显式拒绝。在 aborted 状态再收到 candidate_ready,返回 ErrBadTransition 而不是悄悄忽略——切换流程里「悄悄忽略」是最危险的,它会让控制器以为自己在推进,实际停在原地。

8.2.3 脑裂防护与一致性检查

状态机里最容易被低估的一步是 c.cluster.Fence(old)。它做的事只有一件:把旧主标记为已围栏,并推进集群任期。下面是它的实现,以及写路径上如何拒掉旧主:

// snippet: internal/dr/cluster.go —— 围栏旧主 + 写路径拒绝
// Fence 把某个节点围栏掉,并推进集群任期。
func (c *Cluster) Fence(nodeID string) {
	c.Term++
	if n, ok := c.Nodes[nodeID]; ok {
		n.Fenced = true
		n.Role = RoleStandby
	}
}

// Write 只有未围栏、可达、且是当前主的写会被接受。
func (c *Cluster) Write(nodeID, key string, delta int64) error {
	n := c.Nodes[nodeID]
	if n.Fenced {
		return fmt.Errorf("%w: %s term=%d", ErrFenced, nodeID, n.Term)
	}
	if nodeID != c.PrimaryID {
		return fmt.Errorf("%w: %s (primary=%s)", ErrNotPrimary, nodeID, c.PrimaryID)
	}
	// ... 应用到本地并尽力复制到可达备节点
	return nil
}

围栏的关键在于它是单调的:任期号只增不减,一个节点一旦被围栏就不会因为网络恢复而「复活」成主。旧主醒来后,它的写请求会在 Write 的第一行被 ErrFenced 拒掉——哪怕它自己还以为在任上。这就是脑裂防护的全部:不靠旧主自觉,靠下游的判定。

切换后必须做一致性检查,否则「切成功了」只是错觉。检查的口径有个容易踩的坑:不可达的副本也要算进来。

// snippet: internal/dr/cluster.go —— 一致性检查:不可达副本是滞后风险,不能跳过
func (c *Cluster) CheckConsistency() ConsistencyReport {
	rep := ConsistencyReport{PrimaryID: c.PrimaryID, Versions: map[string]int64{}}
	for _, n := range c.Nodes {
		if n.Fenced {
			continue // 已围栏的旧主不参与
		}
		rep.Versions[n.ID] = sumData(n)
		if !n.Reachable {
			rep.Unreachable = append(rep.Unreachable, n.ID) // 标出来,而不是跳过
		}
	}
	// ... 以新主为基准算 MaxDiff
	rep.OK = rep.MaxDiff == 0 && len(rep.Unreachable) == 0
	return rep
}

真跑的断言结果:

=== RUN   TestFailoverTakesOverAndFencesOldPrimary
    failover_test.go:77: 新主=db-c 旧主已围栏 term=2 一致性 maxDiff=1000 掉队=[db-b]
--- PASS: TestFailoverTakesOverAndFencesOldPrimary (0.00s)
=== RUN   TestFailoverQuorumLostAborts
--- PASS: TestFailoverQuorumLostAborts (0.00s)
PASS
ok  	aegis/internal/dr	0.866s

这个用例做了五件事:写 100 笔数据(其中 db-b 掉队、数据停在旧版本)→ 注入主宕机 → 状态机选出数据最全的 db-c 作为新主 → 断言旧主 db-a 被围栏、它的写被 ErrFenced 拒绝 → 断言一致性检查报出 maxDiff=1000 且列出掉队的 db-b,而不是假装通过。第二行日志里的 term=2,就是切换时 Fence 推进的任期号。

本节小结

  1. 多活形态按 RTO / RPO 与成本取舍,但数据层始终是单写者。 读可以多活,写必须收敛到一个点,否则就要面对无法合并的冲突。
  2. 切换写成状态机,每步可观测、可断言、可中断。 用转移表显式拒绝非法事件,宁可停在 aborted 也不在凑不齐多数派时硬切——那正是脑裂的起点。
  3. 围栏是脑裂的终点,一致性检查是切换的验收。 围栏单调推进任期,让旧主的写被下游拒掉;一致性检查必须把不可达副本算进来,否则「切成功」只是错觉。

下一节把这一章的落点收束成可重复的动作:把备份恢复和故障切换编成演练剧本,定期真跑一遍,再在出事之后用时间线和改进项把经验闭环回去。

阅读导航:上一节:8.1 备份与恢复 · 下一节:8.3 演练与事故复盘 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标