《后端架构设计·高性能与高可用》8.2 主备与故障转移:单区域高可用

在 arch-lab 订单系统上实现带租约和单调 fencing token 的主备接管模型,用测试覆盖主失联、租约过期、旧主恢复后写入被拒与双主脑裂,说明接管门槛为何必须包含回放位点,并给出切换窗口内订单写入的可用性与一致性取舍的具体做法。

主备先回答“谁可以写”

8.1 解决了“拓扑里有没有单点”,这一节解决单点失效后“谁来接管写”。站内 高可用与容错架构 已经讲过主备、双活和故障转移的通用模式,本节不重复定义,只把 arch-lab 订单写路径的接管条件落成可运行模型:租约怎么续、Token 怎么单调递增、旧主恢复后为什么写不进去、脑裂窗口里到底丢不丢数据。

主备的核心不是“备库启动”,而是“同一时刻最多一个写 owner”。如果只靠健康检查选主,两个实例都可能认为自己健康;如果只靠数据库自增 ID,旧主恢复后仍可能写入。订单系统必须把“所有权”变成一个可验证、可过期、可传递到下游的凭证。

租约、Token 与写入栅栏

租约由单区域内的仲裁点发放,包含持有者、过期时间和单调递增的 Token。租约有效期内其他候选者不能抢占;租约到期后,新候选者拿到更大的 Token。Token 不是随机数,必须严格递增,否则下游无法判断新旧。

写入栅栏放在下游存储:它记住见过的最大 Token,拒绝更小的 Token。这里有一个容易被测试抓到的细节:新主只拿到 Token 还不够,必须在接管写流量前先向下游登记栅栏(Fence)。否则旧主的延迟写入可能先于新主到达,下游还没见过更大的 Token,就无法拒绝它。真实系统里这一步是一次带新 Token 的写操作、锁升级或 epoch 登记。

租约模型实现

Store 用内存模型表达仲裁点的三个动作:Acquire 只在租约空闲或过期时成功;Renew 必须同时匹配持有者和 Token,且当前时间早于过期时间;Release 只有旧 Token 匹配时才生效,迟到的释放不会清掉新主的租约。FencedWriter 负责下游栅栏,CanTakeover 把“租约有效 + 回放追平”作为接管门槛。

// file: internal/leader/lease.go
// tier: L1
// verify: go test ./internal/leader/
package leader

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

var (
	ErrLead        = errors.New("not leader")
	ErrLeaseExpiry = errors.New("lease expired")
	ErrFenced      = errors.New("fenced by newer token")
	ErrLagging     = errors.New("replica lagging")
)

// Lease 是单区域内的写所有权凭证。Token 单调递增,旧主的 Token
// 永远小于新主,因此下游可以用它拒绝过期写入。
type Lease struct {
	Holder    string
	Token     uint64
	ExpiresAt time.Time
}

// Store 是租约仲裁点。生产上它由 etcd、Consul 或数据库行实现;
// 这里用内存模型表达同样的“到期前不可抢占、接管必换 Token”语义。
type Store struct {
	mu        sync.Mutex
	current   Lease
	lastToken uint64
}

func (s *Store) Acquire(holder string, now time.Time, ttl time.Duration) (Lease, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.current.Holder != "" && now.Before(s.current.ExpiresAt) {
		return s.current, false
	}
	s.lastToken++
	s.current = Lease{Holder: holder, Token: s.lastToken, ExpiresAt: now.Add(ttl)}
	return s.current, true
}

func (s *Store) Renew(holder string, token uint64, now time.Time, ttl time.Duration) (Lease, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.current.Holder != holder || s.current.Token != token {
		return Lease{}, ErrFenced
	}
	if !now.Before(s.current.ExpiresAt) {
		return Lease{}, ErrLeaseExpiry
	}
	s.current.ExpiresAt = now.Add(ttl)
	return s.current, nil
}

func (s *Store) Release(holder string, token uint64) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.current.Holder == holder && s.current.Token == token {
		s.current = Lease{}
	}
}

func (s *Store) Current() Lease {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.current
}

// FencedWriter 模拟下游存储的写入栅栏:记住见过的最大 Token,
// 任何更小 Token 的写入都拒绝。旧主恢复后即使仍自认为主,
// 也无法越过这道栅栏。
type FencedWriter struct {
	mu        sync.Mutex
	lastToken uint64
	applied   int
}

// Fence 由新主在接管写流量前调用,把新 Token 登记到下游。
// 没有这一步,下游在看到更高 Token 之前无法区分新旧主。
func (w *FencedWriter) Fence(token uint64) error {
	w.mu.Lock()
	defer w.mu.Unlock()
	if token < w.lastToken {
		return fmt.Errorf("%w: fence %d below %d", ErrFenced, token, w.lastToken)
	}
	w.lastToken = token
	return nil
}

func (w *FencedWriter) Write(token uint64, payload string) error {
	w.mu.Lock()
	defer w.mu.Unlock()
	if token < w.lastToken {
		return fmt.Errorf("%w: got %d, last %d", ErrFenced, token, w.lastToken)
	}
	w.lastToken = token
	w.applied++
	return nil
}

func (w *FencedWriter) Applied() int {
	w.mu.Lock()
	defer w.mu.Unlock()
	return w.applied
}

// Replica 是备库的本地视图。Applied 是已回放的日志位点,
// Committed 是已知主库提交位点。
type Replica struct {
	Applied   uint64
	Committed uint64
}

// CanTakeover 只检查“接管所必需”的两个条件:租约仍有效,
// 且本地回放已追平已知提交位点。进程健康、端口可连不在此列。
func CanTakeover(l Lease, now time.Time, r Replica) error {
	if l.Holder == "" || !now.Before(l.ExpiresAt) {
		return ErrLeaseExpiry
	}
	if r.Applied < r.Committed {
		return fmt.Errorf("%w: applied=%d committed=%d", ErrLagging, r.Applied, r.Committed)
	}
	return nil
}

健康检查通过不等于可以接管

进程健康只说明“还能响应探针”,不说明“有资格写”。至少还有四个门槛:仲裁点认为租约属于你;本地回放追平了已知提交位点;下游已经登记了你的 Token;配置和 schema 版本与主库兼容。前两个由 CanTakeover 表达,第三个由 Fence 完成,第四个属于发布流程。

最容易出事故的是第二个:备库进程健康、端口可连、只读查询也正常,但复制线程落后几秒。此时让它接管,会把已经提交的订单读成“不存在”,随后又接受同一订单的新写入,制造状态回退。健康检查不能替代位点比较。

故障场景测试

测试用固定时钟覆盖四个场景:主在租约内续约成功;到期瞬间续约失败且到期前备库抢不到;备库在到期后拿到 Token 2;旧主恢复后用 Token 1 写入被栅栏拒绝。第二个测试验证接管门槛:回放落后时拒绝,追平后允许,租约过期后即使追平也拒绝。

脑裂窗口用“新旧主同时写”表达:旧主写 Token 1 失败,新主写 Token 2 成功,最终只落两条新主写入。测试还验证旧主迟到的 Release 不会清掉新主租约。

// file: internal/leader/lease_test.go
// tier: L1
// verify: go test -v ./internal/leader/
package leader

import (
	"errors"
	"testing"
	"time"
)

var base = time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)

func TestLeaseFailoverAndFencing(t *testing.T) {
	const ttl = 5 * time.Second
	store := &Store{}
	writer := &FencedWriter{}

	a, ok := store.Acquire("order-a", base, ttl)
	if !ok || a.Token != 1 || !a.ExpiresAt.Equal(base.Add(ttl)) {
		t.Fatalf("acquire a: %+v ok=%v", a, ok)
	}

	// 主在到期前续约,租约窗口向后滑动。
	renewed, err := store.Renew("order-a", a.Token, base.Add(3*time.Second), ttl)
	if err != nil || !renewed.ExpiresAt.Equal(base.Add(8*time.Second)) {
		t.Fatalf("renew: %+v err=%v", renewed, err)
	}

	// 到期瞬间旧主续约失败;到期前备库抢不到租约。
	if _, err := store.Renew("order-a", a.Token, base.Add(8*time.Second), ttl); !errors.Is(err, ErrLeaseExpiry) {
		t.Fatalf("expired renew err=%v", err)
	}
	if _, ok := store.Acquire("order-b", base.Add(7*time.Second), ttl); ok {
		t.Fatal("lease must not be stolen before expiry")
	}
	b, ok := store.Acquire("order-b", base.Add(8*time.Second), ttl)
	if !ok || b.Token != 2 || b.Holder != "order-b" {
		t.Fatalf("acquire b: %+v ok=%v", b, ok)
	}

	// 新主接管写流量前先登记栅栏;旧主恢复后仍用 Token 1 写,被拒绝。
	if err := writer.Fence(b.Token); err != nil {
		t.Fatalf("fence: %v", err)
	}
	if err := writer.Write(a.Token, "ship order-1"); !errors.Is(err, ErrFenced) {
		t.Fatalf("old leader write err=%v", err)
	}
	if err := writer.Write(b.Token, "ship order-1"); err != nil {
		t.Fatalf("new leader write err=%v", err)
	}

	// 脑裂窗口:旧主和新主同时写入,只有新 Token 能落地。
	if err := writer.Write(a.Token, "ship order-2"); !errors.Is(err, ErrFenced) {
		t.Fatalf("split brain old write err=%v", err)
	}
	if err := writer.Write(b.Token, "ship order-2"); err != nil {
		t.Fatalf("split brain new write err=%v", err)
	}
	if writer.Applied() != 2 {
		t.Fatalf("applied=%d want 2", writer.Applied())
	}

	// 旧主迟到的 Release 不能清掉新主的租约。
	store.Release("order-a", a.Token)
	if cur := store.Current(); cur.Holder != "order-b" || cur.Token != 2 {
		t.Fatalf("current lease=%+v", cur)
	}
	t.Logf("old_token=%d old_expiry=%s new_token=%d new_expiry=%s fenced_old_writes=2 applied=%d",
		a.Token, a.ExpiresAt.Format(time.RFC3339), b.Token, b.ExpiresAt.Format(time.RFC3339), writer.Applied())
}

func TestTakeoverGate(t *testing.T) {
	store := &Store{}
	l, ok := store.Acquire("order-b", base, 5*time.Second)
	if !ok {
		t.Fatal("acquire failed")
	}

	// 健康检查通过、租约在手,但回放落后:不能接管写流量。
	if err := CanTakeover(l, base.Add(time.Second), Replica{Applied: 99, Committed: 100}); !errors.Is(err, ErrLagging) {
		t.Fatalf("lagging takeover err=%v", err)
	}
	if err := CanTakeover(l, base.Add(time.Second), Replica{Applied: 100, Committed: 100}); err != nil {
		t.Fatalf("caught up takeover err=%v", err)
	}
	// 回放追平但租约过期:同样不能服务。
	if err := CanTakeover(l, base.Add(5*time.Second), Replica{Applied: 100, Committed: 100}); !errors.Is(err, ErrLeaseExpiry) {
		t.Fatalf("expired takeover err=%v", err)
	}
	t.Logf("lagging_rejected=true caught_up_accepted=true expired_rejected=true")
}

真实运行结果

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),命令 go test -v ./... -count=1,运行 1 次;原始输出在 .local/reports/be-arch/be-arch-perf-08-2-active-standby-failover-tests.txt。go test -race ./... 同样通过。

=== RUN   TestLeaseFailoverAndFencing
    lease_test.go:66: old_token=1 old_expiry=2026-10-19T12:00:05Z new_token=2 new_expiry=2026-10-19T12:00:13Z fenced_old_writes=2 applied=2
--- PASS: TestLeaseFailoverAndFencing (0.00s)
=== RUN   TestTakeoverGate
    lease_test.go:88: lagging_rejected=true caught_up_accepted=true expired_rejected=true
--- PASS: TestTakeoverGate (0.00s)
PASS
ok  	archlab/internal/leader	0.430s

日志里的时间是测试时钟,不是墙钟耗时:旧租约在 12:00:05Z 到期,新主在 12:00:08Z 接管并拿到 Token 2,旧主两次写入都被栅栏拒绝,最终只应用新主的两条写入。切换窗口的长度由租约 TTL、续约失败检测、候选者重试间隔和回放追平时间相加,测试只验证了逻辑顺序,没有测真实网络延迟。

切换窗口的取舍

订单写路径选择 CP:拿不到有效租约时返回可重试的 LEADER_UNAVAILABLE,不在本地排队,也不降级为“先写进去再说”。代价是切换窗口内订单创建和支付确认会失败;收益是同一个订单不会被两个 owner 写出两个版本。窗口长度是示例参数:TTL 5 秒、每 1 秒续约、候选者每 500 毫秒重试、回放追平预期小于 1 秒,合计约 6.5 秒;真实值必须用演练日志校准。

读路径可以放宽:备库可以服务商品、订单详情等允许短暂陈旧的查询,但要带“可能落后”的标记;支付状态这种不能读旧值的查询仍回主库。如果业务要求切换窗口内写入不失败,可选方案是客户端排队重试、跨区多活写或同步双写,三者都会引入新的冲突或延迟代价,不能在单区域主备里假装解决。

数据侧的取舍是 RPO 与写延迟:同步复制能把已提交订单带到备库,但每次写多一次跨区往返;异步复制延迟低,切换时可能丢掉最后一段日志。arch-lab 对订单表选择同区域同步副本,RPO 目标为 0(示例目标),代价是写 P99 增加一次网络往返,具体数字必须用压测确认,不能引用通用常数。

arch-lab 参数与演练

参数只是起点,不是承诺:租约 TTL 5 秒,续约间隔 1 秒,接管重试 500 毫秒,CanTakeover 要求 Applied >= Committed,新主接管前必须 Fence。演练至少覆盖:主进程被 kill、主与仲裁点网络隔离、备库复制落后、旧主恢复后继续写、仲裁点不可用。每次演练记录切换窗口、被拒绝的写请求数、是否出现旧 Token 写入。

退出自动切换的条件也要写死:演练窗口超过示例目标 30 秒、出现一次旧 Token 写入成功、或回放位点无法在窗口内追平,就退回人工切换。人工切换不是失败,是在自动化还没被证明可靠前的正确选择。

常见错误与边界

第一,只比较健康检查结果。两个实例都健康时,必须由仲裁点决定 owner;健康检查只适合摘流量,不适合选主。

第二,Token 非单调或每次续约都换 Token。Token 必须在每次所有权变更时递增,续约保持同一个 Token;否则下游栅栏会把正常写入当成旧主。

第三,新主拿到租约就直接服务。没有 Fence、没有回放追平,旧主的延迟写入仍可能落地;接管门槛必须同时包含租约、位点和栅栏。

第四,旧主恢复后先释放租约再检查。迟到的 Release 可能清掉新主租约;释放必须匹配持有者和 Token,恢复后的第一件事是确认自己不是 owner。

小结

单区域主备的关键是把“谁可以写”变成租约、单调 Token 和下游栅栏三件套。本节实现的模型覆盖了主失联、租约过期、旧主恢复写入被拒和双主脑裂;测试还抓到一个真实细节:新主必须先登记栅栏,否则旧主的延迟写入可能抢先落地。订单写路径选择 CP,接受切换窗口内的可重试失败,换取同一订单不被两个 owner 写坏;读路径允许有界陈旧。下一节讲健康检查与流量摘除,回答“实例还活着,但该不该继续接流量”。

阅读导航:上一节:8.1 冗余、故障域与单点消除 · 下一节:8.3 健康检查与流量摘除 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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