Raft 里那个 300ms 的随机超时,可能是分布式系统里最反直觉的一行代码

2026-10-31 11 0

Raft 里那个 300ms 的随机超时,可能是分布式系统里最反直觉的一行代码

说实话,Raft 论文我前后啃了三遍。真正让我开窍的不是那些证明,而是一个非常小的数字:选举超时随机落在 150ms 到 300ms 之间。

就这一个数字,藏着一个被无数人写错的分布式共识实现。如果你把 150~300ms 改成固定的 100ms,你的集群不会"更快",它会以一种非常诡异的方式,在若干次网络抖动之后,永远地失去 Leader。

这篇文章不讲 Paxos 的历史,也不做 Raft 和 ZooKeeper 的对比。我只讲一件事:为什么 Raft 必须在正确性上加一个"看起来纯粹是工程技巧"的随机化,以及这个随机数一旦固定下来,系统会怎么崩给你看。


一、先把共识问题缩到最小

共识算法要解决的事,用大白话说就是:一堆机器隔着一张会丢包、会延迟、会分区的网络,要共同维护一份日志,而且无论发生什么,所有还活着的机器最终看到的那条日志必须一模一样。

难点在于"分裂"两个字。当网络断开,集群被切成两半,两半都可能选出一个 Leader,都可能接受写入。等网络恢复,如果两边的日志不一样还都宣称自己是 Leader,那就完蛋了。

Raft 的聪明之处在于:它不试图证明什么,它把问题拆成三个可以分别解决的子问题:

  • Leader Election:怎么选出一个 Leader
  • Log Replication:Leader 怎么把日志复制给所有人
  • Safety:怎么保证任何时刻都不会有两个 Leader 在同一个 Term 里都认为自己是合法的

注意第三点。Raft 保证的不是"永远只有一个 Leader",而是"同一个 Term 内最多只有一个 Leader"。这两个说法差一个字,差的是生死。

二、Term:一台被隔离的老领导,怎么才能"过期"

Raft 的状态机里有几个核心字段,先把最重要的两个立起来:

  • currentTerm:当前任期号,单调递增,每个整数只用过一次
  • votedFor:本节点在当前 Term 里把票投给了谁(可以是 null)

每个 RPC(RequestVote、AppendEntries)都带着自己的 Term。收到带更大 Term 的消息,本地一律无条件降级:currentTerm = msg.Term,并且立刻变成 Follower,并把票作废。

这一条规则听起来平淡无奇,但它就是"过期"机制的全部。老 Leader 被隔离在网络分区里时,它的 Term 还是旧的;新分区的节点们互相通信,Term 一路涨上去。等老 Leader 重新连上网络,第一次收到别人的 RPC,发现 Term 比自己大——它立刻退位,一个字的写操作都不再执行。

这就是为什么 Raft 不需要"推翻暴君"这种机制。暴君不是被打败的,是自己过期了。

三、最阴险的一行代码:随机化选举超时

现在来看那 150~300ms。

Raft 有两种超时:心跳间隔(Leader 每隔固定时间发一次 AppendEntries,通常 50ms 上下)和选举超时(Follower 多久没收到心跳就发起选举)。

朴素实现会写成这样:

// 天真版本
const electionTimeout = 100 * time.Millisecond

func (n *Node) onTimeout() {
    n.becomeCandidate()
    n.voteForSelf()
    n.requestVotes()
}

这段代码在单机测试里完美,在真实集群里会要命。

设想集群里有 5 个节点,某个瞬间 Leader 所在机器在做一次 Full GC,暂停了 200ms。网络一切正常,剩余 4 个节点同时收到超时,同时变成 Candidate,同时给自己投票。

结果是:4 个节点各自统计票数,每人都只拿到自己的一票 + 最多 1 张别人的票(因为票可能被随机投给别人),谁都凑不够 3 票。于是一次选举失败,Term +1,重新来。

这个过程会无限循环:每次都分裂,每次都失败,每次 Term 加一。这就是经典的 split vote(选票瓜分),而如果超时是固定值,它就变成了"稳定地、每次都失败"——一个确定性的死亡螺旋。

Raft 的解法极其朴素:

// 正确版本
func (n *Node) resetElectionTimer() {
    n.electionTimer.Reset(randomElectionTimeout())
}

func randomElectionTimeout() time.Duration {
    // 150ms ~ 300ms 之间均匀随机
    return time.Duration(150+rand.Intn(150)) * time.Millisecond
}

随机化之后,同一批节点的超时几乎不可能同时到期。第一个超时的节点会抢先发起选举,其他节点在收到它的 RequestVote 后(发现 Term 更大)立即转为 Follower 并把票投给它——选举在几毫秒内就结束了。

这不是"性能优化"。这个随机数是活命的东西。固定超时的系统是"每次都必败",随机超时的系统是"偶尔多花几十毫秒"。

顺带一提,很多生产级实现(比如 etcd)还会加两个补丁:

  • Pre-Vote:先发一轮"试探性投票",确认自己有资格竞选之后,才真正发起正式选举、才递增 Term。这样可以避免一个被隔离的节点回来后盲目抬高 Term,把整个集群的 Term 搅乱。
  • CheckQuorum:Leader 如果在一段时间内连不到多数派,就主动退位。这样集群在失去多数派时不会保留一个"假 Leader"继续接受写入(但注意:这个假 Leader 只是不写数据,它的任期还会被续命,所以必须配合 Pre-Vote 才能真正避免 Term 抖动)。

四、日志复制:一致性不是靠"一致",是靠"可证明的多数"

Leader 当选后,所有写入都走 AppendEntries。核心逻辑:

func (l *Leader) appendEntries(entry Entry) {
    for _, peer := range l.peers {
        // 只发给还没确认的节点
        if l.nextIndex[peer] <= l.lastIndex {
            go l.replicateTo(peer, entry)
        }
    }
}

每个节点维护两个索引:

  • lastIndex:本地最后一条日志的序号
  • nextIndex[peer]:Leader 认为该 follower 的下一条日志应该是多少(初值 = leader 的 lastIndex + 1)

Follower 回一个 matchIndex,Leader 用它更新 commitIndex。当超过多数派都确认了某条日志,这条日志就被提交,状态机按顺序 apply。

这里有个非常容易被写错的细节,也是我认为 Raft 最优雅的地方之一:

Leader 只能通过"提交当前 Term 的日志"来间接提交之前 Term 的日志。

也就是说,如果 Term 5 的一条日志只存到了 2 个节点(没过半),Term 6 来了之后即使有 3 个节点有 Term 5 的日志,Leader 也不能直接宣布 Term 5 的日志已提交。它必须先写入一条 Term 6 的日志并让它获得多数派确认,才能顺带把 Term 5 的日志一起提交。

这个限制看起来是自找麻烦,但它挡住了整个协议里最凶险的一类 bug:Figure 8——一个陈旧的 Leader 在被隔离期间写了若干条只有自己知道的日志,恢复网络后试图覆盖新 Leader 的日志,从而"复活"已被提交的历史。

Figure 8 那个经典的图示,评论区每次都有人问"到底哪里错了"。答案就是:Raft 靠这条规则,让被覆盖的日志一定在旧 Term,而旧 Term 的日志不可能在当前 Term 被"直接"提交,也就不可能通过 commitIndex 推进来复活。

五、 election restriction:候选人必须"至少和我一样新"

还有一条容易被忽略的规则。Follower 给谁投票,要求满足:

// canVote 返回 true 的条件
func (n *Node) canVote(c *Candidate) bool {
    // 1. 候选人的日志必须"至少和我一样新"
    if c.lastTerm > n.lastTerm { return true }
    if c.lastTerm == n.lastTerm && c.lastIndex >= n.lastIndex { return true }
    return false
}

这条规则保证了含有未提交日志的旧 Leader 无法重新当选。如果它能当选,它就能把自己那些"可能是未提交的"的日志重新推给集群,把状态机倒退。

它和上一节的"只能提交当前 Term 日志"是两道独立的锁,分别堵住了"当选路径"和"提交路径"。Raft 的安全性不是靠某一条规则撑起来的,而是靠这种"同一条不变量在多个地方被反复加固"的设计。这点比论文里的证明更值得学——防御性冗余是共识实现的标配。

六、生产环境里那些论文不会告诉你的事

论文中的 Raft 实现是玩具,真上生产还得处理这些:

  • 日志膨胀:Follower 落后太多时,Leader 直接全量发快照 + 后续日志。这个"快照"要包含状态机快照 + 快照点之前的日志索引,快照传输本身又需要流控。很多自研实现在这里直接写出了半个 Raft。
  • 成员变更:直接从 3 节点变 5 节点,可能同时存在两个多数派定义(旧的 3 和新的 5),脑裂。Raft 用 joint consensus(过渡期需要同时满足两套多数派)来过渡,别自己发明。
  • 磁盘 fsync:Term、votedFor、log 这些元数据必须 fsync 之后才能应答。很多人在这里偷懒用 page cache,结果就是断电后丢投票记录,直接违反安全性——这是"活着的节点违反了协议",任何 Raft 论文都救不了你。
  • 时钟与租约:Raft 本身不依赖时钟,但基于 Leader 租约的实现(比如强 Leader)会依赖时钟偏差。容器环境里 NTP 回跳能把你的租约逻辑变成笑话。

七、结尾:随机数不是工程细节,是正确性的一部分

回到那 150~300ms。

很多人第一次读 Raft 会跳过它,觉得"随机超时"只是个实现细节,跟正确性证明没关系。恰恰相反——它决定了系统能否从"稳定的失败"中恢复出来。

分布式系统里有一类非常隐蔽的 bug:确定性配置导致的确定性失败。超时固定、时钟同步、节点数固定、哈希因子固定——任何一个"看起来无害的默认值",都可能在网络抖动的某个瞬间,把所有节点推到同一条失败路径上,而且再也回不来。

这才是我想说的那个反直觉的点:分布式系统的健壮性,往往来自那些"看起来不够确定"的随机性。你精心配置的每一个常数,都可能是在给下一次故障埋雷。

所以,下次你看到一行 rand.Intn(150),别急着把它"优化"成常量。它可能是整个系统里最贵的一行代码。

相关文章

数据库慢得像个拖拉机?先别骂数据库,这 7 个坑有一半是你自己挖的
你的服务每秒在 JSON 上烧掉的 CPU,比你以为的多一个数量级
Go 的调度器凭什么敢让十万个 goroutine 狂飙?GMP 三件套,一次讲透
你删掉了 100 万行,磁盘却没释放:数据库里的「尸体」不会自己消失
一个表情符号能把你的数据库搞瘫?MySQL 字符集 utf8mb4 的六宗罪
你的服务一重启,正在跑的请求就全死了?优雅停机,比你想的难写

发布评论