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 tick | 150–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 的角色、任期、日志 · 下一节:成员变更、快照与线性一致读 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。