4.3 日志存储

概览

SegmentLogStorage 是 braft 默认的日志存储,其以 Segment 的方式存储日志,每一个 Segment 文件存储一段连续的日志,当单个文件大小达到上限(默认 8MB)后,会将其关闭,并创建一个新的 Segment 文件用于写入。

由于日志的顺序特性以及在内存中构建索引的方式,其具有较好的读写性能:

  • 写入:追加写。一批日志先写入 Page Cache,再调用一次 sync 落盘

  • 读取:在内存中构建了 logIndex 到文件 offset 的索引,读取一条日志只需一次 IO

此外,为了保证日志的完整性,写入的时候会在 Header 中写入 CRC 校验值,并在读取的时候进行校验。

组织形式

目录结构

图 4.7 SegmentLogStorage

SegmentLogStorage 管理的目录下将会拥有以下这些文件:

  • 一个元数据文件:记录 firstLogIndex

  • 多个 closed segment:已经写满并关闭的 Segment 文件

  • 一个 open segment:正在写入的 Segment 文件

closed segmentopen segment 都有各自的命名规则:

  • closed segment:文件名格式为 log_{first_index}-{last_index},例如 log_000001-0001000,表示该文件保存的日志的索引范围为 [1, 1000]

  • open segment:文件名格式为 log_inprogress_{first_index},例如 log_inprogress_0003001,表示该文件保存的日志的索引范围为 [3001, ∞)

Segment 文件

图 4.9 Segment 文件的组成

每个 Segment 文件保存一段连续的日志(LogEntry),每条日志由 24 字节的 Header 和实际的数据组成。

Header 字段:

字段
占用位数
说明

term

64

日志的 term

entry-type

8

日志的类型:no_op/data/configuration

checksum_type

8

校验类型:CRC32/MurMurHash32

reserved

16

保留字段

data len

32

日志实际数据的长度

data_checksum

32

日志实际数据的校验值

header checksum

32

Header(前 20 字节) 的校验值

内存索引

为了快速读取日志,SegmentLogStorage 在内存中构建 2 层索引:

1. 文件索引

由于每个 Segment 文件保存的是一段连续的日志,可以构建每个 SegmentfirstIndexSegment 文件的映射。这样就可以根据日志的 logIndex 可以快速定位到其属于哪个 Segment 文件:

firstIndex
Segment 指针

1

fd=10, offset_and_term...

1001

fd=11, offset_and_term...

2001

fd=12, offset_and_term...

3001

fd=13, offset_and_term...

2. offset 索引

找到指定的 Segment 文件后,需要知道日志在该文件的 offset 以及 length,才可以一次性读取出来。为此为每个 Segment 文件构建了 logIndex 到文件 offset 的映射(即 offset_and_term),而日志的 length 可以通过 logIndex+1 日志的 offset 减去当前的 offset 算出来。

举个例子,下表为某个 Segmentoffset_and_term 映射表。从表中可以得知 logIndex=1001 这条日志的 offset1100,而其 length 可通过计算得到,为 1200-1100=100

logIndex
offset

1001

1100

1002

1200

有了这 2 层索引,我们就可以快速定位到某一条日志属于哪个文件,并且也可以迅速获得其在该文件中的 offsetlength

获取日志的 term

特别需要注意的是,在算法的执行过程中经常要获取日志的 term,如 Follower 在接收到 AppendEntries 请求时需要获取自身最后一条日志的 term,与请求中携带的 Leader 的 term 进行对比,以此来判断日志是否连续。如果每次都从文件中读取,显然是低效的,为此将日志的 term 也保存到了内存中,就是我们上述提到的 offset_and_term 映射表,其实该表映射的值不是 offset,而是 <offset,term>pair

具体实现

日志写入

LogManager 在接收到用户的日志后,会调用 SegmentLogStorage::append_entries 进行追加日志,该函数主要做以下几个工作:

Segment::append 会将日志写入到 Segment 文件,其流程如下:

sync 操作会根据相关配置来判断是不是需要执行:

获取 term

日志的 term 将直接从我们上述提到的 offset_and_term 映射表中获取即可,没有 IO 操作:

日志读取

LogManager 需要读取日志时,会优先从内存中读取,如果内存中不存在,则调用 SegmentLogStorage::get_entry 从磁盘读取日志。特别地,当节点重启回放日志时,需要从磁盘读取日志。

get_entry 会先根据日志的 index 找到对应的 Segment,再调用 Segment::get 读取对应的日志:

获取对应的 Segment

Segment::get 会先在我们上述提到的 offset_and_term 表中找到日志对应的 offsetlength,之后再调用 _load_entry 读取日志,并对日志的 HeaderdataCRC 校验:

日志删除

日志删除主要有以下 2 个接口:

  • truncate_prefix:用于节点在打完快照后删除前缀日志

  • truncate_suffix:用于 Follower 在接收到 AppendEntries 请求后,如果发现和 Leader 的不匹配,需要裁剪掉不匹配的日志:

truncate_prefix 会先将 firstLogIndex 保存到元数据文件中,然后删除所有小于 firstLogIndexSegment 文件。需要注意的是,先保存 firstLogIndex 再删除文件主要是为了保证原子性,即使删除到一半节点 Crash 了,在节点重启的时候也可以根据 firstLogIndex 继续删除剩余的文件:

truncate_suffix 的实现和 truncate_prefix 类似,都是先找到需要删除的 Segment 文件,然后将其删除:

日志恢复

节点在启动时会重建日志存储,其主要为了完成以下 2 件事情:

  • 重建我们上述提到的内存索引用于读取,包括文件索引和 offset 索引(即 offset_and_term 映射表)

  • 加载日志元数据(即 firstLogIndex),删除 firstLogIndex 之前的 Segment 文件

调用 load_meta 读取 firstLogIndex

调用 list_segments 遍历目录,获取所有的 Segment 文件,并构建文件索引:

调用 load_segments 来加载 Segment 文件,遍历其中的日志,读取每条日志的 Header(24 字节)用来构建 offset 索引:

参考

Last updated