上一节把数据备份做到了「能恢复到一致状态」,但备份解决的是「事后」,恢复一次动辄几十分钟。对一款在线 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 推进的任期号。
本节小结
- 多活形态按 RTO / RPO 与成本取舍,但数据层始终是单写者。 读可以多活,写必须收敛到一个点,否则就要面对无法合并的冲突。
- 切换写成状态机,每步可观测、可断言、可中断。 用转移表显式拒绝非法事件,宁可停在
aborted也不在凑不齐多数派时硬切——那正是脑裂的起点。 - 围栏是脑裂的终点,一致性检查是切换的验收。 围栏单调推进任期,让旧主的写被下游拒掉;一致性检查必须把不可达副本算进来,否则「切成功」只是错觉。
下一节把这一章的落点收束成可重复的动作:把备份恢复和故障切换编成演练剧本,定期真跑一遍,再在出事之后用时间线和改进项把经验闭环回去。
阅读导航:上一节:8.1 备份与恢复 · 下一节:8.3 演练与事故复盘 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。