4.2 复制优化
优化 1:Batch
Batch 队列

从客户端调用 apply 接口提交 Task 到日志成功复制,并回调用户状态机的 on_apply 接口,日志依次经过了 Apply Queue、Disk Queue、Apply Task Queue 这 3 个队列。这些队列都是 brpc 的 ExecutionQueue 实现,其消费函数都做了 batch 优化:
ApplyQueue: 用户提交的
Task会进入该队列,在该队列的消费函数中会将这些Task对应的日志进行打包,并调用LogManager的append_entries函数进行追加日志,打包的日志既用于持久化,也用于复制。默认一次打包 32 条日志。DiskQueue:
LogManager在接收到这批日志后,需对其进行持久化处理,故会往该队列中提交一个持久化任务(一个任务对应一批日志);在该队列的消费函数中会将这些任务进行打包,将这批任务对应的所有日志写入磁盘。默认一次最多打包 256 个持久化任务,而每个任务最多包含 32 条日志,所以其一次Bacth Write最多会写入256 * 32 = 8192条日志对应的数据。当然其也受字节数限制,默认每次Batch Write最多写入256KB。ApplyTaskQueue:当日志的复制数(包含持久化)达到
Quorum后,会调用on_committed往ApplyTaskQueue中提交一个ApplyTask(每个ApplyTask对应一批已提交的日志);在该队列的消费函数中会将这些ApplyTask打包成Iterator,并作为参数回调用户状态机的on_apply函数。 默认一次最多打包 512 个ApplyTask,而每个ApplyTask最多包含 32 条日志,所以每一次on_apply参数中的Iterator最多包含512 * 32 = 16384条日志。
从以上看出,日志的复制、持久化、应用,全链路都是经过 Batch 优化的。
Follower 的 Batch
以上讨论的是节点作为 Leader 时的
Batch优化,当节点为 Follower 时,其优化也是一样的,因为其用的是相同的代码逻辑,唯一的区别在于:
ApplyQueue: Follower 不会接受用户提交的日志(
Task),其批量日志来源于 Leader 的复制DiskQueue: 日志批量落盘的逻辑是一样的,Follower 在接收到 Leader 的一批日志之后也是直接调用
LogManager的append_entries函数ApplyTaskQueue: 批量
on_apply的逻辑是一样的,区别在于 Follower 的commitIndex来源于 Leader 在 RPC 中携带的commitIndex,并非通过自身的Quorum计算
相关配置
当然,框架也提供了一些配置项来调整这些 Batch 大小:
ApplyQueue
raft_apply_batch
32
该打包大小影响复制、持久化以及on_apply;上限为 512
DiskQueue
raft_max_append_buffer_size
256 KB
每次 Batch Write 最大的字节数
ApplyTaskQueue
raft_fsm_caller_commit_batch
512
每次 on_apply 的最大日志数为 raft_apply_batch * raft_fsm_caller_commit_batch
另外,配置
raft_max_entries_size也会影响每次复制(AppendEntries请求)携带的日志数量,其默认值为 1024
具体实现
ApplyQueue 消费函数:
DiskQueue 消费函数:
ApplyTaskQueue 消费函数:
优化 2:并行持久化日志
持久化与复制

在 Raft 的实现中,Leader 需要先在本地持久化日志,再向所有的 Follower 复制日志,显然这样的实现具有较高的时延。特别地客户端的写都要经过 Leader,导致 Leader 的压力会变大,从而导致 IO 延迟变高成为慢节点,而本地持久化也会阻塞后续的 Follower 复制。
所以在 braft 中,Leader 本地持久化和 Follower 复制是并行的,即 Leader 会先将日志写入内存,同时异步地进行持久化和向 Follower 复制。并且只要大多数节点写入成功就可以应用日志,不必等待 Leader 本地持久化成功。
具体实现
具体的实现我们已经在上一节中详细介绍过了,参见 <4.1 复制流程>。
优化 3:流水线复制
pipeline

Raft 默认是串行复制日志,需要等待一个 AppendEntries 发送成功后再发送下一个,显然这样不是最高效的。可以采用 Pipeline 的复制方式,即 Leader 发送完 AppendEntries 请求后不必等待其响应,立马发送一下批日志。当然,这样的实现对于接受端(Follower)来说,可能会带来乱序、空洞等问题,为此,braft 在 Follower 端引入了日志缓存,将不是顺序的日志先缓存起来,待其前面的日志都接受到后再写入该日志,以达到日志连续的目的。
特别需要注意的是,pipeline 优化默认是关闭的,需要用户通过以下 2 个配置项开启:
具体实现
Leader 端 pipeline 的流程如下:
(1) 发送
AppendEntries请求时会判断并行的请求数(flying)是否达到并行上限(1.1) 如果是的话,直接返回;等待收到响应后回调
_on_rpc_returned将flying计数减一(1.2) 否则继续发送
AppendEntries请求,并将flying计数加一
(2) 发送完后,继续判断
flying数是否已达限,是的话如同步骤(1.1)(3) 否则,判断是否还有日志可以发送:
(3.1) 有的话,重复步骤(1)
(3.2) 否则注册
waiter等待新日志到来
具体流程见下面代码解析:
Follower 接收到 AppendEntries 请求后,其处理逻辑如下:
对于到来的日志,如果其前面的日志没有接受到,先将其缓存起来,并要求 Leader 重发前面的日志
待前面的日志都达到后,再将缓存中在其之后的顺序的日志一起写入磁盘
优化 4:raft sync
sync 配置
日志每次 Batch Write 是先将日志写入 Page Cache,最后再调用一次 fsync 操作。显然 sync 操作会增加时延,为此 braft 提供了一些配置项来控制日志的 sync 行为。用户可以根据业务数据丢失的容忍度高低,灵活调整这些配置以达到性能和可靠性之间的权衡。例如对于数据丢失容忍度较高的业务,可以选择将配置项 raft_sync 设置为 Flase,这将有助于提升性能。
以下是控制日志 sync 的相关参数,前三项针对的是每次的 Batch Write,最后两项针对的是 Segment:
raft_sync
每次 Batch Write 后是否需要 sync
True
raft_sync_policy
对于每次 Batch Write 后的 sync 策略,有立即 sync (RAFT_SYNC_IMMEDIATELY)和按字节数 sync (RAFT_SYNC_BY_BYTES)
RAFT_SYNC_IMMEDIATELY
raft_sync_per_bytes
在 RAFT_SYNC_BY_BYTES 的策略下,每多少字节 sync 一次
INT32_MAX
raft_sync_segments
每当日志写满一个 Segment 需要切换时是否需要 sync,每个 Segment 默认存储 8MB 的日志
False
raft_max_segment_size
单个日志 Segment 大小
8MB
具体实现
Batch Write 写入 Page Cache 后会调用 Segment::sync 进行 sync 操作:
每当一个 Segment 文件写完后,都会调用 Segment::close 将其转换成 closed segment:
优化 5:异步 Apply
当日志被提交时,框架会串行调用用户状态机的 on_apply,虽然这里做了 Batch 优化,但是对于那些不支持批量更新的状态机来说,仍然是低效的。为此,用户可以将 on_apply 函数异步执行,让那些可以并行的操作尽可能并行起来。
需要注意的是,当 on_apply 异步后,需要处理好节点成为 Leader 时日志回放的问题。当节点刚成为 Leader 时,需要回放(on_apply)之前任期的日志,这时候需要将这些日志全部应用到状态机后才能处理读取操作,不然可能会违背线性一致性。因为这些日志在之前的任期可能被提交了,客户端能读取到,而在新任期回放的时候,由于这些日志是异步执行的(on_apply 返回了,但还在异步执行中),可能还没应用到状态机,这时候客户端去读取可能是读取不到的。
参考
Last updated