前言

作为在 AWS 搬砖多年,天天和 DynamoDB 打交道的后端工程师,每次用 ConsistentRead=true 这个参数时,心里难免会产生好奇:一个分布式数据库到底凭什么能够承诺“强一致读”?如果一次写请求已经成功返回,到底是什么保证了后续读请求一定能够看到它,而不是读到旧 leader 或落后副本上的过期状态?这背后其实是所有基于复制状态机的系统都会遇到的一个核心问题:我们如何为读请求找到一个合法的线性化位置?
这篇文章借助更容易理解和手搓实现的 Raft 共识算法,从 linearizability 的定义出发,推导出线性一致读到底需要满足哪些约束,以及 ReadIndex、leader lease read、follower read 这些优化分别在解决什么问题,并给出了一个参考版本的 go 语言实现(commit)。
线性一致性带来的约束
Raft 里糙快猛的线性一致读实现,是把 Get 也作为一条 log entry 写进 Raft log。这个方案的正确性很容易证明:读操作本身在 log 中有一个明确的位置,当这条 entry 被提交并按顺序 apply 时,这个 log index 就是读操作的线性化点。但是这个方案的代价在于:读请求会走完整的复制、提交、apply 流程,引入额外的 RPC 以及 IO 开销。
问题是:如果我们不想把读操作写进 Raft 日志,能否仍然保持线性一致性?要回答这个问题,我们需要先回到 linearizability 的定义。
对于一次读请求 R,在外部观察者看来,它有一个调用开始时刻 R.invoke 和返回时刻 R.response。线性一致性要求我们能够在这两个时刻之间,为 R 选择一个逻辑上的线性化时间点 T:
R.invoke ------------- T ------------- R.response
读操作返回的结果对应于系统在 T 时刻的状态。显然,这里的 T 不是随便选择区间内任意时刻,而是要求在这个时刻,系统中其他写操作对于读请求 R 的可见性,必须符合外部观察到的约束:
- 在
R.invoke之前已经完成并返回的写操作,它们在真实时间上完全早于这次读(且不重叠),因此必须被这次读看到,否则读就返回了过期状态 - 在
R.response之后才开始的写操作,它们在真实时间轴上完全晚于这次读,因此不能影响这次读的返回值——这是显然成立的,因为你不可能读取到时间轴上“未来”才写入的值 - 和
R时间区间存在重叠的写操作,它们的实际发生时刻既可以被排在T之前,也可以被排在T之后;只要存在一个全局串行顺序能解释所有观察到的操作返回值,linearizability 就允许这种自由度,读操作看到它们写入的值就是完全允许的- 这也就意味着,我们可以自由推迟读操作返回的时间,并允许它看到更多与自己重叠的写,而不违反线性一致性;这个观察使得后续的 batching 优化成为了可能
对于 Raft 的复制状态机模型,它里面的状态是由已经 apply 的 log prefix 定义的:假设一次读请求选择了 ReadIndex = i,那么它读取到的就是状态机执行完 log[0..i] 之后的结果。也就意味着,我们在具体实现里可以用 ReadIndex 来定义线性化时间点 T。
回到上面的约束,读操作必须看到所有在它开始之前已经完成的写,那么对于读操作 R,理论上存在一个最低必须可见的边界 L:
L = 在 R.invoke 之前已经完成并返回的所有写操作中,最高的 log index
线性一致性读只需要满足:
ReadIndex >= L
它不需要严格等于 L,如果 ReadIndex 比 L 更靠后,包含了任意数量的与 R 时间区间重叠的新增写操作,也不会有问题,因为这些写可以被安全线性化在 T 之前,只是增加了这个读请求的延迟而已。
事实上,我们也没有能力去捕获读请求到达瞬间的精确状态 L,它在分布式系统里很难被直接记录。这里我们能做的,是选择一个更保守、更靠后的 ReadIndex 位置,只要它覆盖所有必须被看到的写操作就够了。
于是,由 linearizability 定义出发,我们将需求转化为:
- 在收到读请求之后,选择一个足够安全的
ReadIndex位置,使它至少覆盖R.invoke之前所有已经完成的写,即ReadIndex >= L - 保证本地状态机已经 apply 到这个位置,然后开始进行读取
从理论需求到 Raft 实现
经过上面的理论分析,我们需要落地到具体的 Raft 实现。问题在于,我们先前定义的 L 只是一个语义上的边界,并不是 Raft 算法中维护的状态,每个节点本地能看到的只有自己的 CommitIndex 而已。因此我们需要搞明白,满足什么条件后才能够确保 CommitIndex >= L。
谁的 CommitIndex 有资格成为 ReadIndex?
首先,Raft 的写路径永远由 leader 驱动。写请求先进入 leader 的日志,再由 leader 复制到多数派;当 leader 确认某个 log prefix 已经被多数派接受后,才会推进 CommitIndex,并通过后续的 AppendEntries RPC 把这个进度通知给 followers。
因此,leader 是唯一能够主动判断 committed frontier 的节点。Follower 只是被动接收日志和 leaderCommit 进度,它的本地 CommitIndex 可能因为网络延迟和分区而任意落后。一个落后的 follower 即使本地状态完全自洽,也无法证明自己已经包含了读请求开始前所有已经完成的写。
CommitIndex >= L 何时成立?
一个节点收到读请求时,只要它自认为是 leader,直觉上就可以直接选择:
ReadIndex = Raft.CommitIndex
这似乎就是一个比 L 更保守靠后的安全位置?然后等状态机 apply 到这个位置,再从本地读即可。这个想法的问题在于:本地 CommitIndex 只是这个节点对 committed frontier 的认知。它是否足够安全,还要取决于额外的条件。
这个节点是否真的是当前有效 leader
如果它已经被网络分区隔离,而多数派已经选出了新 leader,那么新 leader 可能已经提交了新的写。旧 leader 的本地 CommitIndex 会落后于系统真实的 committed frontier。此时旧 leader 如果直接用自己的 CommitIndex 作为 ReadIndex 来处理新来的读,就可能漏掉其他已经完成的写操作。
如果这个节点刚刚成为 leader,它需要确保自己的 committed frontier 足够新
Raft Leader Completeness 属性保证的是:一个合法新 leader 的日志中包含所有 committed entries。但包含这些 entries ≠ 知道它们已经 committed——新 leader 刚当选时,它的 CommitIndex 仍然可能偏旧,因为选举的时候并不检查这个状态。
这里可以构造一个直观反例:假设某个 leader 刚刚推进了自己的 CommitIndex 并 apply,使得对应的写操作返回成功,但还没来得及广播出去就挂掉了,新选出来的 leader 不管是谁,持有的都是尚未推进的旧 CommitIndex。此时如果读请求落在新的 leader,我们选择这个落后的 CommitIndex 来作为 ReadIndex,就会漏掉在旧 leader 上已完成的写操作。
安全性保障 1️⃣:确认 committed frontier
Raft 有一个关键规则(Figure 8):新任 leader 不能仅仅因为某个旧 term 的 entry 已经复制到多数派,就直接把它 commit。Leader 只能通过提交当前 term 的 entry 来推进 commit;一旦当前 term 的某个 entry 被提交,那么它之前的所有日志都随之可以被视为 committed。
因此,新 leader 可以在刚当选时 append 一条当前 term 的 NOP entry,并把它复制到多数派。等这条 NOP 被 commit 后,leader 就可以安全地推进 CommitIndex 到 NOP 所在的位置。这个位置之前的所有 entry,包括旧 term 中可能已经完成但新 leader 尚未明确知道的 entry,现在都被纳入了 known committed frontier。
可以把这个操作理解成:
Current-term NOP commit
=> 当前 leader 在本 term 内已经提交过 log entry
=> 它可以安全确认一个 committed log prefix
=> ReadIndex 可以基于这个 log prefix 构造
安全性保障 2️⃣:多数派确认 leader 有效性
有了可靠的 committed frontier 还不够,leader 还必须证明自己没有被新的 leader 取代。但如果你熟悉 Raft,很容易有一个直觉上的困惑:这件事情本质上是证明不了的。多数派确认只能证明 leader 身份在某个过去的时间区间一定有效,却不能证明在收到回复的时候仍然有效。
事实上,我们并不需要关心收到回复的时候节点是否还是 leader。我们关心的是,只要在 [R.invoke, R.response] 时间区间内,存在过多数派确认为 leader 的时间点,那么它所对应的 CommitIndex 就能够作为合法线性化点,这个 log prefix 一定包含了 R.invoke 之前已经完成并返回的所有写——因为被多数派确认为 leader 的前提就是不缺失这些 committed 写操作。此时,即便在新 leader 上开始提交新写,也必然不可能在 R.invoke 之前完成;因为时间区间重叠,所以它们对 R 不可见就是完全合法的,可以把它们线性化到 R 之后。
Raft 论文中的做法是:leader 在处理读请求时,向其他节点发送 heartbeat,并等待多数派回复。如果多数派仍然接受它的 heartbeat,并且没有返回更高 term,那么就证明在发起 RPC 和确认多数派回复期间,一定存在一个时间区间,此时自己是合法的 leader。
虽然我们无法知道这个时间区间所对应的 CommitIndex 具体是什么,但是在工程上最干净的做法是:leader 收到多数派确认之后,直接读取当前的 CommitIndex 作为 ReadIndex 就够了,这是一个更加保守靠后的位置,自然也一定能够保证 CommitIndex >= L。
安全性保障 3️⃣:状态机等待
Raft.CommitIndex 只表示某些 log entry 已经 committed,但状态机 apply 是一个异步操作,可能会滞后。所以即使 leader 已经得到了安全的 ReadIndex,也不能立刻去读本地状态机,而是要等待状态机 apply 追上来。这里的区别在于:
ReadIndex标识应该看到哪些写LastApplied >= ReadIndex才能证明本地状态机真的看到这些写生效
Lease Read 优化
Lease read 的思想是不再要求每个读请求都去做一遍多数派确认,而是通过 heartbeat 建立一个 lease window。Leader 在某一时刻 t0 向 followers 发送 heartbeat,并通过多数派确认来证明自己的合法性。由于多数派 followers 在收到 heartbeat 后会重置 election timeout,在一个足够短的 lease window 内,它们一定不会给其他 candidate 投票。
考虑到任何新节点成为 leader 都需要收到多数派投票,而两个多数派必然相交,因此可以证明:在旧 leader 的 lease window 内,不可能选举出新的 leader。也就是说,当前 leader 的合法性能够持续到:
leaderLeaseUntil = t0 + leaseDuration
注意,这里不需要全局同步时钟,正确性依赖的是更弱的有界时钟漂移:leader 和 follower 的本地单调时钟在短时间内不要偏离太多,就足够保证正确性。具体来说就是:
leaseDuration < minElectionTimeout - clockDriftBound - timerSchedulingMargin
读请求到来时,如果当前 term 已经有过 committed entry,那么 leader 就可以直接从本地读,不需要再向多数派发送 RPC。这里的优化本质上是把每次读请求的多数派确认,换成了时间窗口内的 leader 合法性证明,从而大幅度降低了 RPC 开销。
Follower Read 与 Batching 优化
如果想要实现安全从 follower 读取,我们其实可以向 leader 请求一个认证过的安全 ReadIndex;follower 只是在拿到这个 ReadIndex 之后,等待自己的状态机复制并 apply 到该位置,才去执行本地读取。这样做的好处是,客户端请求即使先命中 follower,也不必再被重定向到 leader,处理路径显得更加自然。
本质上我们不可能绕开由 leader 来生成 ReadIndex,但是在这个基础上,可以再加入 batching 优化,用延迟来换取吞吐。因为短时间内到达的多个读请求,其实可以共享同一个 ReadIndex,所以没有必要每个请求都单独去向 leader 发送一次 RPC。更有效的做法是,先把短时间窗口内的读请求收集成一个 batch,再统一获取一次 ReadIndex,然后等状态机 apply 到该位置之后就可以一次性处理这一批请求。这样做不会改变线性一致性的语义,但可以明显减少额外的 RPC,利用读副本把整个系统的吞吐做得更高。
事实上,像 DynamoDB 这类系统并不需要支持 follower read ,一个重要原因是它们本来就提供了不同层级的读语义选择。对于大量简单 KV 访问,多数业务接受的是最终一致性读,而不是每一次都要求严格线性一致;这样读请求就可以直接在副本上消化掉,不需要额外向 leader 协调一个安全的 ReadIndex,更没有做 batching 优化的必要。换句话说,batching 主要适合“既想用读副本来分担负载,又想保证线性一致性”这样的需求。这对读取开销很轻的简单 KV 存储意义不是太大,对 TiDB 这类复杂读查询开销可以很高的分布式 SQL 才有价值。
系统设计面试中的应用
深入理解 Raft 线性一致读,有助于我们在系统设计面试中,在讨论高可用、读副本和强一致性需求时,建立正确的 mental model,避免面试官的追问往错误方向发散,同时也是展现技术深度的高价值信号。
一旦系统要求线性一致读写,我们不能简单提及“多副本”、“同步复制”或者“读写多数派”就结束,而是必须说明已完成的写请求如何成为不可回滚的事实,以及读请求如何证明自己看到了这些已经完成的写。这里面正确的设计空间其实非常有限:
- 单主读写:所有强一致读写都走 primary,这样语义简单,但 primary 显然是单点瓶颈
-
全同步复制:写请求返回前确保所有支持强一致读取的副本都已经同步,因此任意副本可以直接读;代价是写延迟和可用性都受最慢副本影响
- 这里需要注意,同步副本的集合必须是固定的,而不能像 Kafka 那样维护动态变化的 In-Sync Replica 来做动态部分同步复制,否则仍然无法直接做到线性一致性读——因为读请求可能会被发送到已经从 ISR 移除的节点
- 除非我们实现某种 fencing 机制,先保证打算移出 ISR 的慢节点彻底无法接受读请求,然后才将其移出;代价是这期间系统不可用
-
共识复制:写入只需要经过多数派确认,读取也可以通过各种手段优化吞吐;代价是要与 Raft / Paxos 这类复杂共识协议做原生整合
这个分类在面试里非常有用,比如设计一个二手车交易网站,如果核心一致性需求只集中在少量订单事务操作上,那么用 PostgreSQL 做同步复制,再加上外部 HA 控制面和明确的 failover 机制,通常就已经足够。
但如果问题规模上升到大型交易平台、库存/订单/支付系统、跨分区事务、强一致元数据管理,那么普通同步复制可能就不再是最合适的抽象。这里面的核心 tradeoff 不在于副本数越多时发送多少次 RPC 的差异,而是写路径是否被最慢副本所决定。此时如果希望避免写路径去等待最慢副本,同时仍然要求线性一致读,就必须引入底层实现了共识协议的系统——也就是 DynamoDB、Spanner、TiDB、etcd 这类原生分布式存储。
总结
Raft 线性一致读最容易让人想复杂的地方,在于我们总会下意识地把它理解成永远读到全局最新状态。但只要从 linearizability 的定义出发,一步步推导整个链条,会发现这其实是一个很弱的约束条件:
不要漏掉读请求开始前已经完成的写就够了;对于那些与读请求重叠的写,是否看到都不违反线性一致性。