Linearizability and Raft
线性一致性(Linearizablity)是一种判断并发系统正确性的标准,其形式化的定义与证明在 TOPLAS 1990 的论文 Linearizability: A Correctness Condition for Concurrent Objects 中给出。论文中作者将并发程序的执行历史抽象为在对象上执行的一系列操作,每次操作在时间上被调用(invocation)和响应(response)两个事件所限定。基于此,执行历史的线性一致性的形式化定义如下(省略 N 页前置定义与引理的证明):
A history H is linearizable if it can be extended (by appending zero or more response events) to some history H such that:
- complete(H') is equivalent to some legal sequential history S, and
线性一致的基本想法是让一个并发系统看起好像只有一个数据副本,且所有的操作都是原子的。对于一个并发对象的实现,如果其上的所有 possible history 都线性一致的,那么该对象实现也是线性一致的。此处的“并发对象”如果代换为寄存器,那么操作可以是读/写/自增/CAS等;如果是队列,操作可以是入队/出队。论文提出了一个并发队列的线性一致实现,可以从中感受到并发系统简单而优雅的美:
Enq = proc (q: queue, x: item)
i: int := INC(q.back) % Allocate a new slot (return old value)
STORE(q.items[i], x) % Fill it.
end Enq
Deq = proc (q: queue) returns (item)
while true do
range: int := READ(q.back) - 1
for i: int in 1 . . range do
x: item := SWAP(q.items[i], null)
if x -= null then return(x) end
end
end
end Deq
# STORE, INC, READ, SWAP are all atomic
一个常见的误解是 Raft 和 Paxos 等算法是线性一致的。个人感觉二者没有必然联系。线性一致性(以及其它一致性级别)概念来自并发编程领域而非分布式领域,讲的是如何判断并发对象的实现是正确的。共识算法本身只是用于实现并发对象的工具,你可以把它实现为线性一致的,也可以实现为非线性一致的,共识算法的定义也没有基于线性一致性的模型,“操作”“调用”与“返回”在共识算法的问题域中压根就没提到。
因此,如果要在分布式系统中讨论线性一致性,就得将前者的系统以某种方式规约到后者所使用的模型中。6.824 lab 用 Raft 实现了分布式 KV 存储,将 Put/Get/Append 三种操作包装为库函数提供给用户程序,因此一个直观的做法是基于库函数的调用与返回来讨论线性一致性。
线性一致性和 Raft 这篇博客里提到将每次调用的 log index 顺序作为线性一致性的执行历史排序。这是一个典型的概念混淆,因为一次用户程序调用(读写)可能会留下多条日志(当遇到网络问题/机器崩溃 RPC 需要重试的时候),每条日志的返回值可能不一样,那么以哪条返回值为准呢?当然一种简单的做法是通过 clientID 与序列号去重(如 Raft 原论文那样),以去重结果定义线性执行历史排序,但在实现上保证最终用去重留下的 log 响应 client 这种机制并没带来什么 benefit,只是徒耗时间精力而已。
Read Index
线性一致读要求 Client 写完成后的结果能立即被其它任意 Client 读到。Raft 状态机要支持线性一致读,最简单粗暴的做法是 Log read 即 leader 将读请求写入日志,等到提交后再响应给 client。但从概念上讲,日志是对状态机的状态的改变,读请求本身不是共识的一部分,没有必要写进日志中。增加不必要的共识内容给系统带来额外的性能损耗。
直接从 leader 无脑读肯定不可取。当 leader 网络中断,他自以为还是 leader,乐此不疲地向所有人发送心跳包,但墙外大家早就选出了新 leader 并提交了新日志,此时从旧 leader 读到的状态机就处于落后的状态。Raft 原论文提到了两种 log-free read 机制,分别是 Read Index 与 Lease Read,后者只是提了一句,前者作者用一小段简述了其机制。
Read-only operations can be handled without writing anything into the log. However, with no additional measures, this would run the risk of returning stale data, since the leader responding to the request might have been superseded by a newer leader of which it is unaware. Linearizable reads must not return stale data, and Raft needs two extra precautions to guarantee this without using the log. First, a leader must have the latest information on which entries are committed. The Leader Completeness Property guarantees that a leader has all committed entries, but at the start of its term, it may not know which those are. To find out, it needs to commit an entry from its term. Raft handles this by having each leader commit a blank no-opentry into the log at the start of itsterm. Second, a leader must check whether it has been deposed before processing a read-only request (its information may be stale if a more recent leader has been elected). Raft handles this by having the leader exchange heartbeat messages with a majority of the cluster before responding to read-only requests.
简单地说,Read Index 机制就是 Leader 在收到读请求时进行如下几步:
- 如果 Leader 在当前任期还没有提交过日志,先提交一条空日志
- Leader 保存记录当前 commit index 作为
readIndex - 通过心跳,询问成员自己还是不是 Leader,如果收到过半的确认,则可确信自己仍是 Leader
- 等待 Apply Index 超过
readIndex - 读取数据,响应 Client
Discussion
为什么要提交空日志
原论文中对此的解释如下:
The Leader Completeness Property guarantees that a leader has all committed entries, but at the start of its term, it may not know which those are. To find out, it needs to commit an entry from its term.
乍一眼看 leader known all committed entries 与线性一致性好像没什么显而易见的关系,但 read index 机制下读请求需要获取当前的 commitIndex,leader 切换前后可能观察到 commitIndex 回退,这就造成了问题。如果没有空日志,设想如下情况:
- Server A (leader) 提交了 log 10
- Server A 响应 Client 读请求,此时 commitIndex=10
- Server A 还没来得及通过心跳包将新的 commitIndex 发送该 followers 就崩溃了,此时其它 servers 仍然有 commitIndex=10( log 10 之前已经发过去了但还没提交)
- 等新一轮选举结束,server B 成为 leader,Client 再发起读请求,此时 B 有 commitIndex=8,如果 log 9 恰好修改了读取的内容,就使得读到了旧数据。
而第四步时的 server B 能不能一把子把 log 10 提交掉呢?这个是 Raft 的经典问题之 leader 为什么不能直接提交前任的日志。Raft 原论文中用 Figure 8 解释之。简而言之就是,前任的日志是有可能被覆盖的——新来的 leader 如果非要提交某条前任 term=x 的日志 X(如图中 2),此时它并不知道有没有 term=x+1(如图中 3)的日志 Y,如果带有 Y 的 server 在下次选举中获胜(如图中 S5),就有可能用 Y 覆盖掉 X,从而违背了 Leader Completeness 原则(已提交的日志不能被覆盖)。
为什么要等 apply index
这一点在 Raft 原论文中并未提到。这篇论文里 OngaroPhD.pdf 仅提到这样可以 "satisify linearizability"。线性一致性和 Raft 这篇博客解释称 “状态机应用到 ReadIndex 之后的状态都能使这个请求满足线性一致”,但好像没说为啥 readIndex 之前的状态就不能满足线性一致性?我想了半天恍然大悟——在我实现的 Raft 中,写请求对应的日志应用(apply)之后才响应 client,因此没有这个问题。而如果写操作本身是比较重的,那么响应无需等待状态机应用,只需确认该日志提交就可以了。如下图所示。
中,不实现 apply index 也会造成问题。由于 Raft 中 commitIndex 与状态机的状态是没有及时持久化的,当所有机器同时重启后,任何一台 commitIndex=0 的机器都有可能赢得选举,之后 leader 通过心跳包的响应更新 matchIndex 数组,逐渐将现有日志全标记为已提交。如果在此过程中遇到读请求,同样会将未完全恢复的 commitIndex 返回给 client,使其得到旧数据。因此无论如何都要实现等待 readIndex 被应用。
实现
Merge branch 'readidx' · jy5275/mit-6.824-lab@c7ec576
从 Log Read 改为 Read Index 后,lab3 所有用例平均运行时间从 570s 缩短到 470s