背景
随着对raft的学习,最近探讨了etcd/raft的日志结构,笔者今天主要分享raftLog是如何有效地保存日志。
简要
etcd/raft中Raft日志是通过raftLog结构体记录。raftLog结构体中,既有还未持久化的数据,也有已经持久化到稳定存储的数据;其中数据既有日志条目,也有快照。如果直观的给出raftLog中数据的逻辑结构,其大概如下图所示,参考一个大佬的分享。当熟悉raftLog之后,对raft的整个日志模块也就更加清晰了。
raftLog中的数据,按照是否已持久化到稳定存储,可分为两部分:已持久化到稳定存储的部分(stable)和还未持久化到稳定存储的部分(unstable)。无论是stable的部分还是unstable的部分中,都可能包含快照或日志,且每部分的快照中包含的已压缩的日志比该部分相应的未压缩的日志更旧。需要注意的是,在etcd/raft的实现中,在同一时刻,raftLog中的4个段可能并不是同时存在的。在etcd/raft的实现的结构体中,主要包含了Storage和unstable结构,具体如下:
type raftLog struct {
// storage contains all stable entries since the last snapshot.
storage Storage
// unstable contains all unstable entries and snapshot.
// they will be saved into storage.
unstable unstable
// committed is the highest log position that is known to be in
// stable storage on a quorum of nodes.
committed uint64
// applied is the highest log position that the application has
// been instructed to apply to its state machine.
// Invariant: applied <= committed
applied uint64
logger Logger
// maxNextEntsSize is the maximum number aggregate byte size of the messages
// returned from calls to nextEnts.
maxNextEntsSize uint64
}
Storage: 是etcd/raft为stable部分的数据让用户自定义实现存储 而提供的接口,Storage接口在学习四中已经详细了解了,这里就不再细致的展开。
unstable: 是etcd/raft存储为还未持久化的日志数据而设计的一种数据结构。
除此之外的其他参数说明如下:
参数 | 含义 |
---|---|
committed | 在该节点所知数量达到quorum的节点保存到了稳定存储中的日志里index最高的日志的index。 |
applied | 在该节点的应用程序已应用到其状态机的日志里,index最高的日志的index。 其中, applied <= committed 总是成立的。 |
firstIndex | 在该节点的日志中,最新的快照之后的第一条日志的index。 |
lastIndex | 在该节点的日志中,最后一条日志的index。 |
日志的转态转变
raft日志记录着每个操作,随着时间的推移,系统将产生大量的日志,为了能够有效的利用空间和保证系统的稳定性,raft的日志从unstable到stable的处理流程大致如下:(图源自这里)
unstable结构
type unstable struct {
// the incoming unstable snapshot, if any.
snapshot *pb.Snapshot
// all entries that have not yet been written to storage.
entries []pb.Entry
offset uint64
logger Logger
}
如前面所示,unstable中也是保留了快照和记录两种数据结构,从而减小存储的日志量。值得注意的是unstable结构体中的offset字段记录了unstable的日志起点,但该起点和我们在前面的图中看到的有所差别,可能比Storage(稳定存储)中index最高的日志条目旧,也就是说Storage和unstable中的日志可能有部分重叠,因此在处理二者之间的日志时,有一些裁剪日志的操作。其中一些Index在日志所表示的位置:(图片分别来自二)
unstable中的first index和last index的实现与Storage稍有不同。unstable的maybeFirstIndex方法与maybeLastIndex方法获取的是相对整个raftLog的first index或last index,当unstable无法得知整个raftLog的first index或last index时,这两个方法的第二个返回值会被置为false。这种设计主要是考虑到raftLog的实现,在raftLog的firstIndex和lastIndex方法中,首先会调用unstable的maybeFirstIndex方法或maybeLastIndex方法,如果查询的索引不在unstable中时,其会继续询问Storage。unstable中maybeFirstIndex方法与maybeLastIndex方法的实现如下:
// 返回两个参数:bool代表获取是否成功,uint64代表具体的值
// 如果成功就是有快照且是快照的起始的Index+1,即Entry的第一个Index
func (u *unstable) maybeFirstIndex() (uint64, bool) {
if u.snapshot != nil {
return u.snapshot.Metadata.Index + 1, true
}
return 0, false
}
// 如果没有Entry有快照,便返回快照的Index,有entry便返回entry的最后一个Index,
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
}
简单来说,只有unstable中包含快照时,unstable才可能得知整个raftLog的first index的位置(因为快照前的日志不会影响快照后的状态),但只有当unstable中既没有日志也没有快照时,unstable才无法得知last index的位置。
其中unstable.offset成员保存的是entries数组中的第一条数据在raft日志中的索引,即第i条entries在raft日志中的索引为i + unstable.offset。
raftLog中的一些重要方法
- maybeAppend(用于从节点接收主节点的同步)
func (l *raftLog) maybeAppend(index, logTerm, committed uint64, ents ...pb.Entry) (lastnewi uint64, ok bool) {
if l.matchTerm(index, logTerm) {
lastnewi = index + uint64(len(ents))
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:]...)
}
l.commitTo(min(committed, lastnewi))
return lastnewi, true
}
return 0, false
}
其中有几点需要注意的:
- findConflict返回的情况可以分为3种:
- 如果给定的日志与已有的日志的index和term冲突,其会返回第一条冲突的日志条目的index。
- 如果没有冲突,且给定的日志的所有条目均已在已有日志中,返回0.
- 如果没有冲突,且给定的日志中包含已有日志中没有的新日志,返回第一条新日志的index。
- maybeAppend会根据findConflict的返回值确定接下来的处理方式:
- 如果返回0,说明既没有冲突又没有新日志,直接进行下一步处理。
- 如果返回值小于当前的committed索引,说明committed前的日志发生了冲突,这违背了Raft算法保证的Log Matching性质,因此会引起panic。
- 如果返回值大于committed,既可能是冲突发生在committed之后,也可能是有新日志,但二者的处理方式都是相同的,即从将从冲突处或新日志处开始的日志覆盖或追加到当前日志中即可。
-
CommitedTo 方法保证了committed索引只会前进而不会回退,而使用lastnewi和传入的committed中的最小值则是因为传入的数据可能有如下两种情况:
- leader给follower复制日志时,如果复制的日志条目超过了单个消息的上限,则可能出现leader传给follower的committed值大于该follower复制完这条消息中的日志后的最大index。此时,该follower的新committed值为lastnewi。
- follower能够跟上leader,leader传给follower的日志中有未确认被法定数量节点稳定存储的日志,此时传入的committed比lastnewi小,该follower的新committed值为传入的committed值。
资料参考:
- https://mrcroxx.github.io/posts/code-reading/etcdraft-made-simple/4-log/#42-leader%E4%B8%AD%E7%9A%84%E6%97%A5%E5%BF%97%E6%8F%90%E8%AE%AE
- https://youjiali1995.github.io/raft/etcd-raft-log-replication/
- https://github.com/etcd-io/etcd/tree/main/raft