支付回调成功,切换后订单回到待支付
支付网关回调 order-1001:PAID,主库写日志、提交、返回成功;三秒后主库所在可用区故障,运维把备库提为主库。用户再查订单,状态回到 CREATED,库存预占也已释放。支付侧有扣款,订单侧没有支付记录,对账在第二天才把差异捞出来。
直觉上的解释是“复制延迟只有几十毫秒,最多丢一两笔”。这句话有两个问题:延迟是分布,不是常数;丢不丢取决于故障瞬间备库追到了哪个位点,而不是平均延迟。另一个常见直觉是“上了半同步就不会丢”,但半同步只保证确认集合里的副本有数据,如果被提升的备库不在确认集合里,已确认的写入照样会丢。
这一节把“延迟”拆成可检查的位点,把“丢数据”换算成 RPO,并给出一张切换前必须逐项确认的清单。
滞后至少有三个口径
| 口径 | 计算 | 影响 |
|---|---|---|
| 接收滞后 | 主库 lastIndex - 备库 replicatedIndex | 还有多少日志没送到 |
| 应用滞后 | 主库 commitIndex - 备库 appliedIndex | 备库能读出多新的数据 |
| 时间滞后 | 故障时刻 - 备库最后一条日志的时间戳 | 换算成秒级 RPO |
监控只看“复制线程正常”没有意义。接收滞后为 0、应用滞后很大时,备库日志齐全但查询仍旧落后;时间滞后为 0 也可能只是这段时间没有写入。切换决策要同时看接收位点与提交位点,读流量还要额外看应用位点。
丢失窗口:未复制尾部就是 RPO
异步复制下,主库在本地提交后就向客户端确认,不等备库。故障发生时,主库日志末尾可能有一段只存在于主库的条目;提升备库后,这段尾部不存在,客户端却已经收到过成功。丢失窗口就是这段尾部的范围:
- 写丢失数 =
lastCommitted - candidateReplicated(按条目计); - 已确认丢失数 = 上述区间内客户端已收到成功的条目;
- 时间窗口 = 故障时刻 - 备库最后一条已复制条目时间戳。
| 备库已复制到 | 丢失条目 | 已确认丢失 | 说明 |
|---|---|---|---|
| 最新 | 0 | 0 | 切换无数据损失 |
| 差 1 条 | 1 | 1 | 异步下这条已被确认 |
| 差 3 条 | 3 | 3 | 丢失窗口覆盖 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 的角色、任期、日志 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。