注意:本文档已过期。应对其进行更新,以反映与Raft共识相关的设计决策和实现[1]。
本文档介绍了Kudu如何使用Viewstamped Replication(VS)算法和一系列用于恢复,重新配置,压缩等的实用算法/技术来处理日志复制和一致性。本文档介绍了与Kudu直接相关的所有概念,任何遗漏信息请参考原始论文[1,3,4]。
Kudu中的Quorums是一组协进程,其目的是在给定数据集(例如tablet)上保持一致性,备份操作日志。这个备份的一致性日志也扮演者tablet的预写日志(WAL)的角色。
预写日志(WAL)
WAL提供严格的有序性和持久性保证:
- 如果对Reserve()的调用是外部同步的,数据被保存的顺序就是它们将被提交到磁盘的顺序。
- 如果启用了fsync(通过’log_force_fsync_all’标志 -请参阅log_util.cc;注意:默认情况下这是禁用的),那么保证每个事务在执行成功之前都会同步到磁盘。这也是为了防止进程突然挂掉导致未操作成功的数据丢失。
为了提高多线程的大规模数据写入性能,同时保持稳定的延迟,log采用group commit。
基本的WAL使用
要向日志添加操作日志,调用者必须获取锁,并使用一组操作集合作为参数调用Reserve(),返回指向保存数据的指针。然后,调用者可以释放锁并使用保存的数据和和回调函数作为入参调用AsyncAppend()方法。AsyncAppend方法在锁外执行序列化和复制。
有关使用例子,请参阅mt-log-test.cc。
组提交实现细节
目前,组提交的实现使用阻塞队列(请参阅log.h中的Log :: entry_queue_)和一个单独的长时间运行的线程(请参阅log.cc中的Log :: AppendThread)。由于通过锁同步访问队列,并且只有一个线程在删除队列,因此将元素添加到队列的顺序与从队列中删除元素的顺序相同。
队列的长度依赖于entries的数量,但最终会转化成entries的bytes形式的长度。
为entry分配一个slot
目前,Reserve()会给堆上的新entry分配内存,通过状态枚举在内部将entry标记为“保留”,并将其添加到上述队列中。将来,可以使用环形缓冲区或其他类似的数据结构来代替队列并且不需要分配。
将entry内容复制到保留的slot
AsyncAppend()将entry的内容序列化为entry对象中的缓冲区字段(当前缓冲区与条目本身同时分配); 这可以避免在使用共享缓冲区时发生资源竞争。
将条entry容同步到磁盘
一个单独的appender线程等待,直到将条目添加到队列中。一旦队列不再为空,该线程就会抓取队列中的所有元素。然后,对于每个被取出列的entry,appender等待,直到entry标记为ready 状态,然后将条目附加到当前日志段,而不是写入底层文件。
注意:这可以通过使用一个vector(包含所消费过的entry的缓冲区)作为参数调用AppendVector()来进一步优化。
成功添加所有entry后,appender线程将文件同步到磁盘(env :: WritableFile :: Sync())并再次等待,直到将更多条目添加到队列,或者直到队列或appender线程关闭为止。
记录段文件和异步预分配
Log使用PosixWritableFile()进行底层存储。如果启用了预分配(’ - log_preallocate_segments’标志,在log_util.cc中定义,默认情况下为true),那么无论何时创建新段,基础文件都会预先分配到特定大小(以兆字节为单位)(’ - log_segment_size_mb’,已定义)在log_util.cc中,默认为64)。当段文件中的偏移低于预分配长度时,会使用更廉价的fdatasync()操作代替fsync()。
当前段的大小超过预分配大小时,将在单独的线程中会启动一个任务,该线程开始预分配新日志段的基础文件; 在新日志文件分配完成前,仍然向现有文件追加。一旦新文件被预先分配,它将被重命名为下一个段的正确名称,并被替换为当前段。
当当前段关闭而未达到预分配大小时,基础文件被截断为最后写入的偏移量(即实际大小)。
配置以及配置中的角色
Kudu中的配置是一个容错,一致性的单元,可以处理单个tablet的请求。如果配置中有2f + 1个参与者,其中故障的参与者不超过f个,配置将继续为其tablet请求提供服务,并保证客户端在使用数据时能够感知到数据的完全一致性。对该数据的操作。f参数可以决定节点的规模,f = 0表示单节点配置,f = 1表示3节点配置,f = 2表示5节点配置等。Quorums 可能会重叠,每台物理机可能会参与多个配置。
在单个配置中,在稳定状态下,即当没有对成员出现故障时,有两种主要类型的成员。领导者和追随者。领导者成员规定了整个配置中的操作的序列化,其数据更改请求序列的版本是“真实性”,并且当多数配置确认他们“同意”领导者对事件顺序的看法时,任何数据更改请求仅被认为是最终的(即,可以被客户确认为成功)。实际上,这意味着所有写入请求都直接发送给领导者,然后领导者在向客户端发送ACK之前将其复制到大多数追随者。追随者同伴在稳定状态下完全被动,仅接收来自领导者的数据并确认回来。追随者只有在领导者停止的时候才变得活跃,然后他们中的一个会被选举为新的领导者。
可以为配置中的参与者分配以下角色:
LEADER - 配置的当前领导者,接收来自客户端的请求并将它们序列化到其他节点。
FOLLOWER - 配置中的活跃参与者,他们参与投票选主。
LEARNER - 配置中的被动参与者,不参与投票。加入配置的新节点将具有此角色,直到它们赶上并可以提升为FOLLOWER。
NON_PARTICIPANT - 不参与特定配置的成员。
下图说明了可能的状态更改:
+------------+
| NON_PART +---+
+-----+------+ |
Exist. RaftConfig? | |
+-----v------+ |
| LEARNER + | New RaftConfig?
+-----+------+ |
| |
+-----v------+ |
+-->+ FOLLOW. +<--+
| +-----+------+
| |
| +-----v------+
Step Down +<--+ CANDIDATE |
^ +-----+------+
| |
| +-----v------+
+<--+ LEADER |
+------------+
所有状态都可以在配置更改和/或成员超时/死亡时转换为NON_PARTICIPANT。
创建/重新启动RaftConfig以及RaftConfig状态
在启动/重新启动成员之前,WAL中的状态必须在引导阶段重放。此过程将生成最新的日志和tablet。然后使用此日志对新的/重启成员进行Init()。查询最后一个提交的配置entry 的日志(一个Raft配置由一组成员(uuid和最后已知的地址)和上述的角色组成)。如果没有,则表示这是一个新配置。
在成员已经被Init()之后,调用Start(Configuration)。提供的Configuration是一个默认值,只有在没有先前的配置*时才会考虑该值。
无论配置是新配置(新配置)还是旧配置(重新启动配置),配置都无法启动,直到领导者被选出并复制配置达到一致。这可确保大多数节点同意这是最新配置。
提供的配置将始终指定一个领导者 - 在新配置的情况下,它由主服务器选择;在重新启动的配置的情况下,它是在节点崩溃之前处于活动状态的配置。在任何一种情况下,复制此初始配置entry的方式与任何其他配置entry完全相同,即LEADER将尝试将其复制到FOLLOWERS。像往常一样,如果LEADER失败,则会触发领导者选举,新的LEADER将尝试复制新配置。
只有在配置成功复制初始配置entry后,配置才能准备接受写入。
因此,配置中的成员可以处于以下状态:
BOOTSTRAPPING - 正在重放日志的初始化之前的阶段。如果大多数成员仍然是BOOTSTRAPPING,则配置尚不存在。
CONFIGURING:保持这个状态直到当前配置被推动达成一致。对于新配置和重新启动配置都是如此。这个状态下成员不接受客户端请求。在此状态下,Leader尝试复制配置。如果提示的领导者未在配置的超时期限内成功复制,则追随者运行故障检测并触发领导者选举。
RUNNING:LEADER成员接受写入并通过一致性算法复制它们。FOLLOWER副本接受来自领导者的写入并发送ACK。
- 只有在存在适当的领导者选举算法时,才能考虑Start()上提供的配置。这可以在以后添加,但在初始实现中不存在。在配置启动器(通常是主设备)可能暗示配置中对等体的角色应该是什么的意义上暗示了角色,但配置是关于这是否可能的最终决定。