Dgraph数据库 raft wal实现
Raft算法
分布式系统可以大致分为两种,分别是有状态和无状态的。其中无状态的系统最为简单,例如一个多节点的web api,提供一个计算服务,只需要通过负载均衡,即可非常轻松的搭建起来。并且随意增减节点,使得服务可以100%达到可用状态。
而对于有状态的服务,例如分布式存储,情况就会变得非常复杂。各个节点之间由于硬件和网络的不可靠,各个节点的状态并不能完全保持一致。需要通过特殊的方式处理。Raft算法 是Diego Ongaro教授于2013年发表的论文中提到的算法。它的最大的特点是易于理解。本文不会详细展开算法思想细节。但是会涉及许多算法中提及的概念。请自行参考raft算法论文。
该算法在许多平台均有实现,例如Golang,C++,Java等。其中etcd(一款开源分布式kv存储数据库)将raft算法核心封装成了一个算法库,使得开发者能不必从头到尾实现raft算法,即可使得自己的分布式系统实现多副本一致性。
本人从19年开始主要学习后端开发,20年基本掌握无状态分布式系统的开发与搭建,但是对于有状态的分布式系统,却始终不得要领。参加工作后,也陆续接触了许多分布式系统,例如consul,Kafka,Cassandra等,但奈何始终没有突破。近期由于工作需要,需要为公司的自研数据库实现多副本,提高可用性。所以学习了raft算法。特以此文,记录感悟。
Dgraph的Raft WAL实现
raft算法从复制状态机出发,通过日志进行同步,使得整个系统可以达成一致性状态。所以一个系统要实现raft算法,必须能够对WAL(Write-ahead logging)进行管理。etcd的raft库,将WAL抽象成了一个接口,应用的开发者,需要将其实现。
type Storage interface {
// InitialState returns the saved HardState and ConfState information.
InitialState() (pb.HardState, pb.ConfState, error)
// Entries returns a slice of log entries in the range [lo,hi).
// MaxSize limits the total size of the log entries returned, but
// Entries returns at least one entry if any.
Entries(lo, hi, maxSize uint64) ([]pb.Entry, error)
// Term returns the term of entry i, which must be in the range
// [FirstIndex()-1, LastIndex()]. The term of the entry before
// FirstIndex is retained for matching purposes even though the
// rest of that entry may not be available.
Term(i uint64) (uint64, error)
// LastIndex returns the index of the last entry in the log.
LastIndex() (uint64, error)
// FirstIndex returns the index of the first log entry that is
// possibly available via Entries (older entries have been incorporated
// into the latest Snapshot; if storage only contains the dummy entry the
// first log entry is not available).
FirstIndex() (uint64, error)
// Snapshot returns the most recent snapshot.
// If snapshot is temporarily unavailable, it should return ErrSnapshotTemporarilyUnavailable,
// so raft state machine could know that Storage needs some time to prepare
// snapshot and call Snapshot later.
Snapshot() (pb.Snapshot, error)
}
对于raft来说,只要将这个接口实现,wal层就完毕了。
现在我们先来具体看看每个方法:
// InitialState returns the saved HardState and ConfState information.
InitialState() (pb.HardState, pb.ConfState, error)
InitialState,需要从存储的wal中,返回当前机器的状态。分别包括HardState和ConfState,HardState 包含,“任期”,“选票”,”提交索引“ ConfState则包含raft配置信息,例如一些集群相关信息。
// Entries returns a slice of log entries in the range [lo,hi).
// MaxSize limits the total size of the log entries returned, but
// Entries returns at least one entry if any.
Entries(lo, hi, maxSize uint64) ([]pb.Entry, error)
Entries,则是提供了一个传入区间,取出对应index 的日志条目的方法, 如果想要的日志区间不存在,则会返回错误。
// Term returns the term of entry i, which must be in the range
// [FirstIndex()-1, LastIndex()]. The term of the entry before
// FirstIndex is retained for matching purposes even though the
// rest of that entry may not be available.
Term(i uint64) (uint64, error)
Term 提供了查询一个日志条目属于哪个任期,日志条目索引必须符合范围。
// LastIndex returns the index of the last entry in the log.
LastIndex() (uint64, error)
// FirstIndex returns the index of the first log entry that is
// possibly available via Entries (older entries have been incorporated
// into the latest Snapshot; if storage only contains the dummy entry the
// first log entry is not available).
FirstIndex() (uint64, error)
这两个个方法顾名思义,返回wal中第一个和最后一个可用的日志索引。
// Snapshot returns the most recent snapshot.
// If snapshot is temporarily unavailable, it should return ErrSnapshotTemporarilyUnavailable,
// so raft state machine could know that Storage needs some time to prepare
// snapshot and call Snapshot later.
Snapshot() (pb.Snapshot, error)
返回最近的快照元数据。
整体来讲,storage需要实现以上方法。 下面我们先看看具体的数据结构
Storage
type DiskStorage struct {
dir string
elog trace.EventLog
meta *metaFile
wal *wal
lock sync.Mutex
}
这个struct就是对Storage interface的实现。 dir字段对应存储的目录。elgo是性能追踪用的。meta则是一个保存原数据的结构,后面我们会重点分析它。wal则是负责log entry 的管理的数据结构,后面也将重点分析。
它主要是提供对上层访问的,真正的逻辑实现,分别在wal与meta中。
metaFile
// metaFile stores the RAFT metadata (e.g RAFT ID, snapshot, hard state).
type metaFile struct {
*z.MmapFile
}
它数据结构非常简单,只有一个MmapFile,它可以理解为对文件描述符(fd)和缓冲区的抽象。所以很显然,所有的元数据,都是存在这个文件里的。我们具体看看,它包含哪些内容:
type MetaInfo int
const (
RaftId MetaInfo = iota
GroupId
CheckpointIndex
SnapshotIndex
SnapshotTerm
)
// getOffset returns offsets in wal.meta file.
func getOffset(info MetaInfo) int {
switch info {
case RaftId:
return 0
case GroupId:
return 8
case CheckpointIndex:
return 16
case SnapshotIndex:
return snapshotIndex
case SnapshotTerm:
return snapshotIndex + 8
default:
panic("Invalid info: " + fmt.Sprint(info))
}
}
通过这段代码,我们可以发现,它是通过偏移量访问byte数组的。其中包含以下信息: RaftId:本节点在当前集群里的id GroupId:raft group的id CheckpointIndex:保存了最新 applied 的 index ,用于重启后恢复 HardState:getOffset方法没有体现,保存在512偏移量的位置 SnapshotIndex和SnapshotTerm:最新的快照对应的日志条目索引和任期 SnapShotData:getOffset方法并没有直接体现,但是它在CheckpointIndex+16的位置
综合以上可以看出,metaFiles负责持久化保存以上元数据信息。而我们需要在raft主循环自行将这些相关信息,写入到storage中。它最终就会存储在磁盘中。
wal
type wal struct {
// files is the list of all log files ordered in ascending order by the first
// index in the file. current is the file currently being written to, and is
// added to files only after it is full.
files []*logFile
current *logFile
// nextEntryIdx is the index of the next entry to write to. When this value exceeds
// maxNumEntries the file will be rotated.
nextEntryIdx int
// dir is the directory to use to store files.
dir string
}
wal结构体,就是用来具体管理所有的日志文件的了。
首先就是files字段,对应的就是所有日志的磁盘文件
current,代表当前正在使用的文件,它并不会出现在files slice里。
nextEntryIdx,代表下一个即将写入的日志条目的索引,当超过单文件最大条目数量的时候,就会产生一个新的logFile并写入。
dir 就是保存日志文件的目录了。
wal提供了一系列用于访问entry的方法, 例如:
访问对应索引的条目:
func (l *wal) seekEntry(raftIndex uint64) (entry, error)
将一批条目写入磁盘:
func (l *wal) AddEntries(entries []raftpb.Entry) error
获取管理的地一个日志条目的索引: storage也是直接调用这个方法实现的。
func (l *wal) firstIndex() uint64
。。。具体不展开所有方法了
type logFile struct {
*z.MmapFile
fid int64
registry *badger.KeyRegistry
dataKey *pb.DataKey
baseIV []byte
}
由于存在多个不同的文件,并且所有的日志条目都是有序的,所以每个logfile都需要能知道自己存储的条目的开头索引,但是我们可以看到,logFile结构体中,并没有元数据标志。
值得注意的是,wal日志文件中,每个条目的大小可能并不相同,在实际应用中,复杂繁多的命令总是需要更多的存储空间,而wal将文件划分为两个部分,前面的部分为一个一个的槽,每个槽的大小是固定的,里面存放的entry的元数据信息,并且会记录自己的data的偏移量。后一部分,存放真正的数据,等到需要访问Data的时候,就会根据这个偏移量,从一部分文件中拿到data。并且在真正存储的Data的位置,前4个字节会是一个int,存储了这个data的长度。所以从文件取出data的时候,只需要提供offset即可(这是MmapFile数据结构的特性,具体可以参考ristretto的源码)
基于上面的分析,存放entry元数据的部分大小是固定的,那么要知道开头的索引很简单,直接访问文件的头部即可。但是对于结尾就比较麻烦了,需要从头到尾一个一个查找。
- 0
- 0
-
分享