etcd 是一个 Go 言编写的分布式、高可用的一致性键值存储系统, 用于提供可靠的分布式键值存储、配置共享和服务发现等功能, 具有以下特点:
(n-1)/2
节点发送故障, 依然能提供服务;从 etcd 的架构图中我们可以看到, etcd 主要分为四个部分:
Raft 协议一共包含如下 3 类角色:
然后在进行选举过程中, 还有几个重要的概念:
这幅图是领袖、候选人和群众的角色切换图, 我先简单总结一下:
为了便于后续的讲解, 我画了一副简图, “选举定时器"其实就是每个节点的"超时时间”。
成为候选人: 每个节点都有自己的"超时时间", 因为是随机的, 区间值为 150~300ms, 所以出现相同随机时间的概率比较小, 因为节点 B 最先超时, 这时它就成为候选人。
选举领导人: 候选人 B 开始发起投票, 群众 A 和 C 返回投票, 当候选人 B 获取大部分选票后, 选举成功, 候选人 B 成为领袖。
心跳探测: 为了时刻宣誓自己的领导人地位, 领袖 B 需要时刻向群众发起心跳, 当群众 A 和 C 收到领袖 B 的心跳后, 群众 A 和 C 的"超时时间"会重置为 0, 然后重新计数, 依次反复。这里需要说明一下, 领袖广播心跳的周期必须要短于"选举定时器"的超时时间, 否则群众会频繁成为候选者, 也就会出现频繁发生选举, 切换 Leader 的情况。
当领袖 B 挂掉, 群众 A 和 C 会的"选举定时器"会一直运行, 当群众 A 先超时时, 会成为候选人, 然后后续流程和"领导人选举"流程一样, 即通知投票 -> 接收投票 -> 成为领袖 -> 心跳探测。
当出现多个候选者 A 和 D 时, 两个候选者会同时发起投票, 如果票数不同, 最先得到大部分投票的节点会成为领袖; 如果获取的票数相同, 会重新发起新一轮的投票。
当 C 成为新的候选者, 此时的任期 Term 为 5, 发起新一轮的投票, 其它节点发起投票后, 会更新自己的任期值, 最后选择新的领袖为 C 节点。
复制状态机的基本思想是一个分布式的状态机, 系统由多个复制单元组成, 每个复制单元均是一个状态机, 它的状态保存在操作日志中。如下图所示, 服务器上的一致性模块负责接收外部命令, 然后追加到自己的操作日志中, 它与其他服务器上的一致性模块进行通信, 以保证每一个服务器上的操作日志最终都以相同的顺序包含相同的指令。一旦指令被正确复制, 那么每一个服务器的状态机都将按照操作日志的顺序来处理它们, 然后将输出结果返回给客户端。
数据同步流程, 借鉴了"复制状态机"的思想, 都是先"提交", 再"应用"。当 Client 发起数据更新请求, 请求会先到领袖节点 C, 节点 C 会更新日志数据, 然后通知群众节点也更新日志, 当群众节点更新日志成功后, 会返回成功通知给领袖 C, 至此完成了"提交"操作; 当领袖 C 收到通知后, 会更新本地数据, 并通知群众也更新本地数据, 同时会返回成功通知给 Client, 至此完成了"应用"操作, 如果后续 Client 又有新的数据更新操作, 会重复上述流程。
每一个日志条目一般包括三个属性: 整数索引 Log Index、任期号 Term 和指令 Commond。每个条目所包含的"整数索引"即该条目在日志文件中的槽位, "任期号"对应到图中就是每个方块中的数字, 用于检测在不同服务器上日志的不一致问题, 指令即用于被状态机执行的外部命令, 图中就是带箭头的数字。领导人决定什么时候将日志条目应用到状态机是安全的, 即可被提交的呢? 一旦领导人创建的条目已经被复制到半数以上的节点上了, 那么这个条目就称为可被提交的。例如, 图中的 9 号条目在其中 4 节点(一共 7 个节点)上具有复制, 所以 9 号条目是可被提交的; 但条目 10 只在其中 3 个节点上有复制, 因此 10 号条目不是可被提交的。
一般情况下, Leader 和 Follower 的日志都是保存一致的, 如果 Leader 节点在故障之前没有向其它节点完全复制日志文件之前的所有条目, 会导致日志不一致问题。在 Raft 算法中, Leader 会强制 Follower 和自己的日志保存一致, 因此 Follower 上与 Leader 的冲突日志会被领导者的日志强制覆写。为了实现上述逻辑, 就需要知道 Follower 上与 Leader 日志不一致的位置, 那么 Leader 是如何精准找到每个 Follower 日志不一致的那个槽位呢? Leader 为每一个 Follower 维护了一个 nextlndex, 它表示领导人将要发送给该追随者的下一条日志条目的索引, 当一个 Leader 赢得选举时, 它会假设每个 Follower 上的日志都与自己的保持-致, 于是先将 nextlndex 初始化为它最新的日志条目索引数+1, 在上图中, 由于 Leader 最新的日志条目 index 是 10 , 所以 nextlndex 的初始值是 11。当 Leader 向 Follower 发送 AppendEntries RPC 时, 它携带了 (item_id, nextIndex - 1) 二元组信息, item_id 即为 nextIndex - 1 这个槽位的日志条目的 term。Follower 接收到 AppendEntries RPC 消息后, 会进行一致性检查, 即搜索自己的日志文件中是否存在这样的日志条目, 如果不存在, 就像 Leader 返回 AppendEntries RPC 失败, 然后领导人会将 nextIndex 递减, 然后进行重试, 直到成功为止。之后的逻辑就比较简单, Follower 将 nextIndex 之前的日志全部保留, 之后的全部删除, 然后将 Leader 的 nextIndex 之后的日志全部同步过来。上面只是讲述了方法, 下面举个例子, 加深一下理解, 还是以上面的图为例。Leader 的 nextlndex 为 11, 向 b 发送 AppendEntries RPC(6,10), 发现 b 没有, 继续发送 (6,9)(6,8) (5,7) (5,6) (4,5), 最后发送 (4,4) 才找到, 所以对于 b, nextlndex=4 之后的日志全部删除, 然后将 Leader 的 nextlndex=4 的日志全部追加过来。
当网络问题导致脑裂, 出现双 Leader 情况时, 每个网络可以理解为一个独立的网络, 因为原先的 Leader 独自在一个区, 所以向他提交的数据不可能被复制到大多数节点上, 所以数据永远都不会提交, 这个可以在第 4 幅图中提现出来 (SET 3 没有提交)。
当网络恢复之后, 旧的 Leader 发现集群中的新 Leader 的 Term 比自己大, 则自动降级为 Follower, 并从新 Leader 处同步数据达成集群数据一致, 同步数据的方式可以详见"3.3.3 日志原理"。
脑裂情况其实只是异常情况的一种, 当 Leader 通知 Follower 更新日志、Leader 提交更新时, 都存在各种异常情况导致的问题, 这个我就不再详述了, 具体可以参考《云原生分布式存储基石-etcd 深入解析》书中的"1.4.3 异常情况"这一章, 里面讲述的比较清楚。
以 MAC 系统为例, 讲述 2 种按照方法, 第一种很简单, 是 Mac 自带的:
#用 brew 安装非常方便, 没安装的自行安装 Homebrew, 通过下面命令可以查看安装包
brew search etcd
#安装
brew install etc
#查看版本
etcd --version
#启动, 如果没有--enable-v2=true, 就不用使用 v2 的接口
etcd --enable-v2=true
不过这种方式可能会安装失败, 我这把失败时提示日志目前没有权限, 根据提示执行相关命令即可, 我这边的提示如下:
sudo chown -R $(whoami) /usr/local/var/log
我个人更推荐下面这种安装方式:
ETCD_VER=v3.4.14
# choose either URL
GOOGLE_URL=https://storage.googleapis.com/etcd
GITHUB_URL=https://github.com/etcd-io/etcd/releases/download
DOWNLOAD_URL=${GOOGLE_URL}
rm -f /tmp/etcd-${ETCD_VER}-darwin-amd64.zip
rm -rf /tmp/etcd-download-test && mkdir -p /tmp/etcd-download-test
curl -L ${DOWNLOAD_URL}/${ETCD_VER}/etcd-${ETCD_VER}-darwin-amd64.zip -o /tmp/etcd-${ETCD_VER}-darwin-amd64.zip
unzip /tmp/etcd-${ETCD_VER}-darwin-amd64.zip -d /tmp && rm -f /tmp/etcd-${ETCD_VER}-darwin-amd64.zip
mv /tmp/etcd-${ETCD_VER}-darwin-amd64/* /tmp/etcd-download-test && rm -rf mv /tmp/etcd-${ETCD_VER}-darwin-amd64
#输出 etcd 版本
/tmp/etcd-download-test/etcd --version
/tmp/etcd-download-test/etcdctl version
#这里是把 etcd 和 etcdctl copy 到 bin 目录下面
cp /tmp/etcd-download-test/etcd /usr/local/bin
cp /tmp/etcd-download-test/etcdctl /usr/local/bin
然后执行:
#安装 etcd
sh etcd_install.sh
#查看版本
etcd --version
#启动, 如果没有--enable-v2=true, 就不用使用 v2 的接口
etcd --enable-v2=true
下面我们可以部署一个 etcd 集群, 我把代码还是写到文件中, 第一个脚本为不支持在 Docs 外粘贴 block, 内容如下(启动 etcd 需要很多参数, 这些参数我都已经注释说明, 更多参数详见: https://www.cnblogs.com/linuxws/p/11194403.html):
TOKEN=token-01
CLUSTER_STATE=new
NAME_1=etcd-01
NAME_2=etcd-02
NAME_3=etcd-03
HOST_1=127.0.0.1
HOST_2=127.0.0.1
HOST_3=127.0.0.1
PORT_API_1=2379
PORT_PEER_1=2380
PORT_API_2=2479
PORT_PEER_2=2480
PORT_API_3=2579
PORT_PEER_3=2580
CLUSTER=${NAME_1}=http://${HOST_1}:${PORT_PEER_1},${NAME_2}=http://${HOST_2}:${PORT_PEER_2},${NAME_3}=http://${HOST_3}:${PORT_PEER_3}
# For every machine
THIS_NAME=${NAME_1}
THIS_IP=${HOST_1}
THIS_PORT_API=${PORT_API_1}
THIS_PORT_PEER=${PORT_PEER_1}
# 用于杀死进程
lsof -i:2379 | awk '{print $2}' | grep -v "PID" | uniq | xargs kill -9
# --enable-v2 支持 v2 接口, 可以省略
# --data-dir 数据存储目录, 可以省略
# --name 节点名称, 必须
# --initial-advertise-peer-urls 数据在集群内进行交互的 url, 必须
# --listen-peer-urls 集群节点之间通信监听的 url, 必须
# --advertise-client-urls 客户通过该地址与本 member 交互信息, 可以省略
# --listen-client-urls 监听客户端请求的 url, 必须
# --initial-cluster 初始启动的集群配置, 必须
# --initial-cluster-state 初始化集群状态, 取值为 new 和 existing, 可以省略
# --initial-cluster-token 集群初始化 token, 可以省略
etcd --enable-v2=true --data-dir=data.${THIS_NAME} --name ${THIS_NAME} \
--initial-advertise-peer-urls http://${THIS_IP}:${THIS_PORT_PEER} --listen-peer-urls http://${THIS_IP}:${THIS_PORT_PEER} \ --advertise-client-urls http://${THIS_IP}:${THIS_PORT_API} --listen-client-urls http://${THIS_IP}:${THIS_PORT_API} \ --initial-cluster ${CLUSTER} \
--initial-cluster-state ${CLUSTER_STATE} --initial-cluster-token ${TOKEN}
第二个脚本, 需要把里面的内容替换如下:
# For every machine
THIS_NAME=${NAME_2}
THIS_IP=${HOST_2}
THIS_PORT_API=${PORT_API_2}
THIS_PORT_PEER=${PORT_PEER_2}
# 用于杀死进程
lsof -i:2479 | awk '{print $2}' | grep -v "PID" | uniq | xargs kill -9
第三个脚本, 需要把里面的内容替换如下:
# For every machine
THIS_NAME=${NAME_3}
THIS_IP=${HOST_3}
THIS_PORT_API=${PORT_API_3}
THIS_PORT_PEER=${PORT_PEER_3}
# 用于杀死进程
lsof -i:2579 | awk '{print $2}' | grep -v "PID" | uniq | xargs kill -9
有了这 3 个脚本, 分别开 3 个窗口, 分别执行, 服务启动截图如下:
当这 3 个脚本全部启动后, 集群部署完毕, 我们检查一下 3 个节点的健康状态:
curl http://127.0.0.1:2379/healthcurl http://127.0.0.1:2479/healthcurl http://127.0.0.1:2579/health
返回结果如下, 其中 peerURLs 是节点互相通信访问的 url, clientURLs 是对外访问的 url:
{
"members":[
{
"id":"264ae6bc59e99892",
"name":"etcd-01",
"peerURLs":[
"http://127.0.0.1:2380"
],
"clientURLs":[
"http://127.0.0.1:2379"
]
},
{
"id":"dbafe5ad6b652eda",
"name":"etcd-02",
"peerURLs":[
"http://127.0.0.1:2480"
],
"clientURLs":[
"http://127.0.0.1:2479"
]
},
{
"id":"f570ae41f524bdcb",
"name":"etcd-03",
"peerURLs":[
"http://127.0.0.1:2580"
],
"clientURLs":[
"http://127.0.0.1:2579"
]
}
]
}
问题 1: 服务启动后, 不能使用 v2 接口, 比如执行 “curl http://127.0.0.1:2379/v2/members”, 提示"404 page not found"
问题 2: 服务启动失败, 提示"conflicting environment variable “ETCD_ENABLE_V2” is shadowed by corresponding command-line flag (either unset environment variable or disable flag)"
问题 3: 启动某个节点时, 提示 member 已经存在
我们在部署集群时, 用到一些方法, 这里我简单汇总一下:
# 版本检查, 输出{"etcdserver":"3.4.14","etcdcluster":"3.4.0"}
curl http://127.0.0.1:2379/version//
# 健康检查, 输出{"health":"true"}
curl http://127.0.0.1:2379/health//
# 查看集群节点
curl http://127.0.0.1:2379/v2/members
设置键的值:
curl http://127.0.0.1:2379/v2/keys/message -XPUT -d value="hello world"
返回结果:
{
"action":"set",
"node":{
"key":"/message",
"value":"hello world",
"modifiedIndex":43,
"createdIndex":43
}
}
读取键的值:
curl http://127.0.0.1:2379/v2/keys/message
返回结果:
{
"action":"get",
"node":{
"key":"/message",
"value":"hello world",
"modifiedIndex":43,
"createdIndex":43
}
}
给键设置 10s 的超时时间:
curl http://127.0.0.1:2379/v2/keys/message -XPUT -d value="hello world" -d ttl=10
返回结果 (prevNode 是旧值):
{
"action":"set",
"node":{
"key":"/message",
"value":"hello world",
"expiration":"2021-01-21T00:16:13.777434Z",
"ttl":10,
"modifiedIndex":44,
"createdIndex":44
},
"prevNode":{
"key":"/message",
"value":"hello world",
"modifiedIndex":43,
"createdIndex":43
}
}
获取该键值, 超时后, 就提示"key not found":
可以对 key 设置监听, 当 key 的值有变化时, 会通知监听的客户端, 我们先在客户端 A 监听 key:
curl http://127.0.0.1:2379/v2/keys/message?wait=true
然后在客户端 B, 修改该 key 的值:
curl http://127.0.0.1:2379/v2/keys/message -XPUT -d value="hello world2"
客户端 A 返回并退出, 返回结果:
{
"action":"set",
"node":{
"key":"/message",
"value":"hello world2",
"modifiedIndex":48,
"createdIndex":48
}
}
如果希望客户端 A 能持续监听, 不退出, 可以通过增加 stream=true 参数:
curl "http://127.0.0.1:2379/v2/keys/message?wait=true&stream=true"
当在客户端 B 执行如下时:
curl http://127.0.0.1:2379/v2/keys/message -XPUT -d value="hello world" -d ttl=1
客户端 A 会实时监听返回, 比如当给 key 设置值, 或者当 key 过期时, 客户端 A 都会监听到:
这个可用于配置管理, 因为 etcd 数据结构是颗目录树, 所以我们也可以 PUT 一个目录, 目录里存放我们的服务的配置数据:
curl http://127.0.0.1:2379/v2/keys/animal -XPUT -d dir=true
返回结果:
{
"action":"set",
"node":{
"key":"/animal",
"dir":true,
"modifiedIndex":80,
"createdIndex":80
}
}
可以获取目录中的数据, 如果增加参数 recursive=true, 可以递归罗列:
curl http://127.0.0.1:2379/v2/keys?recursive=true
返回结果:
{
"action":"get",
"node":{
"dir":true,
"nodes":[
{
"key":"/animal",
"dir":true,
"modifiedIndex":80,
"createdIndex":80
}
]
}
}
下面我们举一个稍微复杂的示例, 创建 2 个目录:
curl http://127.0.0.1:2379/v2/keys/animal -XPUT -d dir=true
curl http://127.0.0.1:2379/v2/keys/animal/cat -XPUT -d value="a little cat"
curl http://127.0.0.1:2379/v2/keys/animal/dog -XPUT -d value="a big dog"
curl http://127.0.0.1:2379/v2/keys/tool -XPUT -d dir=true
curl http://127.0.0.1:2379/v2/keys/tool/car -XPUT -d value="a small car"
curl http://127.0.0.1:2379/v2/keys/tool/ship -XPUT -d value="a big ship"
然后递归获取里面的数据:
curl http://127.0.0.1:2379/v2/keys?recursive=true
返回结果:
{
"action":"get",
"node":{
"dir":true,
"nodes":[
{
"key":"/tool",
"dir":true,
"nodes":[
{
"key":"/tool/car",
"value":"a small car",
"modifiedIndex":84,
"createdIndex":84
},
{
"key":"/tool/ship",
"value":"a big ship",
"modifiedIndex":85,
"createdIndex":85
}
],
"modifiedIndex":83,
"createdIndex":83
},
{
"key":"/animal",
"dir":true,
"nodes":[
{
"key":"/animal/cat",
"value":"a little cat",
"modifiedIndex":81,
"createdIndex":81
},
{
"key":"/animal/dog",
"value":"a big dog",
"modifiedIndex":82,
"createdIndex":82
}
],
"modifiedIndex":80,
"createdIndex":80
}
]
}
}
我们也可以通过下面方式删除 animal 目录:
curl http://127.0.0.1:2379/v2/keys/animal?recursive=true -XDELETE
返回结果:
{
"action":"delete",
"node":{
"key":"/animal",
"dir":true,
"modifiedIndex":86,
"createdIndex":80
},
"prevNode":{
"key":"/animal",
"dir":true,
"modifiedIndex":80,
"createdIndex":80
}
}
通过对一个目录发起 POST 请求, 我们能够让创建的 key 的名字是有序的。自动创建有序 key 的这个功能在许多场景下都很有用, 例如, 用于实现一个对处理顺序有严格要求的队列等。
curl http://127.0.0.1:2379/v2/keys/queue -XPOST -d value=Jobl
返回结果:
{
"action":"create",
"node":{
"key":"/queue/00000000000000000088",
"value":"Jobl",
"modifiedIndex":88,
"createdIndex":88
}
}
如果我们多执行几次, 会发现 key 中的值是递增关系, 但如果在并发情况下, 就不一定每次都是递增 1, 看一下执行结果:
我们可以列举这个目录下所有的 key, 并排序输出:
curl -s "http://127.0.0.1:2379/v2/keys/queue?recursive=true&sort=true"
输出结果:
{
"action":"get",
"node":{
"key":"/queue",
"dir":true,
"nodes":[
{
"key":"/queue/00000000000000000088",
"value":"Jobl",
"modifiedIndex":88,
"createdIndex":88
},
{
"key":"/queue/00000000000000000089",
"value":"Jobl",
"modifiedIndex":89,
"createdIndex":89
},
{
"key":"/queue/00000000000000000090",
"value":"Jobl",
"modifiedIndex":90,
"createdIndex":90
},
{
"key":"/queue/00000000000000000091",
"value":"Jobl",
"modifiedIndex":91,
"createdIndex":91
},
{
"key":"/queue/00000000000000000092",
"value":"Jobl",
"modifiedIndex":92,
"createdIndex":92
},
{
"key":"/queue/00000000000000000093",
"value":"Jobl",
"modifiedIndex":93,
"createdIndex":93
}
],
"modifiedIndex":88,
"createdIndex":88
}
}
目前 etcd 主要经历了 3 个大的版本, 分别为 etcd 0.4 版本、etcd 2.0 版本和 etcd 3.0 版本。对于 etcd 2.0 版本, 已经可以很好满足 etcd 的初步需求, 主要包括:
gRPC 是 Google 开源的 个高性能、跨语言的 RPC 框架, 基于 HTTP/2 协议实现。它使用 protobuf 作为序列化和反序列化协议, 即基于 protobuf 来声明数据模型和 RPC 接口服务。protobuf 的效率远高于 JSON, 尽管 etcd v2 的客户端已经对 JSON 的序列号和反序列化进行了大量的优化, 但是 etcd v3 的 gRPC 序列号和反序列化的速度依旧是 etcd v2 的两倍多。
etcdv3 的客户端使用 gRPC 与 server 进行通信, 通信的消息协议使用 protobuf 进行约定, 代替了 v2 版本的 HTTP+JSON 格式, 使用二进制替代文本, 更加节省空间。同时 gRPC 使用的是 HTTP/2 协议, 同一个连接可以同时处理多个请求, 不必像 HTTP1.1 协议中, 多个请求需要建立多个连接。同时, HTTP/2 会对请求的 Header 和请求数据进行压缩编码, 常见的有 Header 帧, 用于传输 Header 内容, 另外就是 Data 帧, 来传输正文实体。客户端可以将多个请求放到不同的流中, 然后将这些流拆分成帧的形式进行二进制传输, 传输的帧也会有一个编号, 因此在一个连接中客户端可以发送多个请求, 减少了连接数, 降低了对服务器的压力, 二进制的数据传输格式也会是传输速度更快。
总结一下, 其实这里主要进行 2 点优化:
etcdv2 中的键的实效是使用 TTL 机制来实现的, 每个有存活时间的键, 客户端必须定期的进行刷新重新设置保证它不被自动删除, 每次刷新同时还会重新建立连接去更新键。也就是说, 及时整个集群都处于空闲状态, 也会有很多客户端与服务器进行定期通信, 以保证某个 key 不被自动删除。
etcdv3 版本中采用了租约机制进行实现, 每个租约会有一个 TTL, 然后将一些 key 附加到租约上, 当租约到期后, 附加到它上边的 key 都会被删除。利用键的过期机制可以实现服务注册功能, 我们可以将一个服务的域名、IP 等信息注册到 etcd 中, 并给相应的键设置租约, 并在 TTL 时间内定期维持一个心跳进行刷新。当服务故障后, 心跳消失从而相应的键就会自动删除, 从而实现了服务的注册功能和服务的健康检查功能。
总结一下, 就是 v2 版本比较傻瓜, 需要时刻维护每个 key 的通信, v3 就比较智能, 整个统一的过期 key 的代号, 我们把代号称之为"租约", 我们只需要维护这个代号即可, 避免客户端去维护所有的 key。
etcdv2 中的键被废除以后, 为了能够跟踪 key 的变化, 使用了事件机制进行跟踪, 维护键的状态, 来防止被删除掉的后键还能恢复和 watch 到, 但是有一个滑动窗口的大小限制, 那么如果要获取 1000 个时间之前的键就获取不到了。因此 etcdv2 中通过 watch 来同步数据不是那么可靠, 断开连接一段时间后就会导致有可能中间的键的改动获取不到了。在 etcdv3 中支持 get 和 watch 键的任意的历史版本记录。
另外, v2 中的 watch 本质上还是建立很多 HTTP 连接, 每一个 watch 建立一个 tcp 套接字连接, 当 watch 的客户端过多的时候会大大消耗服务器的资源, 如果有数千个客户端 watch 数千个 key, 那么 etcd v2 的服务端的 socket 和内存资源会很快被耗尽。v3 版本中的 watch 可以进行连接复用, 多个客户端可以共用相同的 TCP 连接, 大大减轻了服务器的压力。
总结一下, 其实这里主要进行 2 点优化:
etcd 是一个 key-value 数据库, ectd v2 只保存了 key 的最新的 value, 之前的 value 会被直接覆盖, 如果需要知道一个 key 的历史记录, 需要对该 key 维护一个历史变更的窗口, 默认保存最新的 1000 个变更, 但是当数据更新较快时, 这 1000 个变更其实"不够用", 因为数据会被快速覆盖, 之前的记录还是找不到。为了解决这个问题, etcd v3 摒弃了 v2 不稳定的"滑动窗口"式设计, 引入 MVCC 机制, 采用从历史记录为主索引的存储结构, 保存了 key 的所有历史记录变更, 并支持数据在无锁状态下的的快速查询。etcd 是一个 key-value 数据库, etcdv2 的 key 是一个递归的文件目录结构, 在 v3 版本中的键改成了扁平化的数据结构, 更加简洁, 并通过线段树的优化方式, 支持 key 的快速查询。
由于 etcd v3 实现了 MVCC, 保存了每个 key-value pair 的历史版本, 数据了大了很多, 不能将整个数据库都存放到内存中。因此 etcd v3 摒弃了内存数据库, 转为磁盘数据库, 即整个数据都存储在磁盘上, 底层的存储引擎使用的是 BoltDB。
总结一下, 其实这里主要进行 3 点优化:
etcd v3 的优化, 还包括迷你事务、快照机制等, 这里就不再阐述, 相关内容后面会进行讲解。
高并发情况下, 会存在大量读写操。对于 etcd v2, 它是一个纯内存的数据库, 整个数据库有一个 Stop-the-World 的大锁, 可以通过锁的机制来解决并发带来的数据竞争, 但是通过锁的方式存在一些确定, 具体如下:
锁的粒度不好控制, 每次操作 Stop-the-World 时都会锁住整个数据库。
读锁和写锁会相互阻塞。
如果使用基于锁的隔离机制, 并且有一段很长的读事务, 那么在这段时间内这个对象就会无法被改写, 后面的事务也会被阻塞, 直到这个事务完成为止, 这种机制对于并发性能来说影响很大。
MVCC 其实就是多版本并发控制, etcd 在 v3 才引入, 它可以很好的解决锁带来的问题, 每当需要更改或者删除某个数据对象时, DBMS 不会在原地删除或者修改这个已有的数据对象本身, 而是针对该数据对象创建一个新的版本, 这样一来, 并发的读取操作可以在无需加锁的情况下读取老版本的数据, 而写操作就可以同时进行, 这个模式的好处可以让读取操作不再阻塞。
总而言之, MVCC 能最大的实现高效的读写并发, 尤其是高效的读, 因此非常适合 etcd 这种"读多写少"的场景。
将讲解 MVCC 的实现原理前, 还需要了解 v2 和 v3 的数据存储模型。
对于 v2, 前面其实已经讲过, v2 是一个存内存的数据库, 数据会通过 WAL 日志和 Snapshot 来持久化数据, 具体持久化数据的方式, 后面会整体讲述。
对于 v3, 因为它支持历史版本数据的查询, 所以它是将数据存储在一个多版本的持久化 K-V 存储里面, 当持久化键值数据发生变化时, 会先保存之前的旧值, 所以数据修改后, key 先前版本的所有值仍然可以访问和 watch。由于 v3 需要保存数据的历史版本, 这就极大地增加了存储量, 内存存储不了那么多的数据, 所以 v3 的数据需要持久化到磁盘中, 存储数据为 BoltDB。
那什么是 BoltDB 呢? BoltDB 是一个纯粹的 Go 语言版的 K-V 存储, 它的目标是为项目提供一个简单、高效、可靠的嵌入式的、可序列化的键值数据库, 而不要像 MySQL 那样完整的数据库服务器。BoltDB 还是一个支持事务的键值存储, etcd 事务就是基于 BoltDB 的事务实现的。为了大家能充分理解, 我再扩展 2 个问题:
v2 会定时快照, v3 需要进行快照么?
答案是不会。v3 实现 MVCC 之后, 数据是实时写入 BoltDB 数据库中, 也就是数据的持久化已经"摊销"到了每次对 key 的写请求上, 因此 v3 就不需要再做快照了。
v3 中所有的历史数据都会保存下来么?
答案是不会。虽然 v3 没有快照, 数据全部落在 BoltDB, 但是为了防止数据存储随着时间推移而无限增长, etcd 可能会压缩(其实就是删除)key 的旧版本数据, 说的通俗一点, 就是删除 BoltDB 中旧版本的数据。
那么 v3 是怎么实现 MVCC 的呢? 我们可以先看如下操作:
etcdctl txn <<< 'put key1 "v1" put key2 "v2"'
etcdctl txn <<< 'put key1 "v12" put key2 "v22"'
BoltDB 中会存入 4 条数据, 具体代码如下所示:
rev={3 0}, key=key1, value="v1"
rev={3 1}, key=key2, value="v2"
rev={4 0}, key=key1, value="v12"
rev={4 0}, key=key2, value="v22"
reversion 主要由 2 部分组成, 第一部分是 main rev, 每操作一次事务就加 1, 第二部分是 sub rev, 同 一个事务中每进行一次操作就加 1。如上示例所示, 第一次操作的 main rev 是 3, 第二次是 4 。
了解 v3 的磁盘存储之后, 可以看到要想从 BoltDB 中查询数据, 必须通过 reversion, 但是客户端都是通过 key 来查询 value, 所以 etcd 在内存中还维护了一个 kvindex , 保存的就是 key reversion 之前的映射关系, 用来加速查询。kvindex 是基于 Google 开源的 GoLang 的 B 树实现, 也就 v3 在内存中维护的二级索引, 这样当客户端通 key 查询 value 的时候, 会先在 kvindex 中查询这个 key 的所有 revision , 然后再通过 revision 从 BoltDB 中查询数据。
etcd 对数据的持久化, 采用的是 WAL 日志加 Snapshot 快照的方式。
其实在"3.3.1 复制状态机"就已经讲述了 WAL 日志的作用, 这里再简单重温一下。etcd 对数据的更新都是先写到 WAL 中, 当通过 Raft 将 WAL 同步到所有分布式节点之后, 再将 WAL 中的数据写到内存。对于 WAL 日志, 其实还有个作用, 就是实现 redo 和 undo 功能, 也就是当数据出现问题时, 以为 WAL 日志记录了对数据的所有操作, 所以可以通过 WAL 对数据库进行恢复和回滚。
既然有了 WAL 日志, 那为什么还需要定期做快照呢? 这里其实和 Redis 中的 RDB 和 AOF 日志很像, 我们可以把 WAL 日志对标为 Redis 的 AOF 日志, Snapshot 快照对标为 Redis 的 RDB 日志。在 Redis 中进行节点间数据同步时, 我们是先全量同步 RDB 日志(快照文件), 然后再增量同步 AOF 日志(数据增量文件)。etcd 也不一样, 因为 WAL 日志太琐碎了, 如果需要通过 WAL 日志去同步数据, 太慢了, 我们先把之前所有的数据同步过去(Snapshot 快照文件), 然后再同步后续的增量数据(WAL 日志)。当对 WAL 数据昨晚快照后, 就可以将旧的 WAL 数据删除。
至于快照文件是怎么生成的, 如果了解 Redis 中的 RDB 文件的生成原理, 这个就不难理解了, 为了偷个懒, 我直接把之前 Redis 的快照原理图贴过来:
其实就是通过写时复制技术 Copy-On-Write 完成的, 当需要进行快照时, 如果数据有更新, 会生成一个数据副本, 如图中的"键值对 C", 当进行快照时, 数据如果未更新, 直接落盘, 数据如果有更新, 同步副本数据即可。