raft是什么我想应该不需要我过多解释,网上的文章也多如牛毛,raft的实现也不在少数,笔者唯独选择etcd这个实现版本,原因很简单:个人梦想开发一个分布式操作系统,这个分布式操作系统的核心需要一个高可靠、高性能的对象存储。etcd是一个非常优秀的项目,很多其他优秀的项目(比如kubernetes)都是基于etcd开发,所以我开始更深入的了解etcd。
在开始本文正式内容之前,笔者现对几个概念做一下简单的介绍(概念对齐才方便理解):
此log非彼log,本文要重点说明的log不是我们用于调试的日志,而是raft的业务日志,日志并非文本而是二进制数据。日志的概念可以简单的用下图表示:
图的左边是系统的A时刻的状态,图的右侧是系统在时刻B依次应用(apply)1、2、3...对系统的更新就可以达到A时刻的状态(这也是为什么raft文章中大量提到状态机的原因)。这里1、2、3...的内容就是log,自身的序号就是索引index,而B时刻的状态就是snapshot。
需要注意的是,raft不实现具体的业务,他只是让多个节点有序的应用相同的日志进而达到状态的一致性。至于日志中的内容具体是什么,raft根本不关心,这是使用者需要关心的。这一点,我们可以通过单条日志的protobuf定义可以看出来了:
// 源码源自go.etcd.io/etcd/raft/raftpb.raft.proto
// 在etcd的raft中,把一条日志定义为entry,而log是日志的统称。
enum EntryType {
EntryNormal = 0; // 常规日志
EntryConfChange = 1; // 配置修改日志
}
// 一条日志的定义,包含了届、索引、类型以及日志数据本身,所以entry可以解读为第term届leader产生的
// 第index条日志,类型是type, 内容是data。
message Entry {
optional uint64 Term = 2 [(gogoproto.nullable) = false]; // must be 64-bit aligned for atomic operations
optional uint64 Index = 3 [(gogoproto.nullable) = false]; // must be 64-bit aligned for atomic operations
optional EntryType Type = 1 [(gogoproto.nullable) = false];
optional bytes Data = 4;
}
上面的代码可以看到,Entry.Data是一个二进制类型,使用者负责把业务序列化成二进制数(例如etcd的增、删、改),在应用日志的时候再反序列化执行相应业务操作。至于Entry的其他字段所代表的意义会在后续的章节以及其他的文章中解释。
其实etcd的raft包中的log模块只是实现了Entry的简单管理,用C++语言描述就是std::vector
虽说日志管理变得复杂了,但是核心原理还是std::vector
// 源码源自go.etcd.io/etcd/raft/log.go
type raftLog struct {
// 源码注释翻译:storage存储了从最后一次snapshot到现在的所有可靠的(stable)日志(Entry),
// 读者看到这里估计会认为这是存储日志用的,确实是用来存储日志的,但是这个存储没有持久化能力,
// 日志的持久化相关的处理raft并不关心,这个存储可以理解为日志的缓存,raft访问最近的日志都是
// 通过他。也就是说storage中的日志在使用者的持久化存储中也有一份,当raft需要访问这些日志的时候,
// 无需访问持久化的存储,不仅效率高,而且与持久化存储充分解耦。那storage中缓存了多少日志呢?
// 从当前到上一次快照之间的所有日志(entry)以及上一次快照都在storage中。这个可以从Storage的
// MemoryStorage实现可以看出来,后面章节会详细说明。
storage Storage
// 源码注释翻译:与storage形成了鲜明的对比,那些还没有被使用者持久化的日志存储在unstable中,
// 如果说storage是持久化存储日志的cache,也就是说这些日志因为持久化了所以变得可靠,但是日志
// 持久化需要时间,并且是raft通过异步的方式(具体实现后续有文章介绍)把需要持久化的日志输出给
// 使用者。在使用者没有通知raft日志持久化完毕前,这些日志都还不可靠,需要用unstable来管理。
// 当使用者持久化完毕后,这些日志就会从unstable删除,并记录在storage中。
unstable unstable
// 源码注释翻译:被超过半数以上peer的raftLog.storage存储的日志中的最大索引值。
// 该值记录者当前节点已知的提交索引,需要注意,提交索引是集群的一个状态,而不是某一节点的状态,
// 但是leader在广播提交索引的时候会因为多种原因造成到达速度不一,所以每个节点知道的提交索引
// 可能不同。
committed uint64
// 前文就提到了应用这个词,日志的有序存储并不是目的,日志的有序应用才是目的,已经提交的日志就要
// 被使用者应用,应用就是把日志数据反序列化后在系统上执行。applied就是该节点已经被应用的最
// 大索引。应用索引是节点状态(非集群状态),这取决于每个节点的应用速度。
applied uint64
// 这个不解释了,用来打印日志的
logger Logger
// raftLog有一个成员函数nextEnts(),用于获取(applied,committed]的所有日志,很容易看出来
// 这个函数是应用日志时调用的,maxNextEntsSize就是用来限制获取日志大小总量的,避免一次调用
// 产生过大粒度的应用操作。
maxNextEntsSize uint64
}
从raftLog的定义可以看出来,除了committed和applied记录了一些状态,其核心就是storage和unstable。而storage和unstable都可以看做是std::vector
在分析unstable功能前先看看他的定义:
// 代码源自go.etcd.io/etcd/raft/log_unstable.go
type unstable struct {
// 看到这个变量时,应该意识到笔者前面说的日志只是Entry的集合,其实这是不全面的,应该还有
// Snapshot。快照不是本文重点,读者可以简单理解为使用者某一时刻的全量序列化后的二进制数即可。
// 笔者没有重点说明快照的原因是:是否理解快照本身对于理解raft影响不大,所以读者如果感兴趣可以
// 自行了解。
snapshot *pb.Snapshot
// 日志数组,证实了笔者前面std::vector的观点,应该不用多说了吧
entries []pb.Entry
// offset是第一个不可靠日志的索引,相信有读者肯定会提出疑问,直接用entries[0].Index不就
// 可以了么?需要注意的是,在系统在很多时刻entries是空的,比如刚启动,日志持久化完成等,所以
// 需要单独一个变量。当entries为空的时候,该值就是未来可能成为不可靠日志的第一个索引。这个变
// 量在索引entries的时候非常有帮助,比如entries[index - offset]就获取了索引为index的Entry
offset uint64
// 打印运行日志用的,不多解释
logger Logger
}
看到unstable的定义还是比较简单的,而unstable的接口函数基本也都是操作日志数组的,笔者就简单的注释一下,确实没啥难度。
// 代码源自go.etcd.io/etcd/raft/log_unstable.go
// 需要注意,后续章节以及其他关于raft文章中会有很多次出现maybeXxx()系列函数,这些函数都是尝试性
// 的操作,也就是可能会成功,也可能会失败,在返回值中会告诉操作的成功或者失败。这个函数用来获取第一
// 个日志的索引,这里特别需要注意,因为函数有点迷惑人,这个函数需要返回的是最近快照到现在的第一个
// 日志索引。所以说就是快照的索引,切不可把它当做是第一个不可靠日志的索引。
func (u *unstable) maybeFirstIndex() (uint64, bool) {
if u.snapshot != nil {
return u.snapshot.Metadata.Index + 1, true
}
return 0, false
}
// 获取最后一条日志的索引
func (u *unstable) maybeLastIndex() (uint64, bool) {
// 如果日志数组中有日志条目,那就返回最后一个条目的索引。
if l := len(u.entries); l != 0 {
return u.offset + uint64(l) - 1, true
}
// 没有日志条目,如果有快照那就返回快照的索引,该状态是快照还没有持久化,这个时间不会有日志,
// 所以快照既是第一个也是最后一个索引。
if u.snapshot != nil {
return u.snapshot.Metadata.Index, true
}
return 0, false
}
// 获取日志索引所在的届,实现方法比较简单,通过索引定位日志,然后返回日志的届。
func (u *unstable) maybeTerm(i uint64) (uint64, bool) {
// 如果索引小于offset,那就只有快照这一条路了,否则就是过于古老的日志,这里是查不到了。
if i < u.offset {
if u.snapshot == nil {
return 0, false
}
if u.snapshot.Metadata.Index == i {
return u.snapshot.Metadata.Term, true
}
return 0, false
}
// 如果比最大日志索引还大,超出处理范围也只能返回失败。
last, ok := u.maybeLastIndex()
if !ok {
return 0, false
}
if i > last {
return 0, false
}
return u.entries[i-u.offset].Term, true
}
// 这个函数是在使用者持久化不可靠日志后触发的调用,可靠的日志索引已经到达了i.
func (u *unstable) stableTo(i, t uint64) {
// 获得日志的届
gt, ok := u.maybeTerm(i)
if !ok {
return
}
// 届值匹配的情况下把i以前的不可靠日志从数组中删除。
if gt == t && i >= u.offset {
u.entries = u.entries[i+1-u.offset:]
u.offset = i + 1
u.shrinkEntriesArray()
}
}
// 这个函数是快照持久完成后触发的调用
func (u *unstable) stableSnapTo(i uint64) {
if u.snapshot != nil && u.snapshot.Metadata.Index == i {
u.snapshot = nil
}
}
// 这个函数是接收到leader发来的快照后调用的,暂时存入unstable等待使用者持久化。
func (u *unstable) restore(s pb.Snapshot) {
u.offset = s.Metadata.Index + 1
u.entries = nil
u.snapshot = &s
}
// 存储不可靠日志,这个函数是leader发来追加日志消息的时候触发调用的,raft先把这些日志存储在
// unstable中等待使用者持久化。为什么是追加?因为日志是有序的,leader发来的日志一般是该节点
// 紧随其后的日志亦或是有些重叠的日志,看似像是一直追加一样。
func (u *unstable) truncateAndAppend(ents []pb.Entry) {
after := ents[0].Index
switch {
// 刚好接在当前日志的后面,理想的追加
case after == u.offset+uint64(len(u.entries)):
u.entries = append(u.entries, ents...)
// 这种情况存储可靠存储的日志还没有被提交,此时新的leader不在认可这些日志,所以替换追加
case after <= u.offset:
u.logger.Infof("replace the unstable entries from index %d", after)
u.offset = after
u.entries = ents
// 有重叠的日志,那就用最新的日志覆盖老日志,覆盖追加
default:
u.logger.Infof("truncate the unstable entries before index %d", after)
u.entries = append([]pb.Entry{}, u.slice(u.offset, after)...)
u.entries = append(u.entries, ents...)
}
}
// 截取(lo,hi]的日志
func (u *unstable) slice(lo uint64, hi uint64) []pb.Entry {
u.mustCheckOutOfBounds(lo, hi)
return u.entries[lo-u.offset : hi-u.offset]
}
Storage是一个抽象类(interface),定义如下:
// 源码源自go.etcd.io/etcd/raft/storage.go
type Storage interface {
// 使用者在构造raft时,需要传入初始状态,这些状态存储在可靠存储中,使用者需要通过Storage
// 告知raft。关于状态的定义不在本文导论范围,笔者会在其他文章中详细说明。
InitialState() (pb.HardState, pb.ConfState, error)
// 获取索引在[lo,hi)之间的日志,日志总量限制在maxSize
Entries(lo, hi, maxSize uint64) ([]pb.Entry, error)
// 获取日志索引为i的届
Term(i uint64) (uint64, error)
// 获取最后一条日志的索引
LastIndex() (uint64, error)
// 获取第一条日志的索引
FirstIndex() (uint64, error)
// 获取快照
Snapshot() (pb.Snapshot, error)
}
raft的Storage实现只有一个,那就是MemoryStorage,从名字就可以看出来是用内存实现的,那岂不又是std::vector
// 代码源自go.etcd.io/etcd/raft/storage.go
// 简单到不想在做多余解释了....
type MemoryStorage struct {
sync.Mutex
hardState pb.HardState
snapshot pb.Snapshot
ents []pb.Entry
}
至于MemoryStorage如何实现Storage接口的,我想读者自己都能想象得到,也就不再浪费笔墨了。
先来个大篇幅的源码注释把,看看raftLog实现了哪些功能点,从前面raftLog的定义看,功能不太多,也不复杂。笔者需要点到为止,不需要大篇幅的解释了。
// 代码源自go.etcd.io/etcd/raft/log.go
// raftLog的构造函数
func newLogWithSize(storage Storage, logger Logger, maxNextEntsSize uint64) *raftLog {
if storage == nil {
log.Panic("storage must not be nil")
}
// 创建raftLog对象
log := &raftLog{
storage: storage,
logger: logger,
maxNextEntsSize: maxNextEntsSize,
}
// 使用者启动需要把持久化的快照以及日志存储在storage中,前面已经提到了,这个
// storage类似于使用者持久化存储的cache。
firstIndex, err := storage.FirstIndex()
if err != nil {
panic(err) // TODO(bdarnell)
}
lastIndex, err := storage.LastIndex()
if err != nil {
panic(err) // TODO(bdarnell)
}
// 这个代码印证了前面提到了,当unstable没有不可靠日志的时候,unstable.offset的值就是
// 未来的第一个不可靠日志的索引。
log.unstable.offset = lastIndex + 1
log.unstable.logger = logger
// 初始化提交索引和应用索引,切记只是初始化,raft在构造完raftLog后还会设置这两个值,所以下面
// 赋值感觉奇怪的可以忽略它。
log.committed = firstIndex - 1
log.applied = firstIndex - 1
return log
}
// 追加日志,在收到leader追加日志的消息后被调用。为什么是maybe?更确切的说什么原因会失败?这就要
// 从index,logTerm这两个参数说起了。raft会把若干个日志条目(Entry)封装在一个消息(Message)中,
// 同时在消息中还有index和logTerm两个参数,就是下面函数传入的同名参数。这两个参数是ents前一条日
// 志的索引和届,笔者会在其他文章介绍leader向其他节点发送日志的方法,此处只需要知道一点,leader有
// 一个参数记录下一次将要发送给某个节点的索引起始值,也就是ents[0].Index,而index和logTerm值就是
// ents[-1].Index和ents[-1].Term。知道这两个参数再来看源码注释。
func (l *raftLog) maybeAppend(index, logTerm, committed uint64, ents ...pb.Entry) (lastnewi uint64, ok bool) {
// 这一批日志的前一条日志届值都不匹配,那么这些日志条目都会被拒收追加,那么就会追加失败。不匹配
// 有两种情况:没有这个索引或者届值不相等,这可能是前一条日志还没到或者前一条日志已经是新leader
// 发来的了。第一种情况好理解,丢包了,造成新的日志包无法追加成功;第二种情况笔者只能理解为网络
// 分区重新选举后老的leader又回到了集群中向节点发送了已经过时的日志。
if l.matchTerm(index, logTerm) {
// 计算最新的日志索引
lastnewi = index + uint64(len(ents))
// 找到冲突的日志,所谓冲突还是届值不匹配,findConflict()后面有注释。
ci := l.findConflict(ents)
// 对于冲突有三种可能:
switch {
// 没有任何冲突,也就是说所有日志节点已经有了,那就是重发的消息了。
case ci == 0:
// 冲突的起始索引不大于提交索引,这个不应该发生,只能选择崩溃了~
case ci <= l.committed:
l.logger.Panicf("entry %d conflict with committed entry [committed(%d)]", ci, l.committed)
// 有一部分冲突,新的日志重新追加。
default:
offset := index + 1
l.append(ents[ci-offset:]...)
}
// 更新提交索引,为什么取了个最小?committed是leader发来的,是全局状态,但是当前节点
// 可能落后于全局状态,所以取了最小值。这里读者可能有疑问,lastnewi是这个节点最新的索引,
// 不是大的可靠索引,如果此时节点异常了,会不会出现提交索引以前的日志已经被应用,但是有些
// 日志还没有被持久化?这里笔者需要解释一下,raft更新了提交索引,raft会把提交索引以前的
// 日志交给使用者应用同时会把不可靠日志也交给使用者持久化,所以这要求使用者必须先持久化日志
// 再应用日志,否则就会出现刚刚提到的问题。
l.commitTo(min(committed, lastnewi))
return lastnewi, true
}
return 0, false
}
// 追加日志。
func (l *raftLog) append(ents ...pb.Entry) uint64 {
// 没有日志。
if len(ents) == 0 {
return l.lastIndex()
}
// 日志与部分提交日志重叠,这种属于不能接受的情况
if after := ents[0].Index - 1; after < l.committed {
l.logger.Panicf("after(%d) is out of range [committed(%d)]", after, l.committed)
}
// 追加到unstable中。
l.unstable.truncateAndAppend(ents)
return l.lastIndex()
}
// 找到冲突日志的起始索引
func (l *raftLog) findConflict(ents []pb.Entry) uint64 {
// 便利日志
for _, ne := range ents {
// 匹配届值
if !l.matchTerm(ne.Index, ne.Term) {
if ne.Index <= l.lastIndex() {
l.logger.Infof("found conflict at index %d [existing term: %d, conflicting term: %d]",
ne.Index, l.zeroTermOnErrCompacted(l.term(ne.Index)), ne.Term)
}
// 返回第一个届值不匹配的日志索引。
return ne.Index
}
}
return 0
}
// 获取不可靠日志,就是把unstable的所有日志输出,这个函数用于输出给使用者持久化
func (l *raftLog) unstableEntries() []pb.Entry {
if len(l.unstable.entries) == 0 {
return nil
}
return l.unstable.entries
}
// 获取应用索引到提交索引间的所有日志,这个函数用于输出给使用者应用日志
func (l *raftLog) nextEnts() (ents []pb.Entry) {
off := max(l.applied+1, l.firstIndex())
if l.committed+1 > off {
ents, err := l.slice(off, l.committed+1, l.maxNextEntsSize)
if err != nil {
l.logger.Panicf("unexpected error when getting unapplied entries (%v)", err)
}
return ents
}
return nil
}
// 判断是否有可应用的日志
func (l *raftLog) hasNextEnts() bool {
off := max(l.applied+1, l.firstIndex())
return l.committed+1 > off
}
// 获取快照
func (l *raftLog) snapshot() (pb.Snapshot, error) {
if l.unstable.snapshot != nil {
return *l.unstable.snapshot, nil
}
return l.storage.Snapshot()
}
// 获取第一个索引,读者可能会问第一个日志索引不应该是0或者是1么?(取决于索引初始值),但是raft会
// 周期的做快照,快照之前的日志就没用了,所以第一个日志索引不一定是0.
func (l *raftLog) firstIndex() uint64 {
if i, ok := l.unstable.maybeFirstIndex(); ok {
return i
}
index, err := l.storage.FirstIndex()
if err != nil {
panic(err) // TODO(bdarnell)
}
return index
}
// 获取最后一条日志的索引。
func (l *raftLog) lastIndex() uint64 {
if i, ok := l.unstable.maybeLastIndex(); ok {
return i
}
i, err := l.storage.LastIndex()
if err != nil {
panic(err) // TODO(bdarnell)
}
return i
}
// 更新提交索引,
func (l *raftLog) commitTo(tocommit uint64) {
// never decrease commit
if l.committed < tocommit {
if l.lastIndex() < tocommit {
l.logger.Panicf("tocommit(%d) is out of range [lastIndex(%d)]. Was the raft log corrupted, truncated, or lost?", tocommit, l.lastIndex())
}
l.committed = tocommit
}
}
// 更新应用索引
func (l *raftLog) appliedTo(i uint64) {
if i == 0 {
return
}
if l.committed < i || i < l.applied {
l.logger.Panicf("applied(%d) is out of range [prevApplied(%d), committed(%d)]", i, l.applied, l.committed)
}
l.applied = i
}
// 使用者告知raftLog日志已经持久化到哪个索引了
func (l *raftLog) stableTo(i, t uint64) { l.unstable.stableTo(i, t) }
// 使用者告知raftLog索引值为i的快照已经持久化了
func (l *raftLog) stableSnapTo(i uint64) { l.unstable.stableSnapTo(i) }
// 获取最后一条日志的届值
func (l *raftLog) lastTerm() uint64 {
t, err := l.term(l.lastIndex())
if err != nil {
l.logger.Panicf("unexpected error when getting the last term (%v)", err)
}
return t
}
// 获取日志届值
func (l *raftLog) term(i uint64) (uint64, error) {
// 如果索引在raftLog记录的所有日志之外,那么只能返回0代表没找到
dummyIndex := l.firstIndex() - 1
if i < dummyIndex || i > l.lastIndex() {
// TODO: return an error instead?
return 0, nil
}
// 在unstable中找一下
if t, ok := l.unstable.maybeTerm(i); ok {
return t, nil
}
// unstable中没有那就在storage找
t, err := l.storage.Term(i)
if err == nil {
return t, nil
}
// 如果storage和unstable都没有,那也算是没找到。这是因为storage可能会压缩,比如把应用
// 索引以前的日志删除,因为他们已经没用了,这样可以节省内存空间。
if err == ErrCompacted || err == ErrUnavailable {
return 0, err
}
panic(err) // TODO(bdarnell)
}
// 获取从索引值为i之后的所有日志,但是日志总量限制在maxsize
func (l *raftLog) entries(i, maxsize uint64) ([]pb.Entry, error) {
if i > l.lastIndex() {
return nil, nil
}
return l.slice(i, l.lastIndex()+1, maxsize)
}
// 获取所有日志
func (l *raftLog) allEntries() []pb.Entry {
ents, err := l.entries(l.firstIndex(), noLimit)
if err == nil {
return ents
}
if err == ErrCompacted { // try again if there was a racing compaction
return l.allEntries()
}
// TODO (xiangli): handle error?
panic(err)
}
// 判断给定日志的索引和届值是不是比raftLog中的新
func (l *raftLog) isUpToDate(lasti, term uint64) bool {
return term > l.lastTerm() || (term == l.lastTerm() && lasti >= l.lastIndex())
}
// 匹配日志届值
func (l *raftLog) matchTerm(i, term uint64) bool {
// 获取日志的届值,如果这个日志不存在匹配失败
t, err := l.term(i)
if err != nil {
return false
}
// 如果日志存在,那么比较届值是否相等
return t == term
}
// 更新提交索引
func (l *raftLog) maybeCommit(maxIndex, term uint64) bool {
if maxIndex > l.committed && l.zeroTermOnErrCompacted(l.term(maxIndex)) == term {
l.commitTo(maxIndex)
return true
}
return false
}
func (l *raftLog) restore(s pb.Snapshot) {
l.logger.Infof("log [%s] starts to restore snapshot [index: %d, term: %d]", l, s.Metadata.Index, s.Metadata.Term)
l.committed = s.Metadata.Index
l.unstable.restore(s)
}
// 获取(lo,hi]的所有日志,但是总量限制在maxSize
func (l *raftLog) slice(lo, hi, maxSize uint64) ([]pb.Entry, error) {
// 判断lo和hi的合法性
err := l.mustCheckOutOfBounds(lo, hi)
if err != nil {
return nil, err
}
if lo == hi {
return nil, nil
}
// 日志有一部分落在storage中
var ents []pb.Entry
if lo < l.unstable.offset {
storedEnts, err := l.storage.Entries(lo, min(hi, l.unstable.offset), maxSize)
if err == ErrCompacted {
return nil, err
} else if err == ErrUnavailable {
l.logger.Panicf("entries[%d:%d) is unavailable from storage", lo, min(hi, l.unstable.offset))
} else if err != nil {
panic(err) // TODO(bdarnell)
}
// 这个判断有意思,如果从storage获取的日志数量比预期少,说明没那么多日志存在storage中,
// 那也就没必要再找unstable了。
if uint64(len(storedEnts)) < min(hi, l.unstable.offset)-lo {
return storedEnts, nil
}
ents = storedEnts
}
// 日志有一部分在unstable中。
if hi > l.unstable.offset {
unstable := l.unstable.slice(max(lo, l.unstable.offset), hi)
if len(ents) > 0 {
combined := make([]pb.Entry, len(ents)+len(unstable))
n := copy(combined, ents)
copy(combined[n:], unstable)
ents = combined
} else {
ents = unstable
}
}
return limitSize(ents, maxSize), nil
}
最后,raftLog主要功能可以用下图表示:
模块会在其他文章详细说明,本文只说明log模块的工作原理,这里可以假设x、y分别代表一个routine。对上图的解释如下:
每一条日志Entry需要经过unstable、stable、committed、applied、compacted五个阶段,接下来总结一下日志的状态转换过程: