扩容到五节点的那一夜
订单集群原本是 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) | 2 | 3 | 是:新多数派至多只有 2 个非旧成员 |
| 4 → 5(加 e) | 3 | 3 | 是:旧配置的补集只有 1 个节点 |
| 3 → 5(一次加两个) | 2 | 3 | 否:{a,b} 与 {c,d,e} 可以不相交 |
| 替换一个(先加后删) | 2 | 2 | 每步单独看是安全的,但必须等第一步提交 |
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 | 时钟漂移有上界 | 租约到期前可能返回陈旧数据 |
| ReadIndex | 1 轮心跳 | 多数派可达 | 凑不齐确认,失败关闭 |
| Quorum Read | 1 轮读 | 多数派可达 | 由读 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 挡在服务之外。下一节离开共识,进入分区:分片键怎么选、热点怎么识别,以及一致性哈希在扩容时到底迁移多少数据。
阅读导航:上一节:选主、日志复制与提交规则的可运行实现 · 下一节:分片键、热点与一致性哈希 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。