支付回调到了两个订单节点
订单 O-20261028-001 支付成功,渠道把回调同时打到了两个订单服务实例。两个实例都读到本地状态 CREATED,都判断“这笔支付还没处理”,于是都写入 PAID,并各自调用了一次库存扣减。事后对账时发现库存少了两件,支付渠道却只有一笔扣款。
直觉上的修复是“我们只部署一个主节点”。问题是故障切换期间,旧主并不会立刻知道自己已经不是主:它仍然接受写请求,仍然告诉客户端“收到了”。客户端看到的“成功”只是本地接受,不是多数派确认。共识要解决的不是“选一个主”,而是两件更基本的事:在一个任期内谁有权做决定,以及哪些日志条目已经是不可回退的最终结果。
这与 单主复制 不同。单主复制假设主是谁已经确定,只关心日志怎么复制;共识要把“主是谁、哪些条目算数”本身也交给多数派判定。
共识的安全性与活性
| 目标 | 含义 | 订单系统里被违反的样子 |
|---|---|---|
| 安全性 | 已经决定的条目不会被推翻,同一任期不会有两个主 | 已支付的订单回退到 CREATED,或两个节点同时扣库存 |
| 活性 | 多数派存活且能互通时,最终能选出主并提交 | 集群长时间没有主,下单全部超时 |
安全性任何时刻都不能破,活性只能在“多数派存活且网络最终恢复”的假设下承诺。网络分区期间,少数派一侧可以一直选不出主,也可以有一个自认为主但提交不了任何条目的节点;这不是 bug,而是共识用活性换安全的正常结果。
三种角色与任期
| 角色 | 进入条件 | 主要动作 | 退出条件 |
|---|---|---|---|
| follower | 启动或收到更高任期消息 | 响应投票、追加日志 | 选举超时后变 candidate |
| candidate | follower 超时 | 任期加一、投自己、拉票 | 拿到多数票变 leader;见到更高任期或 leader 变 follower |
| leader | candidate 拿到多数票 | 追加日志、复制、推进 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 至多一个 leader | ValidateCluster 统计同 term 的 leader |
| Leader Append-Only | leader 只追加,不覆盖、不删除自己的日志 | 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,并用分区测试验证少数派既选不出主,也提交不了任何条目。
阅读导航:上一节:复制延迟、故障切换与数据丢失窗口 · 下一节:选主、日志复制与提交规则的可运行实现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。