PolarFS的ParallelRaft
简介
本文摘自阿里在PVLDB的一篇论文《PolarFS: An Ultra-low Latency and Failure Resilient Distributed File System for Shared Storage Cloud Database》,其中介绍了一种比Raft更高效的一致性协议ParallelRaft。
Raft是一个比Paxos更易于理解与实现的一致性协议(consensus protocol),详细介绍参考Raft协议详解和分布式系统的Raft算法。
理解ParallelRaft首先要理解Raft协议。
ParallelRaft是为PolarFS服务的,PolarFS是一个文件系统。文件系统中有数据块的含义,文中有些地方会涉及到。
文中并没有提出一个更为应用广泛的协议,特别针对了PolarFS做描述,不过可以根据自己的业务场景,按照文中的理论做处理。
Raft的缺陷
为了简单和容易理解,Raft设计成了高度串行化的协议。Leader和Follower的日志都不允许出现空洞,这就是说follower应答的日志、leader提交和所有副本应用(apply)的日志,都是按照顺序的。这样当写请求并行执行的时候,也要按照顺序提交。队列前面的请求还没有处理,在队列尾部的请求就不能提交和应答,这样就增大了平均延迟和吞吐。
Leader和Follower之间有多个连接时,这种串行的方式就不太合适。如果某个连接卡住了或者变慢了,备机就会收到乱序的日志,而且不能做应答。
ParallelRaft解决了这个问题,下面通过日志复制、leader选举和catch up(follower追赶leader)方面描述。
leader和follower之间是单链路连接就可以保证一定是串行吗?明显不是的。不过单链接根本不用考虑并行优化。
乱序日志复制(Out-of-Order Log Replication)
Raft从两个方面串行化:
- leader向follower发送一个日志,follower在收到并且记录下来之后,向leader发送应答,这还表示这条日志之前的日志也都收到并且保存下来了;
- leader提交一个日志,向所有的follower广播消息,就意味着所有之前的日志都已经提交。
ParallelRaft打破了这个约束,并且乱序执行。为了保证协议的正确性,要做到:
- 没有串行化约束,所有提交的事务与典型的关系型数据库语义保持一致(应该是指ACID方面的语义);
- 所有提交的事务不会丢失。
乱序执行,怎么保证新版本的数据不会被旧版本数据覆盖?
换句话说,apply某个数据时,怎么保证不会覆盖与之有冲突的还未收到的数据。
如果某个范围的写入与其它的写入没有重叠,那么这些日志就没有冲突,可以以任意顺序执行。否则,冲突的写入操作必须按照顺序来执行。这样就保证了新修改的数据不会被旧的修改操作覆盖。
ParallelRaft现在为文件系统PolarFS服务,所以数据是否有冲突,只要对比写入的数据块范围是否有重叠即可。如果是数据库,就需要检查是否修改了同一行,或者更精细一点,是不是修改了同一个字段。
乱序应答Out-of-Order Acknowledge
一旦日志持久化成功,follower就给leader应答。这样减少了平均延迟时间,因为不用等前面的日志。
不完全准确,这里的“乱序”并不是绝对的,最终还是要确定一个顺序。在“乱序”达到一定程度时,还是要等待前面的日志。
乱序提交 Out-of-Order Commit
某个日志收到大多数follower应答之后,leader就提交。
抛出问题:收到较大编号的日志提交后,怎么保证之前的日志一定会提交成功?因为raft不允许出现空洞。使用“空”来弥补吗?
Apply带空洞的日志 Apply with Holes in the Log
因为日志不一定是按照顺序写入的,那么肯定会有些场景出现空洞。怎么保证日志有空洞时,apply的正确性?
其实这个就介绍了怎么保证旧版本数据不会覆盖新版本数据。
ParallelRaft引入了一个新的数据结构:look behind buffer
。每个日志中都有一个这样的数据结构,look behind buffer包含前面N条日志修改的逻辑块地址(Logical Block Address),这样的话,look behind buffer
就像一个日志空洞上的桥。N是这个桥的长度,就是允许出现的最大空洞。
注意虽然日志中可能存在多个空洞,所有日志条目(log entries)的逻辑块地址汇总信息总是完整的,除非有空洞比N大。
follower可以通过这个结构确定某条日志(log entry)是否有冲突,是指与在这条日志前面的,但是还未收到的日志修改的逻辑块地址是否有重叠。不与其它日志冲突的可以安全的apply,否则就加到一个pending列表中,稍后再apply。
文中说N选择为2,就是空洞最大是2条日志。
选主 Leader Election
跟Raft一样,ParallelRaft也是选择拥有最新term和最多事务日志的节点为主。
不过ParallelRaft选出来的主,可能会存在带空洞的日志。这样就需要把空洞补上。
选举为主之后,主的状态不是leader
,而是leader candidate
。成为真正的主之前增加一个过程,merge stage
(合并阶段)。在合并阶段先从其它节点上找到缺失的日志,然后合并到本机。完成之后,状态变为leader
。
日志合并
与Raft语义一样,具有相同term和index的日志保证是相同的。但是有一些特殊条件:
- 对于一个提交过的,但是不存在的日志(指在本机上不存在),leader候选人(leader candidate)可以最少从一个follower中找到这条日志。因为日志提交的条件是已经复制到了大多数节点。可以通过询问follower节点得知是否已经提交。
- 对于所有节点都没有提交的日志,可以直接跳过这条日志。按照Raft的协议机制,这种日志不能提交,因为没有被大多数节点接受。(我认为不是的,如果之前的主将数据发送到了备机,备机也都给了应答,但是还没来得及将commit命令发给备机,那么这条日志在follower上也是未提交的。也可能是没有理解透文中的意思。修正:这里对raft committed概念理解错误,raft原文中是这么描述committed的:The leader decides when it is safe to apply a log entry to the state machines; such an entry is called committed. Raft guarantees that committed entries are durable and will eventually be executed by all of the available state machines. A log entry is committed once the leader that created the entry has replicated it on a majority of the servers。就是说Leader将日志复制到了大多数节点,并且这些日志在大多数节点已经持久化durable,就是committed的,并且最终会被执行。)
- 如果某些节点保存的日志,index相同,但是term不同,那么leader候选人就选择term最大的日志:
3.a ParallelRaft的合并阶段必须在新leader提供服务之前完成。这个决定了 某个日志设置了更大的term,index相同但是term比较小的日志不能提交,比较小的term的日志也不能出现在之前完全成功的合并阶段,否则更大term的日志不能有相同的index。
3.b 系统宕机时,如果某个节点中包含了一个未提交的日志,这个日志的应答也可能已经发给了之前的leader,leader也给用户回复了。这样的话就不能简单的丢掉这条日志,否则就会造成数据丢失。更精确的描述,如果宕机的节点总数加上拥有这条未提交事务(拥有相同index最大term的日志)节点个数超过了其它节点的个数,那么这条日志就可能提交了。这样的话,就应该提交这条日志。
merge要确定本机不存在的空洞日志状态是如何的。要保证给客户端回复成功的日志不能丢,回复失败的不能提交,回复不确定的或者没有回复的日志,提交或不提交都是合理的。这几条推理,就是为了确认某条日志,是不是给客户端回复过。提交过的日志肯定是给客户端回复过的,follower上未提交的日志,可能是已经提交的,也可能没有提交。这里给出的是尽量按照已经提交的方法来判断(拥有这个日志的节点个数 + 宕机节点个数 > 不包含这条日志的节点个数)。
这个图片描述了3个副本的示例。
- follower发送本地日志给
leader candidate
,leader candidate
收到日志与本地日志合并; leader candidate
同步状态信息到follower;leader candidate
提交日志并且通知follower提交;leader candidate
升级成leader
。
通过上面的机制,新leader会拥有所有已经提交的日志,就是说ParallelRaft不会丢数据。
checkpoint
ParallelRaft会不时地做checkpoint,checkpoint会记录一个当前系统的快照(snapshot),所有checkpoint之前的日志都已经apply到了数据块上,checkpoint允许包含checkpoint之后的日志(文中没有解释为什么,也没有介绍怎么处理的)。在实际实现中,ParallelRaft会选择有用最新checkpoint的节点作为主,而不是拥有最新日志的节点,原因就是catch up的实现(为了让follower更容易的追上leader的状态)。在merge阶段拥有最新checkpoint的节点更容易处理,因为不需要处理checkpoint之前的日志(就是checkpoint越新,相对来说需要做merge的日志就越少)。
Catch Up
一个滞后的follower追上leader,就称为Catch Up
。
有两种机制:fast-catch-up
和streaming-catch-up
。
fast-catch-up
是follower与leader差距比较小时,使用增量的方式进行同步(同步两个节点的状态可能需要同步checkpoint数据和日志,同步日志就是增量的方式,只需要同步部分数据)。
streaming-catch-up
相对应的,follower与leader差距比较大的时候使用这个方法,比如follower停机了很多天。
两者在程序上的区别主要是leader判断能否使用增量同步的方式将日志复制到follower中。如果leader中的checkpoint比follower中的最新日志还要新,那么就只能使用streaming-catch-up
了,就是全量同步checkpoint + log entries
。
如果使用fast-catch-up
方法的话,follower的checkpoint后面的日志也可能包含空洞,这些空洞也使用look behind buffer来查找,直接从leader的数据中复制过来。
下面这个图片描述了follower追上(catch up)leader的3个场景,根据Leader的信息和Follower的信息来判断需要全量复制(checkpoint + log entries)还是增量复制(log entries)。
ParallelRaft的正确性
ParallelRaft与Raft最大的区别就是乱序提交。所以关键点就在新选举出来的leader怎么处理这些日志中的空洞。ParallelRaft引入merge阶段来保证不会有事务丢失,进而保证了leader的正确性(Safefy,不知道怎么翻译更准确)。
ParallelRaft通过look behind buffer
来保证有冲突的事务不会乱序提交,从而保证状态机的正确性(State Machine Safefy)。
另外,Election Safefy、Leader Append-Only和Log Matching与Raft一样,没有变化。