《后端架构设计·分布式与数据一致性》3.3 复制延迟、故障切换与数据丢失窗口

从支付回调成功后主库宕机、切换后备库缺少最后一笔订单的场景,拆解复制滞后、提交位点与故障切换的关系;用可运行的 Go 模型计算未复制尾部如何变成 RPO,比较异步与半同步的确认集合,并给出切换前的位点检查清单,说明半同步确认集合与提升候选不一致时为何仍会丢数据。

支付回调成功,切换后订单回到待支付

支付网关回调 order-1001:PAID,主库写日志、提交、返回成功;三秒后主库所在可用区故障,运维把备库提为主库。用户再查订单,状态回到 CREATED,库存预占也已释放。支付侧有扣款,订单侧没有支付记录,对账在第二天才把差异捞出来。

直觉上的解释是“复制延迟只有几十毫秒,最多丢一两笔”。这句话有两个问题:延迟是分布,不是常数;丢不丢取决于故障瞬间备库追到了哪个位点,而不是平均延迟。另一个常见直觉是“上了半同步就不会丢”,但半同步只保证确认集合里的副本有数据,如果被提升的备库不在确认集合里,已确认的写入照样会丢。

这一节把“延迟”拆成可检查的位点,把“丢数据”换算成 RPO,并给出一张切换前必须逐项确认的清单。

滞后至少有三个口径

口径计算影响
接收滞后主库 lastIndex - 备库 replicatedIndex还有多少日志没送到
应用滞后主库 commitIndex - 备库 appliedIndex备库能读出多新的数据
时间滞后故障时刻 - 备库最后一条日志的时间戳换算成秒级 RPO

监控只看“复制线程正常”没有意义。接收滞后为 0、应用滞后很大时,备库日志齐全但查询仍旧落后;时间滞后为 0 也可能只是这段时间没有写入。切换决策要同时看接收位点与提交位点,读流量还要额外看应用位点。

丢失窗口:未复制尾部就是 RPO

异步复制下,主库在本地提交后就向客户端确认,不等备库。故障发生时,主库日志末尾可能有一段只存在于主库的条目;提升备库后,这段尾部不存在,客户端却已经收到过成功。丢失窗口就是这段尾部的范围:

  • 写丢失数 = lastCommitted - candidateReplicated(按条目计);
  • 已确认丢失数 = 上述区间内客户端已收到成功的条目;
  • 时间窗口 = 故障时刻 - 备库最后一条已复制条目时间戳。
备库已复制到丢失条目已确认丢失说明
最新00切换无数据损失
差 1 条11异步下这条已被确认
差 3 条33丢失窗口覆盖 3 次写
完全没追上全部全部新备库等于回滚到起点

这里的数字是模型里的条目数,不是生产 QPS。真实系统要按业务时间窗口统计,例如“最近 5 秒内已确认的支付回调有多少笔”,这比“复制延迟 80ms”更能决定能否切换。

异步、半同步与全同步

模式客户端确认条件切换后可能丢什么代价
异步主库本地提交全部未复制尾部延迟最低
半同步 k至少 k 个备库确认不在确认集合里的已确认写写延迟受慢备库影响
全同步全部备库确认理论上不丢已确认写任一备库故障即阻塞写
共识复制多数派持久化不丢已提交写,但需选主复杂度和延迟更高

半同步的关键不是“有几个备库”,而是“确认集合”和“可提升集合”是否一致。若确认集合是任意 k 个,而切换时按最新位点选了集合外的备库,已确认写仍会丢。正确做法是记录每次确认对应的副本集合,并只允许提升覆盖该集合的候选;这已经接近第 4 章的共识问题。

代码:把丢失窗口算出来

下面的模型把“追加”“复制”“确认”“提升”分开,避免把“主库写成功”误当成“客户端已确认”,也避免把“备库收到”误当成“可以提升”。

// file: internal/platform/lag/lag.go
// tier: L1
// verify: go test ./internal/platform/lag/ -count=1
package lag

import (
	"errors"
	"fmt"
)

var (
	ErrNoCandidate        = errors.New("no reachable failover candidate")
	ErrUnknownFollower    = errors.New("unknown follower")
	ErrPositionRegression = errors.New("replication position cannot move backwards")
	ErrPositionAhead      = errors.New("position is ahead of the primary log")
)

type Entry struct {
	Index       uint64
	Command     string
	TimestampMS int64
}

type WriteMode struct {
	SyncAcks int // 0 = asynchronous
}

type Primary struct {
	Log   []Entry
	acked map[uint64]bool
}

func (p *Primary) LastIndex() uint64 {
	if len(p.Log) == 0 {
		return 0
	}
	return p.Log[len(p.Log)-1].Index
}

func (p *Primary) Acked(index uint64) bool { return p.acked[index] }

type Follower struct {
	ID         string
	Replicated uint64
}

type Cluster struct {
	primary   Primary
	followers []*Follower
	down      map[string]bool
}

func New(n int) *Cluster {
	c := &Cluster{primary: Primary{acked: map[uint64]bool{}}, down: map[string]bool{}}
	for i := 0; i < n; i++ {
		c.followers = append(c.followers, &Follower{ID: fmt.Sprintf("f%d", i+1)})
	}
	return c
}

func (c *Cluster) SetDown(id string, down bool) { c.down[id] = down }

func (c *Cluster) Append(command string, ts int64) Entry {
	e := Entry{Index: c.primary.LastIndex() + 1, Command: command, TimestampMS: ts}
	c.primary.Log = append(c.primary.Log, e)
	return e
}

func (c *Cluster) Replicate(followerID string, upTo uint64) error {
	for _, f := range c.followers {
		if f.ID != followerID {
			continue
		}
		if upTo > c.primary.LastIndex() {
			return fmt.Errorf("%w: upTo=%d last=%d", ErrPositionAhead, upTo, c.primary.LastIndex())
		}
		if upTo < f.Replicated {
			return fmt.Errorf("%w: %s from=%d to=%d", ErrPositionRegression, f.ID, f.Replicated, upTo)
		}
		f.Replicated = upTo
		return nil
	}
	return fmt.Errorf("%w: %s", ErrUnknownFollower, followerID)
}

// Acknowledge records a client success only when the mode's ack count is met.
func (c *Cluster) Acknowledge(index uint64, mode WriteMode, acks int) bool {
	if index == 0 || index > c.primary.LastIndex() {
		return false
	}
	if mode.SyncAcks > 0 && acks < mode.SyncAcks {
		return false
	}
	c.primary.acked[index] = true
	return true
}

func (c *Cluster) Lag(followerID string) (uint64, error) {
	for _, f := range c.followers {
		if f.ID == followerID {
			return c.primary.LastIndex() - f.Replicated, nil
		}
	}
	return 0, fmt.Errorf("%w: %s", ErrUnknownFollower, followerID)
}

func (c *Cluster) BestCandidate() *Follower {
	var best *Follower
	for _, f := range c.followers {
		if c.down[f.ID] {
			continue
		}
		if best == nil || f.Replicated > best.Replicated ||
			(f.Replicated == best.Replicated && f.ID < best.ID) {
			best = f
		}
	}
	return best
}

type Report struct {
	Candidate      string
	LastCommitted  uint64
	LastReplicated uint64
	LostEntries    []Entry
	LostAcked      int
	LostWindowMS   int64
}

func (c *Cluster) Failover(atMS int64) (Report, error) {
	best := c.BestCandidate()
	if best == nil {
		return Report{}, ErrNoCandidate
	}
	r := Report{Candidate: best.ID, LastCommitted: c.primary.LastIndex(), LastReplicated: best.Replicated}
	for _, e := range c.primary.Log {
		if e.Index > best.Replicated {
			r.LostEntries = append(r.LostEntries, e)
			if c.primary.acked[e.Index] {
				r.LostAcked++
			}
		}
	}
	r.LostWindowMS = c.lossWindowMS(best.Replicated, atMS)
	return r, nil
}

func (c *Cluster) lossWindowMS(replicated uint64, atMS int64) int64 {
	if len(c.primary.Log) == 0 || replicated == c.primary.LastIndex() {
		return 0
	}
	start := c.primary.Log[0].TimestampMS
	if replicated > 0 {
		start = c.primary.Log[replicated-1].TimestampMS
	}
	if atMS < start {
		return 0
	}
	return atMS - start
}

测试分两组:异步模式下尾部全部算作已确认丢失;半同步模式下未达到确认数的写不算已确认,但确认集合成员故障后仍可能丢已确认写。

// file: internal/platform/lag/lag_test.go
// tier: L1
// verify: go test ./internal/platform/lag/ -count=1
package lag

import (
	"errors"
	"testing"
)

func TestAsyncTailBecomesRPO(t *testing.T) {
	for _, tt := range []struct {
		name     string
		repl     uint64
		lost     int
		lostAck  int
		windowMS int64
	}{
		{"fully replicated", 3, 0, 0, 0},
		{"one behind", 2, 1, 1, 20},
		{"never replicated", 0, 3, 3, 30},
	} {
		t.Run(tt.name, func(t *testing.T) {
			c := New(3)
			for i, cmd := range []string{"CREATED", "PAID", "FULFILLED"} {
				c.Append("order-1001:"+cmd, int64(i)*10)
			}
			for i := uint64(1); i <= 3; i++ {
				if !c.Acknowledge(i, WriteMode{}, 0) {
					t.Fatalf("async ack %d failed", i)
				}
			}
			if err := c.Replicate("f1", tt.repl); err != nil {
				t.Fatalf("Replicate: %v", err)
			}
			rep, err := c.Failover(30)
			if err != nil {
				t.Fatalf("Failover: %v", err)
			}
			if len(rep.LostEntries) != tt.lost || rep.LostAcked != tt.lostAck || rep.LostWindowMS != tt.windowMS {
				t.Fatalf("report = %+v, want lost=%d acked=%d window=%d", rep, tt.lost, tt.lostAck, tt.windowMS)
			}
		})
	}
}

func TestSemiSyncAckSetIsNotEnough(t *testing.T) {
	c := New(3)
	c.Append("order-1001:CREATED", 0)
	c.Append("order-1001:PAID", 10)
	if err := c.Replicate("f1", 2); err != nil {
		t.Fatalf("Replicate f1: %v", err)
	}
	if err := c.Replicate("f2", 1); err != nil {
		t.Fatalf("Replicate f2: %v", err)
	}
	if !c.Acknowledge(2, WriteMode{SyncAcks: 1}, 1) {
		t.Fatalf("entry 2 should be acknowledged")
	}
	c.SetDown("f1", true)

	rep, err := c.Failover(20)
	if err != nil {
		t.Fatalf("Failover: %v", err)
	}
	if rep.Candidate != "f2" || rep.LostAcked != 1 {
		t.Fatalf("report = %+v, want candidate f2 with one lost acked write", rep)
	}
}

func TestFailoverWithoutCandidate(t *testing.T) {
	c := New(2)
	c.SetDown("f1", true)
	c.SetDown("f2", true)
	if _, err := c.Failover(10); !errors.Is(err, ErrNoCandidate) {
		t.Fatalf("Failover error = %v, want ErrNoCandidate", err)
	}
	c.Append("order-1001:CREATED", 0)
	if err := c.Replicate("f9", 1); !errors.Is(err, ErrUnknownFollower) {
		t.Fatalf("Replicate error = %v, want ErrUnknownFollower", err)
	}
}

本机验证模块的真实输出:

$ go test ./article/lag/ -count=1
--- PASS: TestAsyncTailBecomesRPO (0.00s)
--- PASS: TestSemiSyncAckSetIsNotEnough (0.00s)
--- PASS: TestFailoverWithoutCandidate (0.00s)
PASS
ok  	archlab/article/lag	1.591s

TestAsyncTailBecomesRPO 的三个子用例分别得到丢失 0、1、3 条,窗口 0、20、30 个逻辑毫秒;one behind 同时计入 LostAcked=1,因为异步模式已经告诉客户端成功。TestSemiSyncAckSetIsNotEnough 则说明:即使写达到半同步确认数,确认集合里的 f1 故障后,提升 f2 仍会丢 1 条已确认写。

切换检查表

检查项通过条件不通过时
候选位点候选 replicatedIndex 不低于已知安全水位换候选或延迟切换
已确认写入区间内 LostAcked=0,或业务接受损失进入数据修复流程
应用位点候选已应用到提交位点先追赶再开放读
旧主库隔离旧主库不再接受写(fencing)先隔离,避免双写
客户端连接连接串、只读标记、重试预算已切换回滚或降级
对账任务切换窗口内的订单/支付差异可枚举禁止切换
回切预案旧主库追赶与新写入方向明确保持单主,不急于回切

清单的核心是“先证明丢了什么,再决定是否接受”。如果无法枚举丢失窗口内的已确认写,切换就是一次没有 RPO 定义的赌博。

常见错误与边界

第一,用平均延迟替代位点。平均值无法回答“故障瞬间备库在哪”,切换必须看 replicatedIndex 与 appliedIndex。

第二,把半同步当零丢失。确认集合与被提升候选不一致时,已确认写仍可能消失;需要记录确认集合并约束候选。

第三,切换后立即恢复全部读。候选可能只完成日志接收,尚未应用到状态机;读流量要等应用位点追平。

第四,忘记隔离旧主库。旧主库恢复后继续接受写,会与新的写入方向形成双主,冲突比丢失更难收拾。

边界在于:异步复制的 RPO 不可能为零,除非业务愿意用同步或共识复制换取写延迟。能做的只是把窗口量化、把已确认写枚举出来,并让切换决策有明确依据;更完整的容灾流程可参考多站点容灾 。

小结

复制延迟要拆成接收、应用、时间三种口径;故障切换丢的不是“平均延迟里的数据”,而是候选没有复制到的那段已提交尾部。异步复制下这段尾部通常已被客户端确认,所以 LostAcked 才是切换决策的核心指标。半同步缩小窗口,但确认集合与候选集合不一致时仍会丢;真正的零丢失需要共识复制。下一节从切换的边界进入共识:Raft 如何用任期、日志匹配和多数派提交,把“谁有资格成为新主库”变成可证明的规则。

阅读导航:上一节:多主与无主复制:冲突检测与合并 · 下一节:共识问题与 Raft 的角色、任期、日志 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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