etcd系列-----raft保数据一致性手段之WAL日志

WAL (Write-ahead logging)是etcd实现一致性的重要手段之一。一条Entry记录的大致流程:
    (1)当客户端向etcd 集群发送了一次请求之后,请求中的封装Entry记录会先被交给raft模块进行处理,raft模块会先将Entry记录保存到raftLog.unstable中。
    (2)raft模块将该Entry记录封装到前面介绍的Ready实例中,返回给上层模块进行持久化。
    (3)当上层模块收到待持久化的Entry记录之后,会先将其记录到WAL日志文件中,然后进行持久化操作,最后通知raft模块进行处理。
    (4)此时raft模块就会将该Entry记录从unstable移动到storage中保存。
    (5)待该Entry记录被复制到集群中的半数以上节点时,该Entry记录会被Leader节点确认为己提交(committed),并封装进Ready实例返回给上层模块。
    (6)上层模块即可将该Ready实例中携带的待应用Entry记录应用到状态机中。

在wal 模块中,首先需要介绍的就是结构体WAL,它对外提供了WAL日志文件管理的核心API。在操作WAL日志时,对应的WAL实例有read和append两种模式,新创建的WAL实例处于append模式,该模式下只能向WAL中追加日志。 当恢复一个节点时(例如,宕机节点的重启),就需要读取WAL日志的内容,此时刚打开的WAL实例处于read模式,它只能读取日志记录, 当读取完全部的日志之后,WAL实例转换成append模式,可以继续向其追加日志记录。

在WAL日志文件中,日志记录是通过Record表示的,该结构体通过Protocol Buffers生成,主要用于序列化和反序列化日志记录,其中各个字段的含义如下。
    Type字段(int64类型):表示该Record实例的类型。
    Crc字段(uint32类型):记录该Record实例的校验码。
    Data字段([]byte 类型):记录真正的日志数据,根据日志的类型不同,Data字段中保存的数据也有所相同。

Record 结构体还提供了一些简单的方法,后面遇到时会进行简单说明。根据Record.Type字段值,可以将日志记录分为如下几种类型。
    metadataType: 该类型日志记录的Data字段中保存了一些元数据,在每个WAL文件的开头,都会记录一条metadataType类型的日志记录。
    entry Type: 该类型日志记录的Data字段中保存的是Entry记录,也就是客户端发送给服务端处理的数据,例如,raftexample示例中客户端发送的键值对数据。
    state Type:该类型日志记录的Data字段中保存了当前集群的状态信息(即HardState),在每次批量写入entry Type类型日志记录之前,都会先写入一条stateType类型的日志记录。
    crc Type: 该类型的日志记录主要用于数据校验。
    snapshotType: 该类型的日志记录中保存了快照数据的相关信息(即walpb.Snapshot,注意,其中不包含完整的快照数据)。

WAL结构体核心字段的含义:
    dir (string类型):存放WAL日志文件的目录路径。
    dirFile ( *as.File类型):根据dir路径创建的File实例。
    metadata ( []byte类型): 在每个WAL日志文件的头部,都会写入metadata元数据。
    state ( raftpb.HardState 类型): WAL日志记录的追加是批量的,在每次批量写入entryType类型的日志之后,都会再追加一条stateType类型的日志记录,在HardState中记录了当前的Term、当前节点的投票结果和己提交日志的位置。
    start(walpb.Snapshot类型):每次读取WAL日志时,并不会每次都从头开始读取,而是通过这里的start宇段指定具体的起始位置。walpb.Snapshot中的Index字段记录了对应快照数据所涵盖的最后一条Entry 记录的索引值, Term字段则记录了对应Entry记录的Term值。 在读取WAL日志文件时,我们就可以根据这些信息,找到合适的位置并开始读取记录。
    decoder ( *decode「类型): 负责在读取WAL日志文件时,将二进制数据反序列化成Record实例。
    encoder( *encode「类型):负责将写入WAL日志文件的Record实例进行序列化成二进制数据。
    mu ( sync.Mutex类型):读写WAL日志时需要加锁同步。
    enti ( uint64类型):WAL中最后一条Entry记录的索引值。
    locks ( []*fileutil.LockedFile类型): 当前WAL实例管理的所有WAL日志文件对应的句柄。
    fp  ( *filePipeline类型): filePipeline实例负责创建新的临时文件。

filePipeline,它负责预创建日志文件并为日志文件预分配空间。在filePipeline 中会启动一个独立的后台goroutine来创建".tmp”结尾的临时文件,当进行日志文件切换时, 直接将临时文件进行重命名即可使用。newFilePipeline()方法中,除了创建filePipeline实例,还会启动一个后台goroutine来执行filePipeline.run()方法,该后台goroutine 中会创建新的临时文件并将其句柄传递到filec通道中。在WAL切换日志文件时会调用filePipeline. Open()方法,从filec通道中获取之前创建好的临时文件

1、初始化

wal.Create()方法,该方法不仅会创建WAL实例,而是做了很多初始化工作:
    (1)创建临时目录,并在临时目录中创建编号为“0-0”的WAL日志文件, WAL日志文件名由两部分组成,一部分是seq(单调递增),另一部分是该日志文件中的第一条日志记录的索引值。
    (2)尝试为该WAL日志文件预分配磁盘空间。
    (3) 向该WAL日志文件中写入一条crcType类型的日志记录、一条metadataType类型的日志记录及一条snapshotType类型的日志记录。
    (4)创建WAL实例关联的filePipeline实例。
    (5)将临时目录重命名为WAL.dir字段指定的名称。

这里之所以先使用临时目录完成初始化操作再将其重命名的方式,主要是为了让整个初始化过程看上去是一个原子操作。wal.Create()方法的具体实现如下:

func Create(dirpath string, metadata []byte) (*WAL, error) {
	if Exist(dirpath) {
		return nil, os.ErrExist
	}

	// keep temporary wal directory so WAL initialization appears atomic
	tmpdirpath := filepath.Clean(dirpath) + ".tmp"//得到临时目录的路径
	if fileutil.Exist(tmpdirpath) {
		if err := os.RemoveAll(tmpdirpath); err != nil {
			return nil, err
		}
	}
	if err := fileutil.CreateDirAll(tmpdirpath); err != nil {//创建临时文件夹
		return nil, err
	}

	p := filepath.Join(tmpdirpath, walName(0, 0))//第一个WAL日志文件的路径(文件名为0-0)
	f, err := fileutil.LockFile(p, os.O_WRONLY|os.O_CREATE, fileutil.PrivateFileMode)
	if err != nil {
		return nil, err
	}
	//移动临时文件的offset到文件结尾处,注意Seek()方法的第二个参数(0是相对文件开头,1是相对当前offset, 2是相对文件结尾)
	if _, err = f.Seek(0, io.SeekEnd); err != nil {
		return nil, err
	}
	//对新建的临时文件进行空间预分配,默认值是64MB(SegmentSizeBytes)
	if err = fileutil.Preallocate(f.File, SegmentSizeBytes, true); err != nil {
		return nil, err
	}
    //创建WAL实例
	w := &WAL{
		dir:      dirpath,//存放WAL日志文件的目录的路径
		metadata: metadata,
	}
	w.encoder, err = newFileEncoder(f.File, 0)//创建写WAL日志文件的encoder
	if err != nil {
		return nil, err
	}
	w.locks = append(w.locks, f)//将WAL日志文件对应的LockedFile实例记录到locks字段中,表示当前WAL实例正在管理该日志文件
	if err = w.saveCrc(0); err != nil {//创建一条crcType类型的日志写入WAL日志文件
		return nil, err
	}
	//创建一条metadataType类型的日志写入WAL日志文件
	if err = w.encoder.encode(&walpb.Record{Type: metadataType, Data: metadata}); err != nil {
		return nil, err
	}
	if err = w.SaveSnapshot(walpb.Snapshot{}); err != nil {//创建一条空的snapshotType类型的日志记录写入临时文件
		return nil, err
	}
    //将临时目录重命名,并创建WAL实例关联的filePipline实例
	if w, err = w.renameWal(tmpdirpath); err != nil {
		return nil, err
	}

	// directory was renamed; sync parent dir to persist rename
	pdir, perr := fileutil.OpenDir(filepath.Dir(w.dir))
	if perr != nil {
		return nil, perr
	}
	//同步磁盘的操作
	if perr = fileutil.Fsync(pdir); perr != nil {
		return nil, perr
	}
	if perr = pdir.Close(); err != nil {
		return nil, perr
	}

	return w, nil
}

2、日志打开

wal 模块提供了Open()和OpenForRead()两个函数,两者的区别在于:使用Open()函数创建的WAL实例读取完全部日志后,可以继续追加日志:而OpenForRead()函数创建的WAL实例只能用于读取日志,不能追加日志.Open()函数或OpenForRead()函数创建WAL实例之后,就可以调用其ReadAll()方法读取日志了。WAL.ReadAll()方法首先从WAL.start 字段指定的位置开始读取日志记录,读取完毕之后,会根据读取的情况进行一系列异常处理。然后根据当前WAL实例的模式进行不同的处理:如果处于读写模式,则需要先对后续的WAL日志文件进行填充并初始化WAL.encoder字段,为后面写入日志做准备;如果处于只读模式下,则需要关闭所有的日志文件。

func (w *WAL) ReadAll() (metadata []byte, state raftpb.HardState, ents []raftpb.Entry, err error) {
	w.mu.Lock()
	defer w.mu.Unlock()

	rec := &walpb.Record{}//创建Record
	decoder := w.decoder//解码器,负责读取日志文件,并将日志数据反序列化成Record实例

	var match bool     //标识是否找到了start字段对应的日志记录
	//循环读取WAL日志文件中的数据,多个WAL日志文件的切才是是在decoder中完成的,后面会详细分析其实现
	for err = decoder.decode(rec); err == nil; err = decoder.decode(rec) {
		switch rec.Type {
		case entryType:
			e := mustUnmarshalEntry(rec.Data)//反序列化Record.Data中记录的数据,得到Entry
			if e.Index > w.start.Index {//将start之后的Entry记录添加到ents中保存
				ents = append(ents[:e.Index-w.start.Index-1], e)
			}
			w.enti = e.Index//记录读取到的最后一条Entry记录的索引值
		case stateType:
			state = mustUnmarshalState(rec.Data)
		case metadataType:
		    //检测metadata数据是否发生冲突,如果冲突,则抛出异常
			if metadata != nil && !bytes.Equal(metadata, rec.Data) {
				state.Reset()
				return nil, state, nil, ErrMetadataConflict
			}
			metadata = rec.Data
		case crcType:
			crc := decoder.crc.Sum32()
			if crc != 0 && rec.Validate(crc) != nil {
				state.Reset()
				return nil, state, nil, ErrCRCMismatch
			}
			decoder.updateCRC(rec.Crc)//更新deeodr.crc字段
		case snapshotType:
			var snap walpb.Snapshot
			pbutil.MustUnmarshal(&snap, rec.Data)//解析快照相关的数据
			if snap.Index == w.start.Index {
				if snap.Term != w.start.Term {
					state.Reset()
					return nil, state, nil, ErrSnapshotMismatch
				}
				match = true//mateh
			}
		default:
			state.Reset()
			return nil, state, nil, fmt.Errorf("unexpected block type %d", rec.Type)
		}
	}
    //根据WAL.locks字段是否有位判断当前WAL是什么模式
	switch w.tail() {
	case nil:
	   //对于只读模式,并不需妥将全部的日志都读出来,因为以只读模式打开WAL日志文件时,并没有加锁,所以最后一条日志记录可能只写了一半,从而导致io.ErrUnexpectedEOF异常
		if err != io.EOF && err != io.ErrUnexpectedEOF {
			state.Reset()
			return nil, state, nil, err
		}
	default:
		// 对于读写模式,则需将日志记录全部读出来,所以此处不是EOF异常,则报错,将文件指针移动到读取结束的位置,并将文件后续部分全部填充为0
		if err != io.EOF {
			state.Reset()
			return nil, state, nil, err
		}
		if _, err = w.tail().Seek(w.decoder.lastOffset(), io.SeekStart); err != nil {
			return nil, state, nil, err
		}
		if err = fileutil.ZeroToEnd(w.tail().File); err != nil {
			return nil, state, nil, err
		}
	}

	err = nil
	if !match {//如采在读取过程中没有找到与start对应的日志记录, 则抛出异常
		err = ErrSnapshotNotFound
	}

	// close decoder, disable reading
	if w.readClose != nil {//如采是只读模式,则关闭所有日志文件
		w.readClose()//WAL.readClose实际指向的是WAL.CloseAll()方法
		w.readClose = nil
	}
	w.start = walpb.Snapshot{}   //清空start字段

	w.metadata = metadata

	if w.tail() != nil {    //如采是读写模式,则初始化WAL.encoder字段, 为后面写入日志做准备
		// create encoder (chain crc with the decoder), enable appending
		w.encoder, err = newFileEncoder(w.tail().File, w.decoder.lastCRC())
		if err != nil {
			return
		}
	}
	w.decoder = nil    //清空WAL.decoder字段,后续不能再用该WAL实例进行读取了

	return metadata, state, ents, err
}

//brs ( []*bufio.Reader类型): 该decoder实例通过该字段中记录的Reader实例读取相应的日志文件,这些日志文件就是wal.openAtlndex()方法中打开的日志文件。
//lastValidOff ( int64类型):读取日志记录的指针。
func (d *decoder) decodeRecord(rec *walpb.Record) error {
	if len(d.brs) == 0 {//检测brs字段长度, 决定是否还有日志文件需要读取
		return io.EOF
	}
    //读取第一个日志文件中的第一个日志记录的长度
	l, err := readInt64(d.brs[0])
	//是否读到文件尾, 或是读取到了预分目己的部分, 这都表示读取操作结束
	if err == io.EOF || (err == nil && l == 0) {
		// hit end of file or preallocated space
		d.brs = d.brs[1:]//更新brs字段,将其中第一个日志文件对应的Reader清除掉
		if len(d.brs) == 0 {//如果后面没有其他日志文件可读则返回EOF异常,表示读取正常结束
			return io.EOF
		}
		d.lastValidOff = 0//若后续还有其他日志文件待读取,则需换文件这里重直lastValidOff
		return d.decodeRecord(rec)//递归调用decodeRecord()方法
	}
	if err != nil {
		return err
	}

	//计算当前日志记录的实际长度及填无数据的长度,并创建相应的data切片
	recBytes, padBytes := decodeFrameSize(l)

	data := make([]byte, recBytes+padBytes)
	if _, err = io.ReadFull(d.brs[0], data); err != nil {//从日志文件中读取指定长度的字节数如读取不到指定的字节数, 则会返回EOF异常,此时返回ErrUnexpectedEOF异常
		// ReadFull returns io.EOF only if no bytes were read
		// the decoder should treat this as an ErrUnexpectedEOF instead.
		if err == io.EOF {
			err = io.ErrUnexpectedEOF
		}
		return err
	}
	//将0-recBytes反序列化成Record
	if err := rec.Unmarshal(data[:recBytes]); err != nil {
		if d.isTornEntry(data) {
			return io.ErrUnexpectedEOF
		}
		return err
	}

	// skip crc checking if the record type is crcType
	if rec.Type != crcType {
		d.crc.Write(rec.Data)
		if err := rec.Validate(d.crc.Sum32()); err != nil {//进行crc校验
			if d.isTornEntry(data) {
				return io.ErrUnexpectedEOF
			}
			return err
		}
	}
	// record decoded as valid; point last valid offset to end of record
	d.lastValidOff += frameSizeBytes + recBytes + padBytes//将lastValidOff后移,准备读取下一条日志记录
	return nil
}

3、追加日志

WAL对外提供了追加日志的方法,分别是Save()方法和SaveSnapshot()方法。WAL.Save()方法先将待写入的Entry记录封装成entryType类型的Record实例,然后将其序列化并追加到日志段文件中,之后将HardState封装成stateType类型的Record实例,并序列化写入日志段文件中,最后将这些日志记录同步刷新到磁盘。WAL.Save()方法的具体实现如下:

func (w *WAL) Save(st raftpb.HardState, ents []raftpb.Entry) error {
	w.mu.Lock()
	defer w.mu.Unlock()

	//边界检查,如果待写入的HardState和Entry数组都为空,则直接返回;否则就需要将修改同步到磁盘上
	if raft.IsEmptyHardState(st) && len(ents) == 0 {
		return nil
	}

	mustSync := raft.MustSync(st, w.state, len(ents))

	// 遍历待写入的Entry数生且,将每个Entry实例序列化并封装entryType类型的日志记录,写入日志文件
	for i := range ents {
		if err := w.saveEntry(&ents[i]); err != nil {
			return err
		}
	}
	//将状态信息(HardState) 序列化并封装成stateType类型的日志记录,写入日志文件
	if err := w.saveState(&st); err != nil {
		return err
	}
    //获取当前日志段文件的文件指针的位置
	curOff, err := w.tail().Seek(0, io.SeekCurrent)
	if err != nil {
		return err
	}
	//如未写满预分画己的空间, 将新日志刷新到磁盘后,即可返回
	if curOff < SegmentSizeBytes {
		if mustSync {
			return w.sync()
		}
		return nil
	}
    //当前文件大小已超出了预分配的空间, 则需进行日志文件的切换
	return w.cut()
}
func (w *WAL) saveEntry(e *raftpb.Entry) error {
	// TODO: add MustMarshalTo to reduce one allocation.
	b := pbutil.MustMarshal(e)//将Entry记录序列化
	//将序列化后的数据封装成entryType类型的Record记录
	rec := &walpb.Record{Type: entryType, Data: b}
	//通过encoder.encode()方法追加日志记录
	if err := w.encoder.encode(rec); err != nil {
		return err
	}
	w.enti = e.Index //更新WAL.enti字段, 其中保存了最后一条Entry记录的索引位
	return nil
}
func (w *WAL) sync() error {
	if w.encoder != nil {
		if err := w.encoder.flush(); err != nil {//先使用encoder.flush() 方法进行同步刷新
			return err
		}
	}
	start := time.Now()
	err := fileutil.Fdatasync(w.tail().File)//使用操作系统的fdatasync将数据真正刷新到磁盘上

	duration := time.Since(start)
	if duration > warnSyncDuration {//这里会对该刷新操作的执行时间进行监控, 如采刷新操作执行的时间长于指定的时间(默认值是ls),则输出警告日志
		plog.Warningf("sync duration of %v, expected less than %v", duration, warnSyncDuration)
	}
	syncDurations.Observe(duration.Seconds())

	return err
}
//bw ( *iouti l.PageW「ite「类型):PageWriter是带有缓冲区的Writer,在写入时,每写满一个Page大小的缓冲区,就会自动触发一次Flush 操作,将数据同步刷新到磁盘上。每个Page的大小是由walPageBytes常量指定的。
//buf ( []byte类型): 日志序列化之后,会暂存在该缓冲区中, 该缓冲区会被复用, 这就防止了每次序列化创建缓冲区带来的开销。
//uint64buf ( []byte类型):在写入一条日志记录时, 该缓冲区用来暂存一个Frame的长度的数据(Frame 由日志数据和填充数据构成)。

func (e *encoder) encode(rec *walpb.Record) error {
	e.mu.Lock()
	defer e.mu.Unlock()

	e.crc.Write(rec.Data)
	rec.Crc = e.crc.Sum32()//计算crc校验码(
	var (
		data []byte
		err  error
		n    int
	)
    //将待写入到日志记录进行序列化
	if rec.Size() > len(e.buf) {//如果日志记录太大,无法复用eneoder.buf这个缓冲区, 则直接序列化
		data, err = rec.Marshal()
		if err != nil {
			return err
		}
	} else {//复用eneoder.buf这个缓冲区
		n, err = rec.MarshalTo(e.buf)
		if err != nil {
			return err
		}
		data = e.buf[:n]
	}
    //计算序列化之后的数据长度,在eneodeFrarneSize()方法中会完成8字节对齐,这里将真正的数据和填充数据看作一个Frame, 返回值分别是整个Frame的长度,以及其中填充数据的长度
	lenField, padBytes := encodeFrameSize(len(data))
	//将Frame的长度序列化到eneoder.uint64buf数组中,然后写入文件
	if err = writeUint64(e.bw, lenField, e.uint64buf); err != nil {
		return err
	}

	if padBytes != 0 {
		data = append(data, make([]byte, padBytes)...)//向data中写入填充字节
	}
	_, err = e.bw.Write(data)//将data中的序列化数据写入文件
	return err
}

4、文件切换

随着WAL日志文件的不断写入, 单个日志文件会不断变大。在前面提到过,每个日志文件的大小是有上限的,该阀值由SegmentSizeBytes指定(默认值是64MB), 该值也是日志文件预分配磁盘空间的大小。当单个日志文件的大小超过该值时, 就会触发日志文件的切换,该切换过程是在WAL.cut()方法中实现的。WAL.cut()方法首先通过filePipeline 获取一个新建的临时文件,然后写入crcType类型、metaType类型、stateType类型等必要日志记录(这个步骤与前面介绍的Create()方法类似),然后将临时文件重命名成符合WAL日志命名规范的新日志文件,并创建对应的encoder实例更新到WAL.encoder字段。

5、SnapShoter

随着节点的运行,会处理客户端和集群中其他节点发来的大量请求,相应的WAL日志量会不断增加,会产生大量的WAL日志文件,另外etcd-raft模块中的raftLog中也会存储大量的Entry记录,这就会导致资源浪费。当节点宕机之后,如果要恢复其状态,则需要从头读取全部的WAL日志文件,这显然是非常耗时的。 为了解决这些问题,etcd会定期创建快照并将其保存到本地磁盘中,在恢复节点状态时会先加载快照文件,使用该快照数据将节点恢复到对应的状态,之后从快照数据之后的相应位置开始读取WAL日志文件,最终将节点恢复到正确的状态。与WAL日志的管理类似,快照管理是snap模块。其中SnapShotter 通过文件的方式管理快照数据,它是snapshot模块的核心。在SnapShoter结构体中只有一个dir宇段(string类型),该字段指定了存储快照文件的目录位置。Snapshotter.SaveSnap()方法的主要功能就是将快照数据保存到快照文件中,其底层是通过调用save()方法实现的。save()方法的具体实现如下:

func (s *Snapshotter) save(snapshot *raftpb.Snapshot) error {
	start := time.Now()
    //创建快照文件名,快照、文件的名称由三部分组成,分别是快照所涵盖的最后一条Entry记录的Term、Index和.snap文件
	fname := fmt.Sprintf("%016x-%016x%s", snapshot.Metadata.Term, snapshot.Metadata.Index, snapSuffix)
	b := pbutil.MustMarshal(snapshot)//将快照数据进行序列化
	crc := crc32.Update(0, crcTable, b)//计算crc
	//将序列化后的数据和校验码封装成snappb.Snapshot实例,
    //这里简单了解一下raftpb.Snapshot和snappb.Snapshot的区别,前者包含了Snapshot数据及一些元数据(例如,该快照数据所涵盖的最后一条Entry记录的Term和Index);后者则是在前者序列化之后的封笨,其中还记录了相应的校验码等信息
	snap := snappb.Snapshot{Crc: crc, Data: b}
	d, err := snap.Marshal()
	if err != nil {
		return err
	} else {
		marshallingDurations.Observe(float64(time.Since(start)) / float64(time.Second))
	}
    //将snappb.Snapshot序列化后的数据写入文件,并同步刷新到磁盘
	err = pioutil.WriteAndSyncFile(filepath.Join(s.dir, fname), d, 0666)
	if err == nil {
		saveDurations.Observe(float64(time.Since(start)) / float64(time.Second))
	} else {
		err1 := os.Remove(filepath.Join(s.dir, fname))
		if err1 != nil {
			plog.Errorf("failed to remove broken snapshot file %s", filepath.Join(s.dir, fname))
		}
	}
	return err
}
发布了48 篇原创文章 · 获赞 9 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/cyq6239075/article/details/105477267