ETCD 入门

1. ETCD

1.1. 什么是 ETCD?

etcd 是一个 Go 言编写的分布式、高可用的一致性键值存储系统, 用于提供可靠的分布式键值存储、配置共享和服务发现等功能, 具有以下特点:

  • 简单:
  • 易使用: 基于 HTTP+JSON 的 API 让你用 curl 就可以轻松使用;
  • 易部署: 使用 Go 语言编写, 跨平台, 部署和维护简单。
  • 可靠:
  • 强一致: 使用 Raft 算法充分保证了分布式系统数据的强一致性;
  • 高可用: 具有容错能力, 假设集群有 n 个节点, 当有 (n-1)/2 节点发送故障, 依然能提供服务;
  • 持久化: 数据更新后, 会通过 WAL 格式数据持久化到磁盘, 支持 Snapshot 快照。
  • 快速: 每个实例每秒支持一千次写操作, 极限写性能可达 10K QPS。
  • 安全: 可选 SSL 客户认证机制。

1.2. 整体框架

从 etcd 的架构图中我们可以看到, etcd 主要分为四个部分:

  • HTTP Server: 用于处理用户发送的 API 请求以及其它 etcd 节点的同步与心跳信息请求。
  • Store: 用于处理 etcd 支持的各类功能的事务, 包括数据索引、节点状态变更、监控与反馈、事件处理与执行等等, 是 etcd 对用户提供的大多数 API 功能的具体实现。
  • Raft: Raft 强一致性算法的具体实现, 是 etcd 的核心。
  • WAL: Write Ahead Log(预写式日志), 是 etcd 的数据存储方式。除了在内存中存有所有数据的状态以及节点的索引以外, etcd 就通过 WAL 进行持久化存储。WAL 中, 所有的数据提交前都会事先记录日志。Snapshot 是为了防止数据过多而进行的状态快照; Entry 表示存储的具体日志内容。

1.3. Raft 协议

1.3.1. 基本概念

1.3.1.1. 名词解释

Raft 协议一共包含如下 3 类角色:

  • Leader(领袖): 领袖由群众投票选举得出, 每次选举, 只能选出一名领袖;
  • Candidate(候选人): 当没有领袖时, 某些群众可以成为候选人, 然后去竞争领袖的位置;
  • Follower(群众): 这个很好理解, 就不解释了。

然后在进行选举过程中, 还有几个重要的概念:

  • Leader Election(领导人选举): 简称选举, 就是从候选人中选出领袖;
  • Term(任期): 它其实是个单独递增的连续数字, 每一次任期就会重新发起一次领导人选举;
  • Election Timeout(选举超时): 就是一个超时时间, 当群众超时未收到领袖的心跳时, 会重新进行选举。
1.3.1.2. 角色转换

这幅图是领袖、候选人和群众的角色切换图, 我先简单总结一下:

  • 群众 -> 候选人: 当开始选举, 或者"选举超时"时
  • 候选人 -> 候选人: 当"选举超时", 或者开始新的"任期"
  • 候选人 -> 领袖: 获取大多数投票时
  • 候选人 -> 群众: 其它节点成为领袖, 或者开始新的"任期"
  • 领袖 -> 群众: 发现自己的任期 ID 比其它节点分任期 ID 小时, 会自动放弃领袖位置
  • 备注: 后面会针对每一种情况, 详细进行讲解。

1.3.2. 选举

1.3.2.1. 领导人选举

为了便于后续的讲解, 我画了一副简图, “选举定时器"其实就是每个节点的"超时时间”。
成为候选人: 每个节点都有自己的"超时时间", 因为是随机的, 区间值为 150~300ms, 所以出现相同随机时间的概率比较小, 因为节点 B 最先超时, 这时它就成为候选人。
选举领导人: 候选人 B 开始发起投票, 群众 A 和 C 返回投票, 当候选人 B 获取大部分选票后, 选举成功, 候选人 B 成为领袖。
心跳探测: 为了时刻宣誓自己的领导人地位, 领袖 B 需要时刻向群众发起心跳, 当群众 A 和 C 收到领袖 B 的心跳后, 群众 A 和 C 的"超时时间"会重置为 0, 然后重新计数, 依次反复。这里需要说明一下, 领袖广播心跳的周期必须要短于"选举定时器"的超时时间, 否则群众会频繁成为候选者, 也就会出现频繁发生选举, 切换 Leader 的情况。

1.3.2.2. 领袖挂掉情况

当领袖 B 挂掉, 群众 A 和 C 会的"选举定时器"会一直运行, 当群众 A 先超时时, 会成为候选人, 然后后续流程和"领导人选举"流程一样, 即通知投票 -> 接收投票 -> 成为领袖 -> 心跳探测。

1.3.2.3. 出现多个候选者情况

当出现多个候选者 A 和 D 时, 两个候选者会同时发起投票, 如果票数不同, 最先得到大部分投票的节点会成为领袖; 如果获取的票数相同, 会重新发起新一轮的投票。
当 C 成为新的候选者, 此时的任期 Term 为 5, 发起新一轮的投票, 其它节点发起投票后, 会更新自己的任期值, 最后选择新的领袖为 C 节点。

1.3.3. 日志复制

1.3.3.1. 复制状态机

复制状态机的基本思想是一个分布式的状态机, 系统由多个复制单元组成, 每个复制单元均是一个状态机, 它的状态保存在操作日志中。如下图所示, 服务器上的一致性模块负责接收外部命令, 然后追加到自己的操作日志中, 它与其他服务器上的一致性模块进行通信, 以保证每一个服务器上的操作日志最终都以相同的顺序包含相同的指令。一旦指令被正确复制, 那么每一个服务器的状态机都将按照操作日志的顺序来处理它们, 然后将输出结果返回给客户端。

1.3.3.2. 数据同步流程

数据同步流程, 借鉴了"复制状态机"的思想, 都是先"提交", 再"应用"。当 Client 发起数据更新请求, 请求会先到领袖节点 C, 节点 C 会更新日志数据, 然后通知群众节点也更新日志, 当群众节点更新日志成功后, 会返回成功通知给领袖 C, 至此完成了"提交"操作; 当领袖 C 收到通知后, 会更新本地数据, 并通知群众也更新本地数据, 同时会返回成功通知给 Client, 至此完成了"应用"操作, 如果后续 Client 又有新的数据更新操作, 会重复上述流程。

1.3.3.3. 日志原理

每一个日志条目一般包括三个属性: 整数索引 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 的日志全部追加过来。

1.3.4. 脑裂情况

当网络问题导致脑裂, 出现双 Leader 情况时, 每个网络可以理解为一个独立的网络, 因为原先的 Leader 独自在一个区, 所以向他提交的数据不可能被复制到大多数节点上, 所以数据永远都不会提交, 这个可以在第 4 幅图中提现出来 (SET 3 没有提交)。
当网络恢复之后, 旧的 Leader 发现集群中的新 Leader 的 Term 比自己大, 则自动降级为 Follower, 并从新 Leader 处同步数据达成集群数据一致, 同步数据的方式可以详见"3.3.3 日志原理"。
脑裂情况其实只是异常情况的一种, 当 Leader 通知 Follower 更新日志、Leader 提交更新时, 都存在各种异常情况导致的问题, 这个我就不再详述了, 具体可以参考《云原生分布式存储基石-etcd 深入解析》书中的"1.4.3 异常情况"这一章, 里面讲述的比较清楚。

1.4. ETCD 体验

1.4.1. ETCD 安装

1.4.1.1. 直接安装

以 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
1.4.1.2. 源码安装

我个人更推荐下面这种安装方式:

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

1.4.2. 集群部署

1.4.2.1. 部署流程

下面我们可以部署一个 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.4.2.2. 遇到问题

问题 1: 服务启动后, 不能使用 v2 接口, 比如执行 “curl http://127.0.0.1:2379/v2/members”, 提示"404 page not found"

  • 问题原因: 因为 V3.4 版本默认是 V3 接口, 不支持 V2
  • 解决方案: 需要在启动 etcd 时, 加上"–enable-v2=true", 强制使用 V2 接口

问题 2: 服务启动失败, 提示"conflicting environment variable “ETCD_ENABLE_V2” is shadowed by corresponding command-line flag (either unset environment variable or disable flag)"

  • 问题原因: 因为启动 etcd 时, 参数"–enable-v2=true"导致, 因为 V3.4 版本会读取该配置, 所以提示配置重复。
  • 解决方案: 不能删除该参数, 否则会引入其它问题, 我是关闭所有窗口, 然后重新启动 etcd 即可。

问题 3: 启动某个节点时, 提示 member 已经存在

  • 问题原因: 因为之前启动过该节点, 该 member 已经存在, 不能初始化, 只能加入已经存在的 member
  • 解决方案: 需要将启动脚本中的"CLUSTER_STATE=new"改为"CLUSTER_STATE=existing"

1.4.3. 常规操作

1.4.3.1. 集群管理

我们在部署集群时, 用到一些方法, 这里我简单汇总一下:

# 版本检查, 输出{"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
1.4.3.2. 键值操作

设置键的值:

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":

1.4.3.3. watch 通知

可以对 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 都会监听到:

1.4.3.4. 目录操作

这个可用于配置管理, 因为 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
    }
}
1.4.3.5. 自动创建有序 key

通过对一个目录发起 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
    }
}

1.5. 深入 ETCD

1.5.1. ETCD 3.0

1.5.1.1. 版本说明

目前 etcd 主要经历了 3 个大的版本, 分别为 etcd 0.4 版本、etcd 2.0 版本和 etcd 3.0 版本。对于 etcd 2.0 版本, 已经可以很好满足 etcd 的初步需求, 主要包括:

  • 专注于 key-value 存储, 而不是一个完整的数据库;
  • 通过 HTTP + JSON 的方式暴露给外部 API;
  • watch 机制提供持续监听某个 key 变化的功能, 以及基于 TTL 的 key 的自动过期机制。
    但是在实际过程中, 我们也发现了一些问题, 比如客户端需要频繁地与服务端进行通信, 集群在空间和时间上都需要承受较大的压力, 以及垃圾回收 key 的时间不稳定等, 同时"微服务"架构要求 etcd 能够单集群支撑更大规模的并发, 因此诞生了 etcd 3.0 版本, 主要对 HTTP + JSON 的通信方式、key 的自动过期机制、watch 机制、数据持久化等进行了优化, 下面我们看看 etcd 3.0 版本对每个模块都做了哪些优化。
1.5.1.2. 客户端通信方式

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 点优化:

  • 二进制取代字符串: 通过 gRPC 进行通信, 代替了 v2 版本的 HTTP+JSON 格式;
  • 减少 TCP 连接: 使用 HTTP/2 协议, 同一个连接可以同时处理多个请求, 摒弃多个请求需要建立多个连接的方式。
1.5.1.3. 键的过期机制

etcdv2 中的键的实效是使用 TTL 机制来实现的, 每个有存活时间的键, 客户端必须定期的进行刷新重新设置保证它不被自动删除, 每次刷新同时还会重新建立连接去更新键。也就是说, 及时整个集群都处于空闲状态, 也会有很多客户端与服务器进行定期通信, 以保证某个 key 不被自动删除。
etcdv3 版本中采用了租约机制进行实现, 每个租约会有一个 TTL, 然后将一些 key 附加到租约上, 当租约到期后, 附加到它上边的 key 都会被删除。利用键的过期机制可以实现服务注册功能, 我们可以将一个服务的域名、IP 等信息注册到 etcd 中, 并给相应的键设置租约, 并在 TTL 时间内定期维持一个心跳进行刷新。当服务故障后, 心跳消失从而相应的键就会自动删除, 从而实现了服务的注册功能和服务的健康检查功能。
总结一下, 就是 v2 版本比较傻瓜, 需要时刻维护每个 key 的通信, v3 就比较智能, 整个统一的过期 key 的代号, 我们把代号称之为"租约", 我们只需要维护这个代号即可, 避免客户端去维护所有的 key。

1.5.1.4. watch 机制

etcdv2 中的键被废除以后, 为了能够跟踪 key 的变化, 使用了事件机制进行跟踪, 维护键的状态, 来防止被删除掉的后键还能恢复和 watch 到, 但是有一个滑动窗口的大小限制, 那么如果要获取 1000 个时间之前的键就获取不到了。因此 etcdv2 中通过 watch 来同步数据不是那么可靠, 断开连接一段时间后就会导致有可能中间的键的改动获取不到了。在 etcdv3 中支持 get 和 watch 键的任意的历史版本记录。
另外, v2 中的 watch 本质上还是建立很多 HTTP 连接, 每一个 watch 建立一个 tcp 套接字连接, 当 watch 的客户端过多的时候会大大消耗服务器的资源, 如果有数千个客户端 watch 数千个 key, 那么 etcd v2 的服务端的 socket 和内存资源会很快被耗尽。v3 版本中的 watch 可以进行连接复用, 多个客户端可以共用相同的 TCP 连接, 大大减轻了服务器的压力。
总结一下, 其实这里主要进行 2 点优化:

  • 实时监听 key 的更新: 解决 v2 中途 key 的数据更新, 客服端不会感知的问题;
  • 多路复用: 这个可以想到 select 和 epool 模型, 就是一个客户之前需要建立多个 TCP 连接, 现在只需要建立一个即可。
1.5.1.5. 数据存储模型

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 点优化:

  • 保存历史数据: 摈弃 v2 的"滑动窗口"式设计, 通过 MVCC 机制, 保存了所有的历史数据;
  • 数据落磁盘: 因为要保存历史数据, 数据量态度, 不适合全内存存储, 使用 BoltDB 存储;
  • 查询优化: 摒弃 v2 的目录式层级化设计, 使用线段树优化查询。
1.5.1.6. 其它

etcd v3 的优化, 还包括迷你事务、快照机制等, 这里就不再阐述, 相关内容后面会进行讲解。

1.5.2. 多版本并发控制

1.5.2.1. 为什么选择 MVCC

高并发情况下, 会存在大量读写操。对于 etcd v2, 它是一个纯内存的数据库, 整个数据库有一个 Stop-the-World 的大锁, 可以通过锁的机制来解决并发带来的数据竞争, 但是通过锁的方式存在一些确定, 具体如下:

锁的粒度不好控制, 每次操作 Stop-the-World 时都会锁住整个数据库。

读锁和写锁会相互阻塞。

如果使用基于锁的隔离机制, 并且有一段很长的读事务, 那么在这段时间内这个对象就会无法被改写, 后面的事务也会被阻塞, 直到这个事务完成为止, 这种机制对于并发性能来说影响很大。

MVCC 其实就是多版本并发控制, etcd 在 v3 才引入, 它可以很好的解决锁带来的问题, 每当需要更改或者删除某个数据对象时, DBMS 不会在原地删除或者修改这个已有的数据对象本身, 而是针对该数据对象创建一个新的版本, 这样一来, 并发的读取操作可以在无需加锁的情况下读取老版本的数据, 而写操作就可以同时进行, 这个模式的好处可以让读取操作不再阻塞。

总而言之, MVCC 能最大的实现高效的读写并发, 尤其是高效的读, 因此非常适合 etcd 这种"读多写少"的场景。

1.5.2.2. 数据模型

将讲解 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 个问题:

  1. v2 会定时快照, v3 需要进行快照么?
    答案是不会。v3 实现 MVCC 之后, 数据是实时写入 BoltDB 数据库中, 也就是数据的持久化已经"摊销"到了每次对 key 的写请求上, 因此 v3 就不需要再做快照了。

  2. v3 中所有的历史数据都会保存下来么?
    答案是不会。虽然 v3 没有快照, 数据全部落在 BoltDB, 但是为了防止数据存储随着时间推移而无限增长, etcd 可能会压缩(其实就是删除)key 的旧版本数据, 说的通俗一点, 就是删除 BoltDB 中旧版本的数据。

1.5.2.3. 初探 MVCC 实现

那么 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 中查询数据。

1.5.3. 日志和快照管理

1.5.3.1. 数据持久化

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 数据删除。

1.5.3.2. 快照管理

至于快照文件是怎么生成的, 如果了解 Redis 中的 RDB 文件的生成原理, 这个就不难理解了, 为了偷个懒, 我直接把之前 Redis 的快照原理图贴过来:
其实就是通过写时复制技术 Copy-On-Write 完成的, 当需要进行快照时, 如果数据有更新, 会生成一个数据副本, 如图中的"键值对 C", 当进行快照时, 数据如果未更新, 直接落盘, 数据如果有更新, 同步副本数据即可。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

云满笔记

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值