4.3 日志存储
概览
SegmentLogStorage 是 braft 默认的日志存储,其以 Segment 的方式存储日志,每一个 Segment 文件存储一段连续的日志,当单个文件大小达到上限(默认 8MB)后,会将其关闭,并创建一个新的 Segment 文件用于写入。
由于日志的顺序特性以及在内存中构建索引的方式,其具有较好的读写性能:
写入:追加写。一批日志先写入
Page Cache,再调用一次sync落盘读取:在内存中构建了
logIndex到文件offset的索引,读取一条日志只需一次IO
此外,为了保证日志的完整性,写入的时候会在 Header 中写入 CRC 校验值,并在读取的时候进行校验。
组织形式
目录结构

SegmentLogStorage 管理的目录下将会拥有以下这些文件:
一个元数据文件:记录
firstLogIndex多个
closed segment:已经写满并关闭的Segment文件一个
open segment:正在写入的Segment文件
closed segment 和 open segment 都有各自的命名规则:
closed segment:文件名格式为log_{first_index}-{last_index},例如log_000001-0001000,表示该文件保存的日志的索引范围为[1, 1000]open segment:文件名格式为log_inprogress_{first_index},例如log_inprogress_0003001,表示该文件保存的日志的索引范围为[3001, ∞)
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 文件保存的是一段连续的日志,可以构建每个 Segment 的 firstIndex 到 Segment 文件的映射。这样就可以根据日志的 logIndex 可以快速定位到其属于哪个 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 算出来。
举个例子,下表为某个 Segment 的 offset_and_term 映射表。从表中可以得知 logIndex=1001 这条日志的 offset 是 1100,而其 length 可通过计算得到,为 1200-1100=100。
1001
1100
1002
1200
有了这 2 层索引,我们就可以快速定位到某一条日志属于哪个文件,并且也可以迅速获得其在该文件中的 offset 和 length。
获取日志的 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 表中找到日志对应的 offset 和 length,之后再调用 _load_entry 读取日志,并对日志的 Header 和 data 做 CRC 校验:
日志删除
日志删除主要有以下 2 个接口:
truncate_prefix:用于节点在打完快照后删除前缀日志truncate_suffix:用于 Follower 在接收到AppendEntries请求后,如果发现和 Leader 的不匹配,需要裁剪掉不匹配的日志:
truncate_prefix 会先将 firstLogIndex 保存到元数据文件中,然后删除所有小于 firstLogIndex 的 Segment 文件。需要注意的是,先保存 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