《后端架构设计·分布式与数据一致性》4.1 共识问题与 Raft 的角色、任期、日志

从一笔支付回调被两个订单节点同时处理的场景出发,拆解 Raft 的三种角色、任期一票制、日志匹配与多数派提交规则;给出可 go test 的不变量骨架,逐条说明选举安全、日志匹配与 Figure 8 的判据,并区分共识、单主复制与幂等对账的适用边界。

支付回调到了两个订单节点

订单 O-20261028-001 支付成功,渠道把回调同时打到了两个订单服务实例。两个实例都读到本地状态 CREATED,都判断“这笔支付还没处理”,于是都写入 PAID,并各自调用了一次库存扣减。事后对账时发现库存少了两件,支付渠道却只有一笔扣款。

直觉上的修复是“我们只部署一个主节点”。问题是故障切换期间,旧主并不会立刻知道自己已经不是主:它仍然接受写请求,仍然告诉客户端“收到了”。客户端看到的“成功”只是本地接受,不是多数派确认。共识要解决的不是“选一个主”,而是两件更基本的事:在一个任期内谁有权做决定,以及哪些日志条目已经是不可回退的最终结果。

这与 单主复制 不同。单主复制假设主是谁已经确定,只关心日志怎么复制;共识要把“主是谁、哪些条目算数”本身也交给多数派判定。

共识的安全性与活性

目标含义订单系统里被违反的样子
安全性已经决定的条目不会被推翻,同一任期不会有两个主已支付的订单回退到 CREATED,或两个节点同时扣库存
活性多数派存活且能互通时,最终能选出主并提交集群长时间没有主,下单全部超时

安全性任何时刻都不能破,活性只能在“多数派存活且网络最终恢复”的假设下承诺。网络分区期间,少数派一侧可以一直选不出主,也可以有一个自认为主但提交不了任何条目的节点;这不是 bug,而是共识用活性换安全的正常结果。

三种角色与任期

角色进入条件主要动作退出条件
follower启动或收到更高任期消息响应投票、追加日志选举超时后变 candidate
candidatefollower 超时任期加一、投自己、拉票拿到多数票变 leader;见到更高任期或 leader 变 follower
leadercandidate 拿到多数票追加日志、复制、推进 commit见到更高任期变 follower

任期(term)是逻辑时钟,不是墙上时间。它只保证单调递增:节点见到更大的 term 必须立刻降为 follower,并把 term 与 votedFor 在回复之前持久化。一个 term 内每个节点最多投一票,因此同一 term 不可能出现两个拿到多数票的 candidate。这就是 Election Safety 的全部来源,不需要节点之间对时。

“leader 在任期内不会变”也是错的:leader 只要收到更高 term 的消息就下台。任期表达的是选举轮次,不是一段固定的租约时间。

日志:索引、任期与两个水位

每个日志条目由 (index, term) 唯一标识,index 从 1 开始,index 0 是哨兵。两个水位必须分开:

  • commitIndex:已经被多数派确认、不会再回退的最大索引;
  • lastApplied:状态机已经执行的索引,永远不大于 commitIndex。

订单服务只能把 commitIndex 之前的条目当作事实。follower 的 commit 不能自己猜,只能取 min(leaderCommit, 本次 AppendEntries 最后一条的 index);否则会把尚未复制到多数派的条目提前应用。写入顺序同样重要:先持久化 term、vote 和日志,再回复成功;先回复再落盘,崩溃后就会丢失已经承诺过的投票。

五条不变量

不变量判据本节对应的检查
Election Safety每个 term 至多一个 leaderValidateCluster 统计同 term 的 leader
Leader Append-Onlyleader 只追加,不覆盖、不删除自己的日志Log.Append 拒绝索引跳号与 term 回退
Log Matching两条日志在相同 index 且相同 term,则此前所有条目相同集群检查已提交前缀逐条相等
Leader Completeness已提交条目一定出现在后续任期的 leader 日志中由投票的“日志足够新”规则间接保证
State Machine Safety同一 index 不会应用不同命令Check 检查连续性与提交边界

前四条是 Raft 论文里的安全性论证,最后一条是前四条落到状态机上的结果。工程上能直接测的是 Election Safety、Log Matching 和本地连续性;Leader Completeness 依赖“多数派交集 + 投票限制”的归纳证明,通常用故障注入和模型检查验证,而不是一条单元测试。

投票与提交:两条容易写错的规则

投票规则只有两句:同一 term 只投一次;只投给日志至少一样新的 candidate。比较按 (lastTerm, lastIndex) 字典序,先比最后一条的 term,term 相同再比长度。只比长度会让一个“条目多但任期旧”的节点当选,从而覆盖已经提交的条目。

提交规则也常被写错:leader 只能直接提交当前任期的条目。把旧任期条目按多数派直接提交,会踩到 Figure 8:某条 term 2 的条目已经复制到多数派,但新的 leader 在 term 4 可能用另一条 term 2 的条目覆盖它;只有当前 term 的条目提交后,之前的条目才随之前缀被间接提交。代码里的 entry.Term != n.Term 就是这条判据。

还有一个常见误解:多数派按“当前存活节点”算。正确口径是按配置成员算,5 节点集群的多数派永远是 3,即使只剩 2 个节点活着也不能提交。

代码:把不变量写进状态机

下面的骨架只做单节点状态与集群断言,不涉及网络;它把投票、日志连续性和提交条件写成可以直接测试的函数。

// file: internal/platform/consensus/state.go
// tier: L1
// verify: go test ./internal/platform/consensus/ -run Test -count=1
package consensus

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

type Role string

const (
	RoleFollower  Role = "follower"
	RoleCandidate Role = "candidate"
	RoleLeader    Role = "leader"
)

var (
	ErrBrokenLog      = errors.New("consensus: broken log")
	ErrElectionSafety = errors.New("consensus: election safety violated")
)

// Entry is one replicated command; index 0 is a sentinel in every Log.
type Entry struct {
	Index uint64
	Term  uint64
	Cmd   string
}

type Log struct {
	entries []Entry
}

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) EntryAt(index uint64) (Entry, bool) {
	if index > l.LastIndex() {
		return Entry{}, false
	}
	return l.entries[index], true
}

// Append enforces contiguous indexes and non-decreasing terms.
func (l *Log) Append(e Entry) error {
	if e.Index != l.LastIndex()+1 {
		return fmt.Errorf("consensus: append index %d after %d: %w", e.Index, l.LastIndex(), ErrBrokenLog)
	}
	if e.Term < l.LastTerm() {
		return fmt.Errorf("consensus: term %d below last %d: %w", e.Term, l.LastTerm(), ErrBrokenLog)
	}
	l.entries = append(l.entries, e)
	return nil
}

// TruncateFrom removes index and everything after it on a conflicting follower.
func (l *Log) TruncateFrom(index uint64) error {
	if index == 0 || index > l.LastIndex() {
		return fmt.Errorf("consensus: truncate %d outside (0,%d]: %w", index, l.LastIndex(), ErrBrokenLog)
	}
	l.entries = l.entries[:index]
	return nil
}

// Node is the crash-persistent state: term, vote, log, plus two watermarks.
type Node struct {
	ID       string
	Role     Role
	Term     uint64
	VotedFor string
	Log      *Log
	Commit   uint64
	Applied  uint64
	Cluster  int
}

func NewNode(id string, cluster int) *Node {
	return &Node{ID: id, Role: RoleFollower, Log: NewLog(), Cluster: cluster}
}

func (n *Node) Quorum() int { return n.Cluster/2 + 1 }

// BecomeFollower raises the term and clears the vote when the term advances.
func (n *Node) BecomeFollower(term uint64) {
	if term > n.Term {
		n.Term = term
		n.VotedFor = ""
	}
	n.Role = RoleFollower
}

// UpToDate compares (lastTerm, lastIndex) lexicographically.
func UpToDate(candTerm, candIndex, localTerm, localIndex uint64) bool {
	if candTerm != localTerm {
		return candTerm > localTerm
	}
	return candIndex >= localIndex
}

type VoteRequest struct {
	Term        uint64
	CandidateID string
	LastIndex   uint64
	LastTerm    uint64
}

type VoteResponse struct {
	Term    uint64
	Granted bool
}

// HandleVoteRequest grants at most one vote per term and only to an up-to-date log.
func (n *Node) HandleVoteRequest(req VoteRequest) VoteResponse {
	if req.Term < n.Term {
		return VoteResponse{Term: n.Term}
	}
	if req.Term > n.Term {
		n.BecomeFollower(req.Term)
	}
	if n.VotedFor != "" && n.VotedFor != req.CandidateID {
		return VoteResponse{Term: n.Term}
	}
	if !UpToDate(req.LastTerm, req.LastIndex, n.Log.LastTerm(), n.Log.LastIndex()) {
		return VoteResponse{Term: n.Term}
	}
	n.VotedFor = req.CandidateID
	return VoteResponse{Term: n.Term, Granted: true}
}

// AdvanceCommit needs a majority match and an entry from the leader's own term.
// match must contain every other voter, including nodes known to have nothing.
func (n *Node) AdvanceCommit(match map[string]uint64) uint64 {
	if n.Role != RoleLeader {
		return n.Commit
	}
	indexes := make([]uint64, 0, len(match)+1)
	indexes = append(indexes, n.Log.LastIndex())
	for _, idx := range match {
		indexes = append(indexes, idx)
	}
	sort.Slice(indexes, func(i, j int) bool { return indexes[i] < indexes[j] })
	candidate := indexes[len(indexes)-n.Quorum()]
	if candidate <= n.Commit {
		return n.Commit
	}
	entry, ok := n.Log.EntryAt(candidate)
	if !ok || entry.Term != n.Term {
		return n.Commit
	}
	n.Commit = candidate
	return n.Commit
}

// Check verifies the local invariants that do not need a second node.
func (n *Node) Check() error {
	if n.Commit > n.Log.LastIndex() {
		return fmt.Errorf("consensus: commit %d beyond last %d: %w", n.Commit, n.Log.LastIndex(), ErrBrokenLog)
	}
	if n.Applied > n.Commit {
		return fmt.Errorf("consensus: applied %d beyond commit %d: %w", n.Applied, n.Commit, ErrBrokenLog)
	}
	for i := 1; i < len(n.Log.entries); i++ {
		prev, cur := n.Log.entries[i-1], n.Log.entries[i]
		if cur.Index != prev.Index+1 || cur.Term < prev.Term {
			return fmt.Errorf("consensus: entries %d/%d break contiguity or term order: %w", prev.Index, cur.Index, ErrBrokenLog)
		}
	}
	return nil
}

// ValidateCluster checks one leader per term and agreement on committed prefixes.
func ValidateCluster(nodes []*Node) error {
	leaders := make(map[uint64][]string)
	for _, n := range nodes {
		if err := n.Check(); err != nil {
			return err
		}
		if n.Role == RoleLeader {
			leaders[n.Term] = append(leaders[n.Term], n.ID)
		}
	}
	for term, ids := range leaders {
		if len(ids) > 1 {
			return fmt.Errorf("consensus: term %d has leaders %v: %w", term, ids, ErrElectionSafety)
		}
	}
	for i := 0; i < len(nodes); i++ {
		for j := i + 1; j < len(nodes); j++ {
			limit := nodes[i].Commit
			if nodes[j].Commit < limit {
				limit = nodes[j].Commit
			}
			for idx := uint64(1); idx <= limit; idx++ {
				a, aok := nodes[i].Log.EntryAt(idx)
				b, bok := nodes[j].Log.EntryAt(idx)
				if !aok || !bok || a.Term != b.Term || a.Cmd != b.Cmd {
					return fmt.Errorf("consensus: committed index %d differs on %s/%s: %w", idx, nodes[i].ID, nodes[j].ID, ErrBrokenLog)
				}
			}
		}
	}
	return nil
}

运行结果

在 darwin/arm64 + Go 1.26.0 上实际运行(只截取与本篇规则直接相关的用例):

=== RUN   TestVoteDecisionTable
--- PASS: TestVoteDecisionTable (0.00s)
=== RUN   TestTermOnlyMovesForward
--- PASS: TestTermOnlyMovesForward (0.00s)
=== RUN   TestAdvanceCommitNeedsMajorityAndCurrentTerm
--- PASS: TestAdvanceCommitNeedsMajorityAndCurrentTerm (0.00s)
=== RUN   TestValidateClusterCatchesSplitBrainAndDivergence
--- PASS: TestValidateClusterCatchesSplitBrainAndDivergence (0.00s)
PASS
ok  	archlab/internal/platform/consensus	0.314s

表驱动用例覆盖了五种投票输入:旧任期、更高任期、已投他人、日志较短、日志相同;TestTermOnlyMovesForward 把任期序列 6,9,9,12,11 压成 6,9,9,12,12,证明节点见到旧消息不会回退。提交用例先给 5 节点中的 3 个 match,确认 commit 从 0 推到 3;再构造一个只有旧任期条目的 leader,确认多数派复制也不会让它提交。最后一个用例手工造出两个 term 7 的 leader,ValidateCluster 返回 ErrElectionSafety,说明断言能抓住脑裂而不是只做形式检查。

决策表:什么时候真的需要共识

场景更轻的方案代价 / 边界结论
单库主从,允许分钟级人工切换单主复制 + 半同步切换窗口内可能丢尾部不需要共识
自动选主、配置、分布式锁元数据Raft / etcd至少 3 节点,写要多数派往返用共识
订单状态写入共识只保证日志顺序业务幂等、唯一约束仍不可省共识 + 幂等
支付渠道已幂等,只需最终对账Outbox + 对账中间态可见,需补偿不必上共识

共识买到的是“顺序与最终结果的唯一性”,不是“业务只执行一次”。即使日志只提交一次,消费者仍可能重复投递;库存扣减仍要幂等键或条件更新。把共识当成分布式事务的替代品,是 4.2 之后最容易踩的坑。

常见错误与边界

第一,把 leader 身份当租约。本节没有实现 CheckQuorum 或租约读:分区中的旧 leader 仍会接受客户端写入,只是提交不了。客户端必须以“是否 commit”而不是“leader 是否回复”判断成功。

第二,直接提交旧任期条目。多数派复制不等于可以提交;只有当前 term 的条目能推进 commit,旧条目随之前缀间接提交。忽略这一条会在 leader 切换时覆盖已提交数据。

第三,把 applied 当成 commit。状态机执行到哪和日志提交到哪是两个水位;用 applied 做读可能读到尚未提交、之后会回滚的数据。

第四,把 term 当成时间戳。term 只表示选举轮次,不能用来排序两个节点上的业务事件;跨节点顺序要靠日志 index 与提交规则。

小结

共识的安全性来自三条简单约束:一个 term 一票、日志按 (term, index) 比较新旧、只有当前 term 的多数派条目能提交。Raft 的三种角色和单调任期把这些约束变成可执行的状态转换,commitIndex 与 lastApplied 则把“已决定”和“已执行”分开。本节只写了单节点状态与集群断言,还没有消息传递、超时和分区。下一节把投票、AppendEntries、冲突回退和多数派提交接成一个确定性的迷你 Raft,并用分区测试验证少数派既选不出主,也提交不了任何条目。

阅读导航:上一节:复制延迟、故障切换与数据丢失窗口 · 下一节:选主、日志复制与提交规则的可运行实现 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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