[ETCD v3.4.10源码分析] 2. 日志复制与同步机制
第一章 [ETCD v3.4.10源码分析] 1. Raft协议与心跳机制
第二章 [ETCD v3.4.10源码分析] 2. 日志复制与同步机制
前言
分布式共识算法(consensus algorithm)通常的做法就是在多个节点上复制状态机。分布在不同服务器上的状态机执行着相同的状态变化,即使其中几台机器挂掉,整个集群还能继续运作。 复制状态机正确运行的核心的同步日志,日志是保证各节点状态同步的关键,日志中保存了一系列状态机命令,共识算法的核心是保证这些不同节点上的日志以相同的顺序保存相同的命令,由于状态机是确定的,所以相同的命令以相同的顺序执行,会得到相同的结果。raft协议保证系统在任何时刻都保持一下特性:
- Election Safety (选举安全):对于每个给定的Term,整个集群至多能选举出一个leader。
- Leader Append-Only (Leader只追加日志): Leader永远不会改写或者删除日志项,它只会追加日志。
- Log Matching (日志匹配): 如果两个节点的日志包含了具有相同Index和Term的日志项,则这两个节点的日志中,该Index之前的日志项都一样。
- Leader Completeness (leader日志完整性): 在一个Term中如果1条日志已经commit,那么后续的Term中选举出来的Leader一定存有这条日志。
- State Machine Safety (状态机安全性):如果一个server已经apply了一条日志项到状态机中,则其他的server不会apply一条相同Index但是不同内容的日志项。
一、日志的结构形式
日志是以条目(Entry)的方式顺序组织在一起的,日志中包含index、term、type和data等字段。index随日志条目的递增而递增,term是生成该条目的leader当时处于的term。type是etcd定义的字段,目前有两个类型,一个是EntryNormal正常的日志,EntryConfChange是etcd本身配置变化的日志。data是日志的内容。
type Entry struct {
Term uint64 `protobuf:"varint,2,opt,name=Term" json:"Term"`
Index uint64 `protobuf:"varint,3,opt,name=Index" json:"Index"`
Type EntryType `protobuf:"varint,1,opt,name=Type,enum=raftpb.EntryType" json:"Type"`
Data []byte `protobuf:"bytes,4,opt,name=Data" json:"Data,omitempty"`
XXX_unrecognized []byte `json:"-"`
}
内存中的日志操作,主要是由一个raftLog类型的对象完成的,以下是raftLog的源码。可以看到,里面有两个存储位置,一个是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
}
持久化日志: WAL和snapshot。下图显示持久化的Storage接口定义和storage结构中字段的定义。它包含一个WAL来保存日志条目,一个Snapshotter负责保存日志快照。
type Storage interface {
// Save function saves ents and state to the underlying stable storage.
// Save MUST block until st and ents are on stable storage.
Save(st raftpb.HardState, ents []raftpb.Entry) error
// SaveSnap function saves snapshot to the underlying stable storage.
SaveSnap(snap raftpb.Snapshot) error
// Close closes the Storage and performs finalization.
Close() error
// Release releases the locked wal files older than the provided snapshot.
Release(snap raftpb.Snapshot) error
// Sync WAL
Sync() error
}
type storage struct {
*wal.WAL
*snap.Snapshotter
}
WAL是一种追加的方式将日志条目一条一条顺序存放在文件中。存放在WAL的记录都是walpb.Record形式的结构。Type代表数据的类型,Crc是生成的Crc校验字段。Data是真正的数据。v3版本中,有下图显示的几种Type:
- metadataType:元数据类型,元数据会保存当前的node id和cluster id。
- entryType:日志条目
- stateType:存放的是集群当前的状态HardState,如果集群的状态有变化,就会在WAL中存放一个新集群状态数据。里面包括当前Term,当前竞选者、当前已经commit的日志。
- crcType:存放crc校验字段。读取数据是,会根据这个记录里的crc字段对前面已经读出来的数据进行校验。
- snapshotType:存放snapshot的日志点。包括日志的Index和Term。
WAL有read模式和write模式,区别是write模式会使用文件锁开启独占文件模式。read模式不会独占文件。
type Record struct {
Type int64 `protobuf:"varint,1,opt,name=type" json:"type"`
Crc uint32 `protobuf:"varint,2,opt,name=crc" json:"crc"`
Data []byte `protobuf:"bytes,3,opt,name=data" json:"data,omitempty"`
XXX_unrecognized []byte `json:"-"`
}
const (
metadataType int64 = iota + 1
entryType
stateType
crcType
snapshotType
// warnSyncDuration is the amount of time allotted to an fsync before
// logging a warning
warnSyncDuration = time.Second
)
Snapshotter 提供保存快照的SaveSnap方法。在v2中,快照实际就是storage中存的那个node组成的树结构。它是将整个树给序列化成了json。在v3中,快照是boltdb数据库的数据文件,通常就是一个叫db的文件。v3的处理实际代码比较混乱,并没有真正走snapshotter。
二、日志保存的整体流程
- 集群某个节点收到client的put请求要求修改数据。节点会生成一个Type为MsgProp的Message,发送给leader。
classDiagram
client --|> server1_follower : PutRequest(Key: a, Value: b)
client --> server2_follower
client --> server3_leader
server1_follower --> server3_leader : Message(Type: MsgProp, Data: xxxx)
- leader收到Message以后,会处理Message中的日志条目,将其append到raftLog的unstable的日志中,并且调用bcastAppend()广播append日志的消息.
- leader中有协程处理unstable日志和刚刚准备发送的消息,newReady方法会把这些都封装到Ready结构中。
- leader的另一个协程处理这个Ready,先发送消息,然后调用WAL将日志持久化到本地磁盘。
- follower收到append日志的消息,会调用它自己的raftLog,将消息中的日志append到本地缓存中。随后follower也像leader一样,有协程将缓存中的日志条目持久化到磁盘中并将当前已经持久化的最新日志index返回给leader。
- 所有节点,包括follower 和leader都会将已经认定为commit的日志apply到kv存储中。对于v2就是更新store中的树节点。对于v3就是调用boltdb的接口更新数据。
- 日志条目到一定数目以后,会触发snapshot,leader会持久化保存第6步所说的kv存储的数据。然后删除内存中过期的日志条目。
- WAL中保存的持久化的日志条目会有一个定时任务定时删除。
以v3.4.10代码分析上述过程:
3. 日志生成
- v3操作etcd一般是直接使用etcd提供的client库,因为v3的client和server也采用grpc通信,直接用httpclient会非常复杂。Client结构中包含了一个叫KV的接口,里面定义了Put、Get、Delete等方法。Put方法的实现实际就是向其中一个server发送一条grpc请求,请求体正是PutRequest结构的对象。
- 服务端收到gprc请求以后,会调用EtcdServer的Put()、Range()、DeleteRange()、Txn()等方法,这些方法最终都会调用到processInternalRaftRequestOnce(),这个方法的处理是先用request的id注册一个channel,将request对象序列化成byte数组,作为参数传入Propose()方法,调用raftNode的Propose()方法,最后等待刚刚注册的channel上的数据,node会在请求已经apply到状态机以后,也就是请求处理结束以后,往这个channel推送一个ApplyResult对象,触发等待它的请求处理协程继续往下走,返回请求结果。
func (s *EtcdServer) processInternalRaftRequestOnce(ctx context.Context, r pb.InternalRaftRequest) (*applyResult, error) {
ai := s.getAppliedIndex()
ci := s.getCommittedIndex()
if ci > ai+maxGapBetweenApplyAndCommitIndex {
return nil, ErrTooManyRequests
}
r.Header = &pb.RequestHeader{
ID: s.reqIDGen.Next(),
}
authInfo, err := s.AuthInfoFromCtx(ctx)
if err != nil {
return nil, err
}
if authInfo != nil {
r.Header.Username = authInfo.Username
r.Header.AuthRevision = authInfo.Revision
}
data, err := r.Marshal()
if err != nil {
return nil, err
}
if len(data) > int(s.Cfg.MaxRequestBytes) {
return nil, ErrRequestTooLarge
}
id := r.ID
if id == 0 {
id = r.Header.ID
}
ch := s.w.Register(id)
cctx, cancel := context.WithTimeout(ctx, s.Cfg.ReqTimeout())
defer cancel()
start := time.Now()
err = s.r.Propose(cctx, data)
if err != nil {
proposalsFailed.Inc()
s.w.Trigger(id, nil) // GC wait
return nil, err
}
proposalsPending.Inc()
defer proposalsPending.Dec()
select {
case x := <-ch:
return x.(*applyResult), nil
case <-cctx.Done():
proposalsFailed.Inc()
s.w.Trigger(id, nil) // GC wait
return nil, s.parseProposeCtxErr(cctx.Err(), start)
case <-s.done:
return nil, ErrStopped
}
}
- raftNode的Propose方法实现在node结构上。它会生成一条MsgProp消息,消息的Data字段是已经序列化的request。也就是说v3中,日志条目的内容就是request。最后调用step()方法,是把消息推到propc channel中。
func