微服务架构与应用Golang

Etcd存储的实现

2019-01-21  本文已影响75人  神奇的考拉

概览

下图中展示了etcd如何处理一个客户端请求的涉及到的模块和流程。图中淡紫色的矩形表示etcd,它包括如下几个模块:

在上图中,一个请求与一个etcd集群交互的主要流程分为两大部分:

  1. 写数据到某个etcd server中。
  2. 该etcd server与集群中的其他etcd节点进行交互,当确保数据已经被存储之后应答客户端。

请求流程划分为了以下的子步骤:

注意在以上流程中,假设这里写入数据的etcd是leader节点,因为在raft协议中,如果提交数据到非leader节点的话需要路由到etcd leader节点去。

而应答步骤如下:

从上面的流程可以看到

以下就来分析etcd server与这部分相关的几个模块。

etcd server与raft的交互

EtcdServer结构体,负责对外与客户端进行通信。内部有一个raftNode结构的成员,负责与etcd的raft库进行交互。

etcd V3版本的API,通过GRPC协议与客户端进行交互,其相关代码在etcdserver/v3_server.go中。以一次Put请求为例,最后将会调用的代码在函数EtcdServer::processInternalRaftRequestOnce中,代码的主要流程分析如下。

etcd flowchart
  1. 拿到当前raft中的apply和commit索引,如果commit索引比apply索引超出太多,说明当前有很多数据都没有apply,返回ErrTooManyRequests错误。
  2. 调用s.reqIDGen.Next()函数生成一个针对当前请求的ID,注意这个ID并不是一个随机数而是一个严格递增的整数。同时将请求序列化为byte数据,这会做为raft的数据进行存储。
  3. 根据第2步中的ID,调用Wait.Register函数进行注册,这会返回一个用于通知结果的channel,后续就通过监听该channel来确定是否成功储存了提交的值。
  4. 调用Raft.Process函数提交数据,这里传入的参数除了前面序列化的数据之外,还有使用超时时间创建的Context。
  5. 监听前面的Channel以及Context对象: a. 如果context.Done返回,说明数据提交超时,使用s.parseProposeCtxErr函数返回具体的错误。 b. 如果channel返回,说明已经提交成功。

从以上的流程可以看出,在调用Raft.Process函数向Raft库提交数据之后,等待被唤醒的Channel才是正常提交数据成功的路径。

在EtcdServer.run函数中,最终会进入一个死循环中,等待raftNode.apply返回的channel被唤醒,而raftNode继承了raft.Node的实现,从前面分析etcd raft的流程中可以明白,EtcdServer就是在向raft库提交了数据之后,做为其上层消费Ready数据的应用层。

自此,整体的流程大体已经清晰:

  1. EtcdServer对外通过GRPC协议接收客户端请求,对内有一个raftNode类型的成员,该类型继承了raft.Node的实现。
  2. 客户端通过EtcdServer提交的数据修改都会通过raftNode来提交,而EtcdServer本身通过监听channel与raft库进行通信,由Ready结构体来通过EtcdServer哪些数据已经提交成功。
  3. 由于每个请求都会一个对应的ID,ID绑定了Channel,所以提交成功的请求通过ID找到对应的Channel来唤醒提交流程,最后通知客户端提交数据成功。

WAL

以上介绍了EtcdServer的大体流程,接下来看WAL的实现。

前面已经分析过了,etcd raft提交数据成功之后,将通知上面的应用层(在这里就是EtcdServer),然后再进行持久化数据存储。而数据的持久化可能会花费一些时间,因此在应答应用层之前,EtcdServer中的raftNode会首先将这些数据写入WAL日志中。这样即使在做持久化的时候数据丢失了,启动恢复的时候也可以根据WAL的日志进行数据恢复。

etcdserver模块中,给raftNode用于写WAL日志的工作,交给了接口Storage来完成,而这个接口由storage来具体实现:

type storage struct {

    *wal.WAL

    *snap.Snapshotter

}

可以看到,这个结构体组合了WAL和snap.Snapshotter结构,Snapshotter负责的是存储快照数据。

WAL日志文件中,每条日志记录有以下的类型:

  1. Type:日志记录类型,下面详细解释都有哪些类型。
  2. Crc:这一条日志记录的校验数据。
  3. Data:真正的数据,根据类型不同存储的数据也不同。

日志记录又有如下的类型:

  1. metadataType:存储的是元数据(metadata),每个WAL文件开头都有这类型的一条记录数据。
  2. entryType:保存的是raft的数据,也就是客户端提交上来并且已经commit的数据。
  3. stateType:保存的是当前集群的状态信息,即前面提到的HardState。
  4. crcType:校验数据。
  5. snapshotType:快照数据。

etcd使用两个目录分别存放WAL文件以及快照文件。其中,WAL文件的文件名格式是“16位的WAL文件编号-该WAL第一条entry数据的index号.wal”,这样就能从WAL文件名知道该WAL文件中保存的entry数据至少大于什么索引号。而快照文件名的格式则是“16位的快照数据最后一条日志记录任期号-16位的快照数据最后一条记录的索引号.snap”。

Etcd会管理WAL目录中的所有WAL文件,但是在生成快照文件之后,在快照数据之前的WAL文件将被清除掉,保证磁盘不会一直增长。

比如当前etcd中有三个WAL文件,可以从这些文件的文件名知道其中存放数据的索引范围。

etcd wal1

在生成快照文件之后,此时就只剩一个WAL文件和一个快照文件了:

etcd wal2

那么,又是在什么情况下生成快照文件呢?Etcdserver在主循环中通过监听channel获知当前raft协议返回的Ready数据,此时会做判断如果当前保存的快照数据索引距离上一次已经超过一个阈值(EtcdServer.snapCount),此时就从raft的存储中生成一份当前的快照数据,写入快照文件成功之后,就可以将这之前的WAL文件释放了。

以上流程和对应的具体函数见下面的流程图。

etcd snapshot

backend store的实现

revision概念

Etcd存储数据时,并不是像其他的KV存储那样,存放数据的键做为key,而是以数据的revision做为key,键值做为数据来存放。如何理解revision这个概念,以下面的例子来说明。

比如通过批量接口两次更新两对键值,第一次写入数据时,写入<key1,value1>和<key2,value2>,在Etcd这边的存储看来,存放的数据就是这样的:

  revision={1,0}, key=key1, value=value1
  revision={1,1}, key=key2, value=value2

而在第二次更新写入数据<key1,update1>和<key2,update2>后,存储中又记录(注意不是覆盖前面的数据)了以下数据:

  revision={2,0}, key=key1, value=update1
  revision={2,1}, key=key2, value=update2

其中revision有两部分组成,第一部分成为main revision,每次事务递增1;第二部分称为sub revision,一个事务内的一次操作递增1。 两者结合,就能保证每次key唯一而且是递增的。

etcd revision

但是,就客户端看来,每次操作的时候是根据Key来进行操作的,所以这里就需要一个Key映射到当前revision的操作了,为了做到这个映射关系,Etcd引入了一个内存中的Btree索引,整个操作过程如下面的流程所示。

etcd keyindex

查询时,先通过内存中的btree索引来查询该key对应的keyIndex结构体,然后再根据这个结构体才能去boltdb中查询真实的数据返回。

所以,下面先展开讨论这个keyIndex结构体和btree索引。

keyIndex结构

keyIndex结构体有以下成员:

如何理解generation结构呢,可以认为每个generation对应一个数据从创建到删除的过程。每次删除key的操作,都会导致一个generation最后添加一个tombstone记录,然后创建一个新的空generation记录添加到generations数组中。

generation结构体存放以下数据:

以下图来说明keyIndex结构体:

etcd keyindex struct

如上图所示,存放的键为test的keyIndex结构。

它的generations数组有两条记录,其中generations[0]在revision 1.0时创建,当revision2.1的时候进行tombstone操作,因此该generation的created是1.0;对应的generations[1]在revision3.3时创建,紧跟着就做了tombstone操作。

所以该keyIndex.modifiled成员存放的是3.3,因为这是这条数据最后一次被修改的revision。

一个已经被tombstone的generation是可以被删除的,如果整个generations数组都已经被删除空了,那么整个keyIndex记录也可以被删除了。

etcd generation_compact

如上图所示,keyIndex.compact(n)函数可以对keyIndex数据进行压缩操作,将删除满足main revision < n的数据。

treeIndex结构

Etcd中使用treeIndex来在内存中存放keyIndex数据信息,这样就可以快速的根据输入的key定位到对应的keyIndex。

treeIndex使用开源的github.com/google/btree来在内存中存储btree索引信息,因为用的是外部库,所以不打算就这部分做解释。而如果很清楚了前面keyIndex结构,其实这部分很好理解。

所有的操作都以key做为参数进行操作,treeIndex使用btree根据key查找到对应的keyIndex,再进行相关的操作,最后重新写入到btree中。

store

前面讲到了WAL数据的存储、内存索引数据的存储,这部分讨论持久化存储数据的模块。

etcd V3版本中,使用BoltDB来持久化存储数据(etcd V2版本的实现不做讨论)。所以这里先简单解释一下BoltDB中的相关概念。

BoltDB相关概念

BoltDB中涉及到的几个数据结构,分别为DB、Bucket、Tx、Cursor、Tx等。

其中:

Backend与BackendTx接口

Backend和BackendTx内部的实现,封装了BoltDB,太简单就不做分析了。

Lessor接口

etcd中没有提供针对数据设置过期时间的操作,通过租约(Lease)来实现数据过期的效果。而Lessor接口就提供了管理租约的相关接口。

比如,使用etcdctl命令可以创建一个lease:

etcdctl lease grant 10 lease 694d67ed2bfbea03 granted with TTL(10s)

这样就创建了一个ID为694d67ed2bfbea03的Lease,此时可以将键值与这个lease进行绑定:

etcdctl put --lease=694d67ed2bfbea03 a b

当时间还没超过过期时间10S时,能通过etcd拿到这对键值的数据。如果超时了就获取不到数据了。

从上面的命令可以看出,一个Lease可以与多个键值对应,由这个Lease通过管理与其绑定的键值数据的生命周期。

etcd中,将Lease ID存放在名为“lease”的Bucket中,注意在这里只存放Lease相关的数据,其键值为:<Lease ID,序列化后的Lease数据包括TTL、ID>,之所以不存放与Lease绑定的键值,是因为这些键值已经存放到另外的Bucket里了,写入数据的时候也会将这些键值绑定的Lease ID写入,这样在恢复数据的时候就可以将键值与Lease ID绑定的关系写入内存中。

即:Lease这边需要持久化的数据只有Lease ID与TTL值,而键值对这边会持久化所绑定的Lease ID,这样在启动恢复的时候可以将两者对应的关系恢复到内存中。

Lease

明白了以上关系再来理解Lessor的实现就很简单了。

lessor中主要包括以下的成员:

其他的就很简单了:

  1. lessor启动之后会运行一个goroutine协程,在这个协程里定期查询哪些Lease超时,超时的Lease将通过expiredC channel通知外部。
  2. 而针对Lease的CRUD操作,都需要进行加锁才能操作。

KV接口

有了以上的准备,可以开始分析数据存储相关的内容了。在etcd V3中,所有涉及到数据的存储,都会通过KV接口。

store结构体实现了KV接口,其中最重要的就是封装了前面提到的几个数据结构:

在store结构体初始化时,根据传入的backend.Backend,初始化backend.BatchTx结构,后面的任何涉及到事务的操作,都可以通过这个backend.BatchTx来进行。

其实有了前面的准备,理解store结构做的事情已经不难,以一次Put操作为例,其流程主要如下图所示:

etcd store_put

applierV3

EtcdServer内部实现中,实际使用的是applierV3接口来进行持久化数据的操作。

这个接口有以下几个实现,但是其中applierV3backend的实现是最重要的,其内部使用了前面提到的KV接口来进行数据的处理。

另外,applierV3接口还有其他几个实现,这里分别列举一下。

综述

下图将上面涉及到的关键数据结构串联在一起,看看EtcdServer在收到Raft库通过Ready channel通知的可以持久化数据之后,都做了什么操作。

etcd raft_flow
  1. raft库通过Ready Channel通知上层的raftNode哪些数据可以进行持久化。
  2. raftNode启动之后也是会启动一个Goroutine来一直监听这个Ready Channel,以便收到可以持久化数据的通知。
  3. raftNode在收到Ready数据之后,将首先写入WAL日志中。这里的WAL日志由storage结构体来管理,分为两大部分:WAL日志以及WAL快照文件数据Snapshotter,后者用来避免WAL文件一直增大。
  4. raftNode在写WAL数据完成之后,通过apply Channel通知EtcdServer。
  5. EtcdServer启动之后也是启动一个Goroutine来监听这个channel,以便收到可以持久化数据的通知。
  6. EtcdServer通过调用applierV3接口来持久化数据。applierV3backend结构体实现applierV3接口, applierV3backend结构体实现applierV3接口,内部通过调用KV接口进行持久化操作。而在实现KV接口的store结构体中,treeIndex负责在内存中维护数据键值与revision的对应关系即keyIndex数据,Backend接口负责持久化数据,最后持久化的数据将落盘到BoltDB中。

原文链接

上一篇下一篇

猜你喜欢

热点阅读