《后端架构设计·分布式与数据一致性》4.2 选主、日志复制与提交规则的可运行实现

用订单库存扣减场景实现确定性迷你 Raft:逻辑 tick 驱动消息队列、按节点 ID 与任期错开选举超时、AppendEntries 冲突回退、多数派且当前任期才提交;分区测试验证少数派既选不出新主,也提交不了任何条目,并给出超时参数的取舍表。

leader 回复了,不等于命令提交了

大促期间订单服务从 5 个节点缩到 3 个,运维在旧节点上还留着流量。客户端把 reserve:sku-1:1 发给一个自认为是 leader 的旧节点,对方返回成功;几秒后新 leader 在多数派一侧产生,这条预留从未被复制,库存被另一笔订单重复占用。

错误直觉是“leader 接受就算成功”。Raft 的接受与提交是两件事:leader 把命令追加到本地日志只代表“我打算执行”,只有多数派确认且条目属于当前任期,commitIndex 才会推进。客户端必须以提交结果为准,或者用幂等键承受“可能重复但不会丢失”的重试。

用逻辑 tick 替代真实 sleep

本篇的节点没有 goroutine、没有 time.Sleep,所有时间都是整数 tick。每个 tick 做三件事:投递上一轮在途消息、处理消息、检查超时并发送心跳。网络分区就是“丢弃跨组消息”,不需要真的断网。

这样做的直接好处是测试可重复:同一组 ID、同一初始状态、同样的 tick 数,必然得到同一条日志。真实 Raft 的随机超时是为了打破平票,这里用 ID[0] + term 生成确定但互不相同的偏移,同样能制造平票与下一轮重选,只是把随机性换成了可复现的伪随机。

选主:超时错开,一任期一票

follower 超过 electionAt 没收到心跳就自增任期、投自己、向所有 peer 发 MsgVote。投票请求携带 lastIndex 与 lastTerm;接收方先比 term,再用 (lastTerm, lastIndex) 判断候选人的日志是否至少一样新。拿到多数票的 candidate 成为 leader,立刻把每个 peer 的 nextIndex 设为 lastIndex+1,并发送一轮空的 AppendEntries 宣示任期。

同一 term 重复投票会被 Vote 字段挡掉;见到更高 term 一律降为 follower。平票时谁都不够多数,等下一个超时周期,任期加一后重来。真实实现里这个周期要加随机抖动,否则两个节点会长期同步超时、反复平票。

日志复制:nextIndex、matchIndex 与冲突回退

leader 为每个 peer 维护两个游标:nextIndex 是下一条要发的日志,matchIndex 是已确认复制到该 peer 的最高索引。AppendEntries 带 prevIndex/prevTerm 做前缀校验:

场景follower 动作leader 动作
prev 匹配,条目为空回成功,match 不变心跳,维持领导权
prev 匹配,有新条目冲突则截断,再追加成功后将 match 推到新末尾
prev 不匹配回失败并给出匹配前缀提示nextIndex 回退,重发更早的条目
term 更小拒绝并带上自己的 term见到更高 term 后降级

冲突回退的关键不是“退一格”本身,而是 follower 只截断与 leader 冲突的后缀,匹配的前缀必须保留。否则每次重试都把已提交数据删掉,Log Matching 立刻被破坏。

提交:多数派且当前任期

leader 把“自己的日志末尾 + 所有 peer 的 matchIndex”排序,取第 N - quorum + 1 大的索引作为候选;只有该索引的条目属于当前任期时才推进 commit。5 节点集群需要 3 个副本,3 节点需要 2 个。follower 不自行计算多数派,只取 min(leaderCommit, 本次追加后的最后索引)。

这条规则同时解释了分区行为:少数派一侧的旧 leader 仍然可以 Propose,日志会变长,但 matchIndex 永远凑不齐多数,commit 停在原地;多数派一侧选出更高任期的新 leader 后,旧 leader 的未提交尾部会在重新加入时被截断。

代码:节点状态机

下面的文件是节点逻辑的全部;驱动它的 Cluster 只负责按 tick 投递消息和按分组丢包,不在本节展开。

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

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

// Role is the node role for the current term.
type Role uint8

const (
	Follower Role = iota
	Candidate
	Leader
)

type Entry struct {
	Index uint64
	Term  uint64
	Cmd   string
}

type Kind uint8

const (
	MsgVote Kind = iota
	MsgVoteResp
	MsgAppend
	MsgAppendResp
)

// Message is one in-flight RPC; fields are shared across kinds.
type Message struct {
	From, To            string
	Kind                Kind
	Term                uint64
	LastIndex, LastTerm uint64
	Granted             bool
	PrevIndex, PrevTerm uint64
	Entries             []Entry
	LeaderCommit        uint64
	Success             bool
	MatchIndex          uint64
}

var ErrNotLeader = errors.New("raft: not leader")

// UpToDate compares (lastTerm, lastIndex) lexicographically.
func UpToDate(ct, ci, lt, li uint64) bool {
	if ct != lt {
		return ct > lt
	}
	return ci >= li
}

// Node is driven by logical ticks; it owns no clock and no goroutines.
type Node struct {
	ID     string
	Role   Role
	Term   uint64
	Vote   string
	Leader string
	Log    []Entry
	Commit uint64

	peers      []string
	electionAt int
	next       map[string]uint64
	match      map[string]uint64
	votes      map[string]bool
}

func NewNode(id string, peers []string) *Node {
	n := &Node{ID: id, Log: []Entry{{}}, peers: peers,
		next: map[string]uint64{}, match: map[string]uint64{}, votes: map[string]bool{}}
	for _, p := range peers {
		n.match[p] = 0
	}
	return n
}

func (n *Node) quorum() int            { return (len(n.peers)+1)/2 + 1 }
func (n *Node) lastIndex() uint64      { return n.Log[len(n.Log)-1].Index }
func (n *Node) lastTerm() uint64       { return n.Log[len(n.Log)-1].Term }
func (n *Node) termAt(i uint64) uint64 { return n.Log[i].Term }

// resetElection gives each id and term a different deterministic timeout.
func (n *Node) resetElection(now int) {
	n.electionAt = now + 6 + int((uint64(n.ID[0])+n.Term)%5)
}

func (n *Node) becomeFollower(term uint64, now int, leader string) {
	if term > n.Term {
		n.Term, n.Vote = term, ""
	}
	n.Role, n.Leader = Follower, leader
	n.votes = map[string]bool{}
	n.resetElection(now)
}

func (n *Node) reply(m Message, kind Kind, ok bool) Message {
	return Message{From: n.ID, To: m.From, Kind: kind, Term: n.Term, Granted: ok, Success: ok}
}

// Step processes one message and returns messages queued for the next tick.
func (n *Node) Step(now int, m Message) []Message {
	switch m.Kind {
	case MsgVote:
		if m.Term < n.Term {
			return []Message{n.reply(m, MsgVoteResp, false)}
		}
		if m.Term > n.Term {
			n.becomeFollower(m.Term, now, "")
		}
		ok := (n.Vote == "" || n.Vote == m.From) &&
			UpToDate(m.LastTerm, m.LastIndex, n.lastTerm(), n.lastIndex())
		if ok {
			n.Vote = m.From
			n.resetElection(now)
		}
		return []Message{n.reply(m, MsgVoteResp, ok)}

	case MsgVoteResp:
		if m.Term > n.Term {
			n.becomeFollower(m.Term, now, "")
			return nil
		}
		if n.Role != Candidate || m.Term != n.Term || !m.Granted {
			return nil
		}
		n.votes[m.From] = true
		if len(n.votes) < n.quorum() {
			return nil
		}
		n.Role, n.Leader, n.Vote = Leader, n.ID, n.ID
		for _, p := range n.peers {
			n.next[p], n.match[p] = n.lastIndex()+1, 0
		}
		return n.appendAll()

	case MsgAppend:
		if m.Term < n.Term {
			r := n.reply(m, MsgAppendResp, false)
			r.MatchIndex = n.lastIndex()
			return []Message{r}
		}
		n.becomeFollower(m.Term, now, m.From)
		if m.PrevIndex > n.lastIndex() || n.termAt(m.PrevIndex) != m.PrevTerm {
			r := n.reply(m, MsgAppendResp, false)
			r.MatchIndex = n.lastIndex()
			if m.PrevIndex > 0 && m.PrevIndex <= n.lastIndex() {
				r.MatchIndex = m.PrevIndex - 1
			}
			return []Message{r}
		}
		for _, e := range m.Entries {
			if e.Index <= n.lastIndex() && n.termAt(e.Index) == e.Term {
				continue
			}
			if e.Index <= n.lastIndex() {
				n.Log = n.Log[:e.Index]
			}
			n.Log = append(n.Log, e)
		}
		if m.LeaderCommit > n.Commit {
			n.Commit = min(m.LeaderCommit, n.lastIndex())
		}
		r := n.reply(m, MsgAppendResp, true)
		r.MatchIndex = m.PrevIndex + uint64(len(m.Entries))
		return []Message{r}

	case MsgAppendResp:
		if m.Term > n.Term {
			n.becomeFollower(m.Term, now, "")
			return nil
		}
		if n.Role != Leader || m.Term != n.Term {
			return nil
		}
		if m.Success {
			if m.MatchIndex > n.match[m.From] {
				n.match[m.From] = m.MatchIndex
			}
			n.next[m.From] = n.match[m.From] + 1
			n.advanceCommit()
			return nil
		}
		next := n.next[m.From]
		if m.MatchIndex+1 < next {
			next = m.MatchIndex + 1
		}
		if next < 1 {
			next = 1
		}
		n.next[m.From] = next
		return []Message{n.appendFor(m.From)}
	}
	return nil
}

func (n *Node) appendFor(p string) Message {
	next := n.next[p]
	if next < 1 {
		next = 1
	}
	m := Message{From: n.ID, To: p, Kind: MsgAppend, Term: n.Term,
		PrevIndex: next - 1, PrevTerm: n.termAt(next - 1), LeaderCommit: n.Commit}
	m.Entries = append(m.Entries, n.Log[next:]...)
	return m
}

func (n *Node) appendAll() []Message {
	out := make([]Message, 0, len(n.peers))
	for _, p := range n.peers {
		out = append(out, n.appendFor(p))
	}
	return out
}

// advanceCommit counts the leader's own tail plus every peer's match index.
func (n *Node) advanceCommit() {
	idx := make([]uint64, 0, len(n.peers)+1)
	idx = append(idx, n.lastIndex())
	for _, p := range n.peers {
		idx = append(idx, n.match[p])
	}
	sort.Slice(idx, func(i, j int) bool { return idx[i] < idx[j] })
	c := idx[len(idx)-n.quorum()]
	if c > n.Commit && n.termAt(c) == n.Term {
		n.Commit = c
	}
}

func (n *Node) TickTimeout(now int) []Message {
	if n.Role == Leader || now < n.electionAt {
		return nil
	}
	n.Role, n.Term, n.Vote = Candidate, n.Term+1, n.ID
	n.votes = map[string]bool{n.ID: true}
	n.resetElection(now)
	out := make([]Message, 0, len(n.peers))
	for _, p := range n.peers {
		out = append(out, Message{From: n.ID, To: p, Kind: MsgVote, Term: n.Term,
			LastIndex: n.lastIndex(), LastTerm: n.lastTerm()})
	}
	return out
}

// Propose appends a command on the leader; acceptance is not commit.
func (n *Node) Propose(cmd string) error {
	if n.Role != Leader {
		return fmt.Errorf("raft: %s cannot propose: %w", n.ID, ErrNotLeader)
	}
	n.Log = append(n.Log, Entry{Index: n.lastIndex() + 1, Term: n.Term, Cmd: cmd})
	return nil
}

运行结果

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

=== RUN   TestElectionIsSingleAndConverges
--- PASS: TestElectionIsSingleAndConverges (0.00s)
=== RUN   TestReplicationCommitsOnMajority
--- PASS: TestReplicationCommitsOnMajority (0.00s)
=== RUN   TestMinorityLeaderCannotCommitOrOverwrite
--- PASS: TestMinorityLeaderCannotCommitOrOverwrite (0.00s)
=== RUN   TestAppendEntriesConflictRollback
--- PASS: TestAppendEntriesConflictRollback (0.00s)
=== RUN   TestProposeOnFollowerFails
--- PASS: TestProposeOnFollowerFails (0.00s)
PASS
ok  	archlab/internal/platform/consensus/raft	0.272s

TestElectionIsSingleAndConverges 跑 40 个 tick 后断言只有一个 leader,且所有节点 term 一致。TestReplicationCommitsOnMajority 连续提交三条订单命令,10 个 tick 后 leader 与全部 5 个节点的 Committed 都是同样三条。TestMinorityLeaderCannotCommitOrOverwrite 把旧 leader 单独分区:它仍能接受第四次 Propose,但 commit 停在 1;多数派选出更高任期的新 leader,提交第二条命令;恢复网络后旧 leader 的未提交尾部被截断并追平。

怎么读分区测试

分区不是“节点宕机”,消息还在发,只是被丢弃。旧 leader 不会自动退位,这是没有实现 CheckQuorum 时的真实行为;安全性仍然成立,因为它的 commit 推不动。测试真正验证的是两条:多数派一侧能恢复活性,少数派一侧不能制造第二个已提交历史。

如果测试里出现两个 leader 但 term 不同,不是 bug;同一 term 两个 leader 才是。断言要按 (term, leader) 而不是“全局只有一个 leader”来写,否则把正常的新旧任期并存误判成脑裂。

决策表:超时与重试参数

参数本节取值生产取值方向判据
选举超时6–10 tick150–300ms 基值 + 随机抖动远大于一次心跳往返,避免误触发
心跳间隔1 tick超时的 1/3 左右留出两次丢包仍不触发选举
冲突回退每次退一格按 term 跳回冲突任期起点长日志下减少重试轮次
客户端重试不内置幂等键 + 退避重试可能重复,不能假设恰好一次

常见错误与边界

第一,把 Propose 成功当提交。代码里 Propose 只追加本地日志,返回 nil 不代表多数派确认;生产 API 必须等 apply 或返回“处理中”。

第二,follower 自行推进 commit。follower 只能跟随 leader 的 LeaderCommit,自行按本地副本数计算会在分区时提交出不同历史。

第三,冲突回退时清空整条日志。只应截断冲突后缀;把匹配前缀也删掉会破坏已提交数据,也会让重试永远追不上。

第四,用真实 sleep 写选主测试。sleep 长短受机器负载影响,10ms 的抖动足以让测试偶发失败;逻辑 tick 才能稳定复现平票和分区。

第五,忽略持久化顺序。本节为了聚焦协议省略了磁盘,生产实现必须在回复投票和 AppendEntries 之前落盘 term、vote 与日志,否则崩溃恢复后会违背已经发出的承诺。

小结

迷你 Raft 的全部机制可以压成三条规则:超时错开保证有人发起选举,一任期一票保证不会有两个 leader,多数派加当前任期才推进 commit。nextIndex/matchIndex 把复制进度变成可回退的游标,follower 只截断冲突后缀。分区测试说明安全性不依赖“旧 leader 知道自己下台”,而依赖它凑不齐多数派。下一节在这个骨架上加三件生产必需的东西:成员变更时新旧多数派的交集、日志压缩后的快照安装,以及不读陈旧数据的 ReadIndex。

阅读导航:上一节:共识问题与 Raft 的角色、任期、日志 · 下一节:成员变更、快照与线性一致读 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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