braft snapshot实现

snapshot作用

snapshot是raft算法的四大特性之Log Compaction。
snapshot的主要作用,对本节点来说:

  • Log Compaction,在完成Snapshot完成之后,这个时间之前的日志都可以被删除了,这样可以减少日志占用的资源
  • 启动加速,启动阶段变为加载Snapshot和追加之后日志两个阶段,而不需要重新执行历史上所有的操作.
    对整个raft-group来说,leader和follower会有InstallSnapshot操作,目前,主要有两个地方会触发InstallSnapshot的发送:
  • Raft中的Follower落后Leader太多,Follower所需要的Raft Log在Leader中已经删除,会触发Leader向Follower发送snapshot
  • 当一个Raft Group增加一个peer时,这个peer通过Leader发送snapshot进行数据的初始化

braft实现

braft将log compaction分成了三大块:

  • snapshot save:创建快照
  • snapshot load:加载快照
  • install snapshot:复制快照

不同业务的Snapshot千差万别,因为SnapshotStorage并没有抽象具体读写Snapshot的接口,而是抽象出SnapshotReader和SnapshotWriter,交由用户扩展具体的snapshot创建和加载逻辑。
Snapshot创建流程

  • SnapshotStorage::create创建一个临时的Snapshot,并返回一个SnapshotWriter
  • SnapshotWriter将状态数据写入到临时snapshot中
  • SnapshotStorage::close来将这个snapshot转为合法的snapshot
  • 通过NodeOptions.snapshot_uri指定快照的存储路径

Snapshot读取流程

  • SnapshotStorage::open打开最近的一个Snapshot,并返回一个SnapshotReader
  • SnapshotReader将状态数据从snapshot中恢复出来
  • SnapshotStorage::close清理资源

libraft内提供了基于文件列表的LocalSnapshotWriter和LocalSnapshotReader默认实现,具体使用方式为:

  • 在fsm的on_snapshot_save回调中,将状态数据写入到本地文件中,然后调用SnapshotWriter::add_file将相应文件加入snapshot meta。
  • 在fsm的on_snapshot_load回调中,调用SnapshotReader::list_files获取本地文件列表,按照on_snapshot_save的方式进行解析,恢复状态数据。

braft中一个raft-group中的所有副本做log compaction是相互独立的,通常将NodeOptions.snapshot_interval_s=-1,然后每个副本自己控制snapshot。
主要类说明:

描述 主要流程
SnapshotStorage 快照读写的抽象接口,用户可以继承该类实现自己的快照读写 LocalSnapshotStorage,通过create方法创建一个快照,通过copy_from方法开始拷贝快照
SnapshotReader 快照元信息读取的抽象 LocalSnapshotReader,通过get_path方法来获取该snapshot存放的路径,通过list_files来获取snapshot对应的文件,通过get_file_meta接口获取文件元信息
SnapshotWriter 快照写操作的抽象 LocalSnapshotWriter,使用add_file接口告知libRaft业务状态机该snapshot对应的文件名及meta信息
SnapshotCopier 快照拷贝的抽象 LocalSnapshotCopier,负责拷贝快照数据,load_meta_table(元数据拷贝)->filter(过滤规)->copy_file(数据拷贝)
RemoteFileCopier 封装rpc请求 send_next_rpc发起异步请求,copy_to_iobuf拷贝元数据,copy_to_file拷贝数据
FileSystemAdaptor 底层存储接口 默认实现是本地文件系统接口PosixFileSystemAdaptor,可以定制,如根据rocksdb实现RocksdbFileSystemAdaptor

主要流程

braft的状态机提供了on_snapshot_save、on_snapshot_load的回调接口通知用户快照创建和加载,这两个操作是在状态机队列里串行执行的,所以需要高效实现,不然就会阻塞状态机。

snapshot save:创建快照

快照创建由Node::snapshot接口触发,然后相继持久化元数据和数据,braft状态机会持久化包括last_included_index,last_included_term和此snapshot对应的peer configuration的元数据,保存(last_included_term,last_included_index)在加载快照的时候完成后就会从这个log_index开始同步日志;在on_snapshot_save回调中业务完成自己snapshot数据的持久化。

主要函数调用:

  1. Node::snapshot(Closure* done):定时或手动触发创建快照
  2. SnapshotExecutor::do_snapshot(Closure* done):
    • SnapshotStorage::create():返回SnapshotWriter
    • FSMCaller::on_snapshot_save(SaveSnapshotClosure* done)
    • FSMCaller::do_snapshot_save(SaveSnapshotClosure* done):保存snapshot元数据
    • _fsm::on_snapshot_save(writer, done):执行用户状态机的逻辑
  3. SnapshotExecutor::on_snapshot_save_done():异步执行
    • SnapshotWriter::save_meta()保存快照元数据
    • SnapshotStorage::close(writer)关闭资源,更新内存

用户状态机实现on_snapshot_save接口如下:

1
void on_snapshot_save(SnapshotWriter* writer, Closure* closure);

SnapshotWriter是braft快照写操作的抽象,业务通过SnapshotWriter::add_file接口保存快照的元信息。

1
void add_file(const std::string& name, google::protobuf::Message* meta);

执行snapshot save后,braft即可将更老的snapshot删除,并将last_included_index之前的log安全地清除掉,snapshot的元信息存放在指定快照目录的__raft_snapshot_meta文件里。

snapshot load:加载快照

snapshot load发生的场景:

  • 节点重启:通过加载snapshot来恢复业务状态机的状态,首先通过on_snapshot_load接口回调业务状态机,完成snapshot的加载,然后在将此snapshot之后的committed log replay给业务状态机,完成状态恢复;
  • 日志落后太多:follower日志落后Leader太多时(follower next_log_index < leader first_log_index),leader会发起install_snapshot给Follower加速数据补齐,注意此时在此时在on_snapshot_load回调中需要现清理本地数据。
  • 新peer加入:这是上一中场景的特殊情况。

on_snapshot_load接口如下:

1
int on_snapshot_load(SnapshotReader* reader);

SnapshotReader是braft快照读操作的抽象,业务状态机通过get_path方法来获取该snapshot存放的路径,通过list_files来获取snapshot对应的文件,之后即可进行快照文件的读写。

1
2
std::string get_path();
void list_files(std::vector<std::string>* files);

snapshot load完成后,braft从last_included_index之后的log进行回放(replay)。

install snapshot:安装快照

Install Snapshot包括两个阶段,即leader节点上的读以及follower节点上的写。

安装快照时首先Leader向Follower发起RaftService::install_snapshot rpc请求,请求内容如下:

1
2
3
4
5
6
7
8
message InstallSnapshotRequest {
required string group_id = 1;
required string server_id = 2;
required string peer_id = 3;
required int64 term = 4;
required SnapshotMeta meta = 5;
required string uri = 6;
};

Follower收到请求后开启异步任务,通过FileService::get_file rpc接口来拉取快照数据,
请求GetFileRequest/GetFileResponse内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
message GetFileRequest {
required int64 reader_id = 1;
required string filename = 2;
required int64 count = 3;
required int64 offset = 4;
optional bool read_partly = 5;
}
message GetFileResponse {
// Data is in attachment
required bool eof = 1;
optional int64 read_size = 2;
}

拉取完数据后Flollower执行snapshot load加载快照到业务状态机,然后回复Leader InstallSnapshotResponse。完成install snapshot,snapshot install之后,Leader即可从该snapshot的last_included_index之后log开始replication给Follower。

BaikalDB基于RocksDB的Snapshot实现

BaikalDB采用Rocksdb作为单机引擎,raft状态机中应用raft-log直接将数据持写入Rocksdb,可以直接将RocksDB的数据作为snapshot,通过定制RocksdbFileSystemAdaptor实现Snapshot,具体实现如下:

snapshot save:创建快照

在这个阶段不做任何操作,只在SnapshotWriter中add两个文件名,然后结束。BaikalDB创建了meta和data两个ColumnFamily分别保存数据库的元数据和数据。

1
2
3
4
5
6
7
8
9
10
11
const std::string SNAPSHOT_DATA_FILE = "region_data_snapshot.sst"; // 数据文件
const std::string SNAPSHOT_META_FILE = "region_meta_snapshot.sst"; // 元数据文件
oid Region::on_snapshot_save(braft::SnapshotWriter* writer, braft::Closure* done) {
brpc::ClosureGuard done_guard(done);
if (writer->add_file(SNAPSHOT_META_FILE) != 0
|| writer->add_file(SNAPSHOT_DATA_FILE) != 0) {
done->status().set_error(EINVAL, "Fail to add snapshot");
return;
}
}

直接使用RocksDB里的数据作为snapshot,而RocksDB在持续不断的写入数据,因此在snapshot load时需要比较snapshot_index和applied_index,确保事务恢复正确状态。

snapshot load:加载快照

snapshot load发生在场景包括节点重启或者Follower落后日志太多的时候:
节点重启时:

节点重启时重启RocksDB,同时读取raft log_index < std::max(snapshot_index, _applied_index)的 未提交事务的log进行raplay恢复事务状态。

日志落后太多时

日志落后太多时会从Leader拉取一份完整的snapshot,因为是从Leasder拉取的一份完整的数据,此时需要先清除本地所有数据,使用RocksDB的DeleteRange接口,然后将拉取的sst文件通过IngestExternalFile接口加载到Rocksdb。

RocksdbFileSystemAdaptor实现
工具类 父类 功能
RocksdbFileSystemAdaptor braft::FileSystemAdaptor 负责整个快照文件的维护
PosixFileAdaptor braft::FileAdaptor 负责braft自身快照元数据的读写
SstWriterAdaptor braft::FileAdaptor 负责follower写sst文件
RocksdbReaderAdaptor braft::FileAdaptor 负责leader读取rocksdb数据

主要接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class FileSystemAdaptor {
public:
virtual FileAdaptor* open(const std::string& path, int oflag, const google::protobuf::Message* meta, Error* err) = 0;
virtual bool open_snapshot(const std::string& path) = 0;
virtual void close_snapshot(const std::string& path) = 0;
};
class FileAdaptor {
public:
virtual ssize_t write(const base::IOBuf& data, off_t offset) = 0;
virtual ssize_t read(base::IOPortal* portal, off_t offset, size_t size) = 0;
virtual ssize_t size() = 0;
virtual bool close() = 0;
};

Leader端

leader执行install_snapshot流程:
open_snapshot:该接口用来完成一些snapshot读取前的准备工作,进行并发控制。可能同一个group内有多个Follower需要拉取同一个snapshot,由于braft没有区分读取请求来自哪个Follower,因此需要在open_snapshot接口中对同一个snapshot进行并发限制,同一时刻只允许一个Follower读取该snapshot,检查到已经有其他snapshot install任务打开该snapshot时,返回打开失败。
open:读取时调用RocksdbFileSystemAdaptor::open接口返回RocksdbReaderAdaptor,该类是有状态的FileAdaptor,维护每次读取的进度,并持有RocksDB Iterator直至数据读取全部结束。
read:read操作通过指定offset来决定要从哪里开始读取数据,size参数指定了本次要读取的数据量,通过RocksDB Iterator遍历kv数据分批发送给Follower,直到eof,kv数据以Fixed-Length-Bytes的方式编码,即size+data。
close: close操作清理open操作维护的内存结构,如释放RocksDB Iterator等。
close_snapshot:close_snapshot操作在完成snapshot或出错后调用,在此接口中清除open_snapshot时保存的状态。

Follower端

在Follower端,在收到InstallSnapshotRequest后,调用RocksdbFileSystemAdaptor::open接口返回一个SstWriterAdaptor,利用rocksdb::SstFileWriter生成sst文件。
在SstWriterAdaptor::write接口中,将从Leader读取的数据,根据Fixed-Length-Bytes解析出kv数据,调用SstFileWrtier的Put接口写入sst文件。
最后,在close时,调用SstFileWriter的Finish接口生成完整的sst文件,此sst文件即可在后续snapshot load时ingest到RocksDB中,完成snapshot数据加载。