4.2 复制优化

优化 1:Batch

Batch 队列

图 4.4 Batch 优化

从客户端调用 apply 接口提交 Task 到日志成功复制,并回调用户状态机的 on_apply 接口,日志依次经过了 Apply QueueDisk QueueApply Task Queue 这 3 个队列。这些队列都是 brpc 的 ExecutionQueue 实现,其消费函数都做了 batch 优化:

  • ApplyQueue: 用户提交的 Task 会进入该队列,在该队列的消费函数中会将这些 Task 对应的日志进行打包,并调用 LogManagerappend_entries 函数进行追加日志,打包的日志既用于持久化,也用于复制。默认一次打包 32 条日志。

  • DiskQueue: LogManager 在接收到这批日志后,需对其进行持久化处理,故会往该队列中提交一个持久化任务(一个任务对应一批日志);在该队列的消费函数中会将这些任务进行打包,将这批任务对应的所有日志写入磁盘。默认一次最多打包 256 个持久化任务,而每个任务最多包含 32 条日志,所以其一次 Bacth Write 最多会写入 256 * 32 = 8192 条日志对应的数据。当然其也受字节数限制,默认每次 Batch Write 最多写入 256KB

  • ApplyTaskQueue:当日志的复制数(包含持久化)达到 Quorum 后,会调用 on_committedApplyTaskQueue 中提交一个 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 的一批日志之后也是直接调用 LogManagerappend_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:并行持久化日志

持久化与复制

图 4.5 持久化日志的串行与并行

在 Raft 的实现中,Leader 需要先在本地持久化日志,再向所有的 Follower 复制日志,显然这样的实现具有较高的时延。特别地客户端的写都要经过 Leader,导致 Leader 的压力会变大,从而导致 IO 延迟变高成为慢节点,而本地持久化也会阻塞后续的 Follower 复制。

所以在 braft 中,Leader 本地持久化和 Follower 复制是并行的,即 Leader 会先将日志写入内存,同时异步地进行持久化和向 Follower 复制。并且只要大多数节点写入成功就可以应用日志,不必等待 Leader 本地持久化成功。

具体实现

具体的实现我们已经在上一节中详细介绍过了,参见 <4.1 复制流程>

优化 3:流水线复制

pipeline

图 4.6 串行与 Pipeline

Raft 默认是串行复制日志,需要等待一个 AppendEntries 发送成功后再发送下一个,显然这样不是最高效的。可以采用 Pipeline 的复制方式,即 Leader 发送完 AppendEntries 请求后不必等待其响应,立马发送一下批日志。当然,这样的实现对于接受端(Follower)来说,可能会带来乱序、空洞等问题,为此,braft 在 Follower 端引入了日志缓存,将不是顺序的日志先缓存起来,待其前面的日志都接受到后再写入该日志,以达到日志连续的目的。

特别需要注意的是,pipeline 优化默认是关闭的,需要用户通过以下 2 个配置项开启:

具体实现

Leader 端 pipeline 的流程如下:

  • (1) 发送 AppendEntries 请求时会判断并行的请求数(flying)是否达到并行上限

    • (1.1) 如果是的话,直接返回;等待收到响应后回调 _on_rpc_returnedflying 计数减一

    • (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 策略,有立即 syncRAFT_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