《后端架构设计·分布式与数据一致性》4.3 成员变更、快照与线性一致读

从三节点订单集群一次性扩到五节点、出现两个不相交多数派的场景出发,说明单节点成员变更为何安全、配置条目如何提交;实现日志压缩与 InstallSnapshot 的边界保留,并用 ReadIndex 的当前任期多数派确认堵住陈旧 leader 的线性一致读。

扩容到五节点的那一夜

订单集群原本是 3 节点 {a,b,c}。大促前运维为了扛读流量,一次性把 {d,e} 两个节点加进配置,日志里出现了一条“5 节点配置”的条目。就在这条配置生效前后,网络发生分区:{a,b} 与 {c,d,e} 互不可达,但两边都能各自通信。

按 5 节点配置,{c,d,e} 是合法多数派;按旧的 3 节点配置,{a,b} 也是合法多数派。两个多数派没有交集,于是可以各自选出 leader、各自提交不同条目。等网络恢复,同一 index 上出现了两条都声称“已提交”的命令:一条扣库存,一条取消订单。

问题不在 Raft 的日志复制,而在配置本身被当成了普通数据。成员变更必须保证:任意新旧两个多数派至少有一个共同节点,否则“多数派”不再是同一个集合的多数派。

成员变更:为什么一次只动一个节点

单节点变更的核心约束是:旧配置与新配置只差一个 voter。这样任意旧多数派与任意新多数派必然相交——反证即可,若不相交,两个多数派的人数之和会超过两边各自的总人数。

变更旧多数派新多数派是否保证相交
3 → 4(加 d)23是:新多数派至多只有 2 个非旧成员
4 → 5(加 e)33是:旧配置的补集只有 1 个节点
3 → 5(一次加两个)23否:{a,b} 与 {c,d,e} 可以不相交
替换一个(先加后删)22每步单独看是安全的,但必须等第一步提交

CheckSingleChange 只允许 added+removed 等于 1,就是把这条约束写成可执行的门禁。工程上还要加一条:新节点先追上日志,再提交“把它加入配置”的条目。否则新配置一生效,多数派门槛立刻把它算进去,一个落后节点会拖慢提交,甚至让集群在只剩少数旧节点时失去活性。

配置条目本身也必须按普通日志提交,并且在旧配置下提交。只有它被应用之后,节点才切换 Config;不能在收到条目时立刻改配置,否则“未提交的配置”会参与 quorum 计算。

快照:日志不能无限长

订单事件每秒都在写,日志不可能永久保留。压缩的做法是:把 lastApplied 之前的日志丢弃,只保留一个边界条目 (Base, BaseTerm) 和状态机镜像。边界条目的 term 必须保留,因为 AppendEntries 的 prevLogTerm 校验仍可能指向它。

Compact 只允许压缩到 applied,不能压缩尚未应用的条目;InstallSnapshot 处理三种情况:快照落后于本地 base 时直接忽略;快照边界与本地同 index 同 term 时保留本地后缀;边界冲突或快照超出本地日志时,用快照替换整条日志。替换后 commit 与 applied 至少要推进到快照边界,否则状态机会重复应用或回退。

还有一个容易漏的点:快照必须携带当时的成员配置。新节点只靠快照恢复时,如果配置丢了,它会以为自己不属于任何集群,或者用错误的老配置计算多数派。

线性一致读:ReadIndex 而不是猜

leader 处理读请求时,最诱人的做法是“我是 leader,直接读本地状态机”。这在分区时是错的:旧 leader 可能还没收到更高任期的消息,本地状态已经落后,却仍然对外服务。租约读(lease read)用“心跳在租约期内一定有效”来避免每次确认,但它依赖时钟漂移有上界;跨云、虚拟机暂停、GC 长停顿都会破坏这个假设。

ReadIndex 不依赖墙上时钟。leader 收到读请求时记下当前 commitIndex,然后向所有 voter 发一轮心跳,只有收到当前任期的多数派确认后,才把该 commitIndex 作为 read index 返回;本地状态机必须 applied >= readIndex 才能响应。旧 leader 在分区里凑不齐当前任期的多数派确认,读请求失败而不是返回陈旧数据。follower 需要读时,把请求转给 leader 或走 ReadIndex 转发,不能直接读本地。

读方案额外往返依赖假设分区中的旧 leader
本地读0无(因此不安全)返回陈旧数据
租约读0时钟漂移有上界租约到期前可能返回陈旧数据
ReadIndex1 轮心跳多数派可达凑不齐确认,失败关闭
Quorum Read1 轮读多数派可达由读 quorum 拒绝

代码:配置、快照与读索引

// file: internal/platform/consensus/raftx/raftx.go
// tier: L2
// verify: go test ./internal/platform/consensus/raftx/ -count=1
package raftx

import (
	"errors"
	"fmt"
	"sort"
)

var (
	ErrBadChange   = errors.New("raftx: membership change must add or remove exactly one voter")
	ErrBrokenLog   = errors.New("raftx: broken log")
	ErrNoQuorum    = errors.New("raftx: read index needs a current-term quorum")
	ErrNotApplied  = errors.New("raftx: applied index behind read index")
	ErrSnapshotGap = errors.New("raftx: snapshot index outside compactable range")
)

// Config is one voting configuration; it is copied, never mutated in place.
type Config struct {
	Voters map[string]bool
}

func NewConfig(ids ...string) Config {
	c := Config{Voters: make(map[string]bool, len(ids))}
	for _, id := range ids {
		c.Voters[id] = true
	}
	return c
}

func (c Config) Quorum() int        { return len(c.Voters)/2 + 1 }
func (c Config) Has(id string) bool { return c.Voters[id] }

func (c Config) Clone() Config {
	out := Config{Voters: make(map[string]bool, len(c.Voters))}
	for id := range c.Voters {
		out.Voters[id] = true
	}
	return out
}

// CheckSingleChange accepts configurations that differ by exactly one voter.
// One-at-a-time changes make every old majority intersect every new majority.
func CheckSingleChange(old, next Config) error {
	added, removed := 0, 0
	for id := range next.Voters {
		if !old.Voters[id] {
			added++
		}
	}
	for id := range old.Voters {
		if !next.Voters[id] {
			removed++
		}
	}
	if added+removed != 1 {
		return fmt.Errorf("raftx: added=%d removed=%d: %w", added, removed, ErrBadChange)
	}
	if len(next.Voters) == 0 {
		return fmt.Errorf("raftx: empty configuration: %w", ErrBadChange)
	}
	return nil
}

// AdvanceCommit counts a quorum of the current config; an old-term entry is
// never committed directly, only carried by a current-term entry.
func AdvanceCommit(cfg Config, match map[string]uint64, commit uint64, termAt func(uint64) uint64, currentTerm uint64) uint64 {
	indexes := make([]uint64, 0, len(cfg.Voters))
	for id := range cfg.Voters {
		indexes = append(indexes, match[id])
	}
	sort.Slice(indexes, func(i, j int) bool { return indexes[i] < indexes[j] })
	candidate := indexes[len(indexes)-cfg.Quorum()]
	if candidate <= commit {
		return commit
	}
	if termAt(candidate) != currentTerm {
		return commit
	}
	return candidate
}

// Entry is a replicated command; index 1 is the first real entry.
type Entry struct {
	Index uint64
	Term  uint64
	Cmd   string
}

// Log is compactable: Entries[0] is the boundary at Base. An index below Base
// is covered by a snapshot instead of a log entry.
type Log struct {
	Entries  []Entry
	Base     uint64
	BaseTerm uint64
}

func NewLog() *Log {
	return &Log{Entries: []Entry{{Index: 0, Term: 0}}}
}

func (l *Log) LastIndex() uint64 { return l.Entries[len(l.Entries)-1].Index }
func (l *Log) LastTerm() uint64  { return l.Entries[len(l.Entries)-1].Term }

func (l *Log) TermAt(index uint64) (uint64, bool) {
	if index < l.Base || index > l.LastIndex() {
		return 0, false
	}
	return l.Entries[index-l.Base].Term, true
}

func (l *Log) Append(e Entry) error {
	if e.Index != l.LastIndex()+1 {
		return fmt.Errorf("raftx: append %d after %d: %w", e.Index, l.LastIndex(), ErrBrokenLog)
	}
	if e.Term < l.LastTerm() {
		return fmt.Errorf("raftx: term %d below %d: %w", e.Term, l.LastTerm(), ErrBrokenLog)
	}
	l.Entries = append(l.Entries, e)
	return nil
}

// Compact drops the prefix through index and keeps index as the new boundary.
func (l *Log) Compact(index, applied uint64) error {
	if index < l.Base || index > l.LastIndex() || index > applied {
		return fmt.Errorf("raftx: compact %d base=%d last=%d applied=%d: %w",
			index, l.Base, l.LastIndex(), applied, ErrSnapshotGap)
	}
	l.Entries = append([]Entry(nil), l.Entries[index-l.Base:]...)
	l.Base = index
	l.BaseTerm = l.Entries[0].Term
	return nil
}

// Snapshot captures the boundary entry, the config and the state-machine image.
type Snapshot struct {
	LastIndex uint64
	LastTerm  uint64
	Config    Config
	Data      []byte
}

func (l *Log) Snapshot(index, applied uint64, cfg Config, data []byte) (Snapshot, error) {
	term, ok := l.TermAt(index)
	if !ok || index > applied {
		return Snapshot{}, fmt.Errorf("raftx: snapshot %d applied=%d: %w", index, applied, ErrSnapshotGap)
	}
	return Snapshot{LastIndex: index, LastTerm: term, Config: cfg.Clone(), Data: data}, nil
}

// Store is the follower-side state touched by InstallSnapshot.
type Store struct {
	Log     *Log
	Commit  uint64
	Applied uint64
	Config  Config
}

// InstallSnapshot keeps a matching local suffix and replaces a conflicting one.
func (s *Store) InstallSnapshot(snap Snapshot) error {
	if snap.LastIndex <= s.Log.Base {
		return nil
	}
	keep := false
	if snap.LastIndex <= s.Log.LastIndex() {
		term, ok := s.Log.TermAt(snap.LastIndex)
		keep = ok && term == snap.LastTerm
	}
	if keep {
		s.Log.Entries = append([]Entry(nil), s.Log.Entries[snap.LastIndex-s.Log.Base:]...)
	} else {
		s.Log.Entries = []Entry{{Index: snap.LastIndex, Term: snap.LastTerm}}
	}
	s.Log.Base, s.Log.BaseTerm = snap.LastIndex, snap.LastTerm
	if s.Commit < snap.LastIndex {
		s.Commit = snap.LastIndex
	}
	if s.Applied < snap.LastIndex {
		s.Applied = snap.LastIndex
	}
	s.Config = snap.Config.Clone()
	return nil
}

// ReadIndex returns a safe read index once a quorum acks the leader's term.
// A stale leader cannot collect current-term acks and fails closed.
func ReadIndex(term, commit uint64, cfg Config, ackedTerm map[string]uint64) (uint64, error) {
	acks := 0
	for id := range cfg.Voters {
		if ackedTerm[id] == term {
			acks++
		}
	}
	if acks < cfg.Quorum() {
		return 0, fmt.Errorf("raftx: %d/%d current-term acks: %w", acks, cfg.Quorum(), ErrNoQuorum)
	}
	return commit, nil
}

// CanServe rejects a read until the state machine has applied the read index.
func CanServe(applied, readIndex uint64) error {
	if applied < readIndex {
		return fmt.Errorf("raftx: applied %d < read %d: %w", applied, readIndex, ErrNotApplied)
	}
	return nil
}

运行结果

在 darwin/arm64 + Go 1.26.0 上实际运行 go test ./internal/platform/consensus/raftx/ -count=1 -v:

=== RUN   TestSingleChangeKeepsQuorumsOverlapping
--- PASS: TestSingleChangeKeepsQuorumsOverlapping (0.00s)
=== RUN   TestTwoNodeChangeIsRejected
--- PASS: TestTwoNodeChangeIsRejected (0.00s)
=== RUN   TestAdvanceCommitUsesCurrentConfig
--- PASS: TestAdvanceCommitUsesCurrentConfig (0.00s)
=== RUN   TestCompactAndInstallSnapshot
--- PASS: TestCompactAndInstallSnapshot (0.00s)
=== RUN   TestReadIndexNeedsCurrentTermQuorum
--- PASS: TestReadIndexNeedsCurrentTermQuorum (0.00s)
PASS
ok  	archlab/internal/platform/consensus/raftx	0.263s

TestSingleChangeKeepsQuorumsOverlapping 不是抽样,而是枚举 3、4、5 节点配置下所有多数派子集,逐一验证新旧集合相交;TestTwoNodeChangeIsRejected 确认一次加两个或“加一个删一个”都会被门禁挡下。提交用例给出同一组 match:旧 3 节点配置下 commit 能到 5,新 4 节点配置下只能到 3,说明扩容会立刻抬高提交门槛。快照用例先压缩到 index 5,再让一个只有 1–3 的 follower 安装快照,验证 base、commit、applied 一起推进,且后续 index 6 仍能正常追加。读用例里只有 1 个当前任期确认时返回 ErrNoQuorum,即使三个节点都回复、但都停在旧任期,结果也一样,这正是旧 leader 分区后的处境。

决策表:变更与读的选型

需求方案代价边界
常规扩缩容单节点变更 + 新节点预同步一次一条配置日志,操作分多步不能跳过预同步
一次换多个节点joint consensus 或分批实现复杂,需同时满足新旧多数派不建议手写
日志无限增长周期性快照 + 保留尾部快照传输占带宽,需分块快照必须带配置
读多写少、能容忍一次往返ReadIndex每读一轮心跳多数派不可达时失败
同机房、时钟可控租约读依赖漂移上界跨云/虚拟化慎用

常见错误与边界

第一,一次性加入多个节点。{a,b} 与 {c,d,e} 可以同时成为多数派,这不是概率问题,而是配置设计直接允许了双主。

第二,新节点没追平就计入 quorum。配置条目一提交,新节点立刻参与多数派计算;它落后越久,集群可用性越差,极端情况下只剩旧节点时无法提交。

第三,压缩到未应用的日志。Compact 的 index > applied 必须拒绝,否则状态机永远补不上被删掉的条目。

第四,快照不带成员配置。只靠快照恢复的节点会丢失集群视图;配置必须和状态机镜像一起持久化。

第五,ReadIndex 不带任期。只数“有多少节点回了我”而不检查回复的 term,会把旧 leader 在旧任期收集的确认当成有效,读仍然可能是陈旧的。

小结

成员变更的安全性来自“新旧多数派必有交集”,单节点变更把这条性质变成可检查的门禁;配置条目必须按普通日志在旧配置下提交,新节点要先追平再计入。快照把日志压缩成边界条目加镜像,边界 term 和成员配置都不能丢。线性一致读不能靠“我是 leader”的自信,ReadIndex 用当前任期的一轮多数派确认把陈旧 leader 挡在服务之外。下一节离开共识,进入分区:分片键怎么选、热点怎么识别,以及一致性哈希在扩容时到底迁移多少数据。

阅读导航:上一节:选主、日志复制与提交规则的可运行实现 · 下一节:分片键、热点与一致性哈希 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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