ETCD学习笔记

16 篇文章 1 订阅


官网首页:etcd is a strongly consistent, distributed key-value store that provides a reliable way to store data that needs to be accessed by a distributed system or cluster of machines. It gracefully handles leader elections during network partitions and can tolerate machine failure, even in the leader node.

etcd设计简单,官网和代码都非常易读,是学习go语言的首选开源项目。

相关链接:

etcd官网:https://etcd.io/

etcd代码:https://github.com/etcd-io/etcd

raft官网:https://raft.github.io/

raft动画演示:http://thesecretlivesofdata.com/raft/#election

raft论文导读:https://hardcore.feishu.cn/docs/doccnMRVFcMWn1zsEYBrbsDf8De

一、ETCD基本概念

1、一致性算法由来

单节点服务的时代已经过去,日益复杂的业务场景逼迫我们不得不使用大规模集群来提供服务。这就引入了三个不得不考虑的问题:

  • 1、如何多快好省的对大规模数据集进行存储和计算?

    a、更好的机器
    b、更多的机器【√】

  • 2、如何让跨网络的机器之间协调一致的工作?

    a、状态的立即一致
    b、状态的最终一致【√】

  • 3、如何应对网络的不可靠以及节点的失效?

    a、可读写【√】
    b、可读【√】
    c、不可用【√】

那么如何组织机器使其状态最终一致并允许局部失败的算法称之为一致性算法

Paxos算法由来已久,目前是功能和性能最完善的一致性算法,然而它难以理解与实现。

Raft简化了Paxos,它以易于理解为首要目标,并尽量提供与Paxos一样的功能与性能。

2、引入etcd

  • etcd概念

etcd是CoreOS基于Raft协议开发的分布式key-value存储,可用于服务发现、共享配置以及一致性保障(如数据库选主、分布式锁等)。

在分布式系统中,如何管理节点间的状态一直是一个难题,etcd像是专门为集群环境的服务发现和注册而涉及,它提供了数据TTL失效、数据改变监视、多值、目录监听、分布式锁原子操作等功能,可以方便的跟踪并管理集群节点的状态。

  • 特点

etcd 采用 Go 语言编写,它具有出色的跨平台支持,很小的二进制文件和强大的社区。 etcd 机器之间的通信通过 raft 算法处理。 除此之外,它还具有以下特点 :

简单: 安装配置简单;curl可访问的用户的API(HTTP + JSON)
键值对存储:将数据存储在分层组织的目录中,如同在标准文件系统中
监测变更:监测特定的键或目录以进行更改,并对值的更改做出反应
安全:可选的SSL客户端证书认证
快速: 单实例每秒16k QPS
可靠:使用Raft算法保证一致性

  • 主要功能

1、基本的key-value存储
2、监听机制
3、 key的过期及续约机制, 用于监控和服务发现
4、原子Compare And Swap和Compare And Delete, 用于分布式锁和leader选举

  • 应用场景

分布式系统中的数据分为控制数据和应用数据。
etcd的使用场景默认处理的数据都是控制数据,对于应用数据,只推荐数据量很小,但是更新访问频繁的情况。

应用场景有如下几类:

场景一:服务发现(Service Discovery)
场景二:消息发布与订阅
场景三:负载均衡
场景四:分布式通知与协调
场景五:分布式锁、分布式队列
场景六:Leader竞选与集群监控

举个最简单的例子,如果需要一个分布式存储仓库来存储配置信息,并且希望这个仓库读写速度快、支持高可用、部署简单、支持http接口,那么就可以使用etcd。目前,kubernetes用etcd来存储docker集群的配置信息等。

etcd 在微服务和 Kubernates 集群中不仅可以作为服务注册于发现,还可以作为 key-value 存储的中间件。

3、raft协议简介

ETCD工作原理核心部分在于Raft协议。

ETCD使用Raft协议来维护集群内各个节点状态的一致性。简单说,ETCD集群是一个分布式系统,由多个节点相互通信构成整体对外服务,每个节点都存储了完整的数据,并且通过Raft协议保证每个节点维护的数据是一致的。

每个ETCD节点都维护了一个状态机,并且,任意时刻至多存在一个有效的主节点。主节点处理所有来自客户端写操作,通过Raft协议保证写操作对状态机的改动会可靠的同步到其他节点。

Raft协议主要分为三个部分:选主,日志复制,安全性

  • 选主

    Raft协议是用于维护一组服务节点数据一致性的协议。这一组服务节点构成一个集群,并且有一个主节点来对外提供服务。当集群初始化,或者主节点挂掉后,面临一个选主问题。集群中每个节点,任意时刻必然处于Leader, Follower, Candidate这三个角色之一。选举特点如下:

    1、当集群初始化时候,每个节点都是Follower角色;
    2、集群中存在至多1个有效的主节点,通过心跳与其他节点同步数据;
    3、当Follower在一定时间内没有收到来自主节点的心跳,会将自己角色改变为Candidate,并发起一次选主投票;当收到包括自己在内超过半数节点赞成后,选举成功;当收到票数不足半数选举失败,或者选举超时。若本轮未选出主节点,将进行下一轮选举(出现这种情况,是由于多个节点同时选举,所有节点均为获得过半选票)。
    4、Candidate节点收到来自主节点的信息后,会立即终止选举过程,进入Follower角色。为了避免陷入选主失败循环,每个节点未收到心跳发起选举的时间是一定范围内的随机值,这样能够避免2个节点同时发起选主。

  • 日志复制

    所谓日志复制,是指主节点将每次操作形成日志条目,并持久化到本地磁盘,然后通过网络IO发送给其他节点。其他节点根据日志的逻辑时钟(TERM)和日志编号(INDEX)来判断是否将该日志记录持久化到本地。当主节点收到包括自己在内超过半数节点成功返回,那么认为该日志是可提交的(committed),并将日志输入到状态机,将结果返回给客户端。

  • 安全性

    选主以及日志复制并不能保证节点间数据一致。 比如leader挂了一段时间之后又当选为leader,那这个挂了的时间段的日志怎么处理?其他协议解决这个问题的办法是,新当选的主节点会询问其他节点,和自己数据对比,确定出集群已提交数据,然后将缺失的数据同步过来。这个方案有明显缺陷,增加了集群恢复服务的时间(集群在选举阶段不可服务),并且增加了协议的复杂度。

    Raft解决的办法是,在选主逻辑中,对能够成为主的节点加以限制,确保选出的节点已定包含了集群已经提交的所有日志。如果新选出的主节点已经包含了集群所有提交的日志,那就不需要从和其他节点比对数据了。简化了流程,缩短了集群恢复服务的时间。

    安全性证明:Raft协议下,只要仍然有超过半数节点存活,这样的主一定能够选出。因为已经提交的日志必然被集群中超过半数节点持久化,显然前一个主节点提交的最后一条日志也被集群中大部分节点持久化。当主节点挂掉后,集群中仍有大部分节点存活,那这存活的节点中一定存在一个节点包含了已经提交的日志。

二、安装部署

1、方式一:直接下载二进制文件

tar -xvf etcd-v3.5.0-linux-amd64.tar.gz
  • step3:将解压缩的文件夹中的二进制文件放到/usr/local/bin目录下
cd /etcd-v3.5.0-linux-amd64
  • step4:完成,验证一下
[root@k8s101 ~]# etcd --version
etcd Version: 3.5.0
Git SHA: 946a5a6
Go Version: go1.18.2
Go OS/Arch: linux/amd64

2、方式二:下载代码,自己编译出二进制文件

  • step1:clone想要的版本代码
git clone -b v3.5.0 https://github.com/etcd-io/etcd.git
  • step2:编译
cd etcd
./build.sh
  • step3:添加环境变量
export PATH="$PATH:`pwd`/bin"
  • step4:完成,验证一下
[root@k8s101 ~]# etcd --version
etcd Version: 3.5.0
Git SHA: 946a5a6
Go Version: go1.18.2
Go OS/Arch: linux/amd64

三、ETCD使用

1、快速入门

  • step1:开启etcd服务 [开启一个客户终端1 ]
[root@k8s101 ~]# etcd
{"level":"info","ts":"2022-05-17T20:53:20.021+0800","caller":"etcdmain/etcd.go:73","msg":"Running: ","args":["etcd"]}
{"level":"warn","ts":"2022-05-17T20:53:20.022+0800","caller":"etcdmain/etcd.go:105","msg":"'data-dir' was empty; using default","data-dir":"default.etcd"}
... ...
  • step2:使用etcd服务 [开启另一个客户终端2 ]
[root@k8s101 ~]# etcdctl put greeting "Hello, etcd"
OK
[root@k8s101 ~]# etcdctl get greeting
greeting
Hello, etcd

2、常用的CRUD

export ETCDCTL_API=3
ENDPOINTS=localhost:2379
# Create   增
etcdctl --endpoints=$ENDPOINTS put foo "Hello World1"

# Retrieve 查
etcdctl --endpoints=$ENDPOINTS get foo
etcdctl --endpoints=$ENDPOINTS --write-out="json" get foo
## 根据前缀查询
etcdctl --endpoints=$ENDPOINTS put web1 value1
etcdctl --endpoints=$ENDPOINTS put web2 value2
etcdctl --endpoints=$ENDPOINTS put web3 value3
etcdctl --endpoints=$ENDPOINTS get web --prefix

# Update   改
etcdctl --endpoints=$ENDPOINTS put foo "Hello World2"
etcdctl --endpoints=$ENDPOINTS --write-out="json" get foo

# Delete   删
[root@k8s101 ~]# etcdctl --endpoints=$ENDPOINTS del foo
1
[root@k8s101 ~]# etcdctl --endpoints=$ENDPOINTS del web --prefix
3

3、用户角色权限认证管理

用户【user】角色【role】权限【access】认证【auth】

一句话概括:权限绑定在角色上,将角色授予用户,用户就有了相应的权限。

export ETCDCTL_API=3
ENDPOINTS=localhost:2379

# 添加root角色
etcdctl --endpoints=${ENDPOINTS} role add root
etcdctl --endpoints=${ENDPOINTS} role get root

# 添加root用户
etcdctl --endpoints=${ENDPOINTS} user add root
etcdctl --endpoints=${ENDPOINTS} user grant-role root root
etcdctl --endpoints=${ENDPOINTS} user get root

# 添加role0角色,并授予读写foo的权限
etcdctl --endpoints=${ENDPOINTS} role add role0
etcdctl --endpoints=${ENDPOINTS} role grant-permission role0 readwrite foo
# 添加user0,并授予role0角色
etcdctl --endpoints=${ENDPOINTS} user add user0
etcdctl --endpoints=${ENDPOINTS} user grant-role user0 role0

# 打开权限认证【打开之后,所有的客户端请求都需要认证】
etcdctl --endpoints=${ENDPOINTS} auth enable

# 所有的操作都必须带上用户名和密码,否则就permission denied
etcdctl --endpoints=${ENDPOINTS} --user=user0:123456 put foo bar
etcdctl --endpoints=${ENDPOINTS} get foo
# permission denied, user name is empty because the request does not issue an authentication request
etcdctl --endpoints=${ENDPOINTS} --user=user0:123456 get foo

# 关闭权限认证【关闭之后,所有的客户端请求都不再需要认证】
etcdctl --endpoints=${ENDPOINTS} --user=root:123456 auth disable

4、事务txn

txn 方法可以在单个事务中处理多个请求 , 从而保证了业务执行的一致性。

txn 请求增加键值存储的修订版本并为每个完成的请求生成带有相同修订版本的事件。etcd 不容许在一个 txn 中多次修改同一个 key

[root@k8s101 ~]# etcdctl --endpoints=localhost:2379 put user1 bad
# 开启事务,进入交互模式
[root@k8s101 ~]# etcdctl --endpoints=localhost:2379 txn --interactive
# 先做比较:如果比较成功,那么成功请求将被按顺序处理,而应答将按顺序包含他们对应的应答;如果比较失败,那么失败请求将被按顺序处理,而应答将按顺序包含他们对应的应答。
compares:
value("user1") = "bad"       

success requests (get, put, del):
del user1

failure requests (get, put, del):
put user1 good

SUCCESS

1

5、监听watch

etcdctl --endpoints=localhost:2379 watch stock1
etcdctl --endpoints=localhost:2379 put stock1 1000

etcdctl --endpoints=localhost:2379 watch stock --prefix
etcdctl --endpoints=localhost:2379 put stock1 10
etcdctl --endpoints=localhost:2379 put stock2 20

6、租约lease

# 租约,会得到一个租约凭证
[root@k8s101 ~]# etcdctl --endpoints=localhost:2379 lease grant 300
lease 694d80d25fc6781a granted with TTL(300s)
# 拿着租约凭证进行操作
[root@k8s101 ~]# etcdctl --endpoints=localhost:2379 put sample value --lease=694d80d25fc6781a
OK
[root@k8s101 ~]# etcdctl --endpoints=localhost:2379 get sample
sample
value
# 查看租约
[root@k8s101 ~]# etcdctl --endpoints=localhost:2379 lease keep-alive 694d80d25fc6781a
lease 694d80d25fc6781a keepalived with TTL(300)
# 回收租约凭证
[root@k8s101 ~]# etcdctl --endpoints=localhost:2379 lease revoke 694d80d25fc6781a
lease 694d80d25fc6781a revoked
[root@k8s101 ~]# etcdctl --endpoints=localhost:2379 get sample

7、分布式锁lock

# 客户终端1
[root@k8s101 ~]# etcdctl --endpoints=localhost:2379 lock mutex1
mutex1/694d80d25fc67827

# 再开启另外一个 客户终端2 去拿同样的锁【此时会拿不到锁,直到客户终端1释放锁,客户终端2才能拿到锁】
[root@k8s101 ~]# etcdctl --endpoints=localhost:2379 lock mutex1

8、选举elect

# 客户终端1 直接选举成功,成为leader
etcdctl --endpoints=localhost:2379 elect one p1

# 再开启另外一个 客户终端2 去参与选举【此时选举不上,直到客户终端1放弃leader,客户终端2才能成为leader】
etcdctl --endpoints=localhost:2379 elect one p2

9、集群健康状态status

etcd可以通过检查集群的健康状态,来进行leader的选举工作。

#  单个节点健康检查
[root@k8s101 ~]# etcdctl --write-out=table --endpoints=localhost:2379 endpoint status
+----------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
|    ENDPOINT    |        ID        | VERSION | DB SIZE | IS LEADER | IS LEARNER | RAFT TERM | RAFT INDEX | RAFT APPLIED INDEX | ERRORS |
+----------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| localhost:2379 | 8e9e05c52164694d |   3.5.0 |   33 kB |      true |      false |         5 |        121 |                121 |        |
+----------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
[root@k8s101 ~]# etcdctl --endpoints=localhost:2379 endpoint health
localhost:2379 is healthy: successfully committed proposal: took = 1.681467ms

# 集群健康检查【以表格形式输出】
[root@k8s017 ~]# etcdctl --write-out=table --endpoints=localhost:2379 endpoint status
+------------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
|    ENDPOINT      |        ID        | VERSION | DB SIZE | IS LEADER | IS LEARNER | RAFT TERM | RAFT INDEX | RAFT APPLIED INDEX | ERRORS |
+------------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| 10.240.0.17:2379 | 4917a7ab173fabe7 |  3.5.0  |   45 kB |      true |      false |         4 |      16726 |              16726 |        |
| 10.240.0.18:2379 | 59796ba9cd1bcd72 |  3.5.0  |   45 kB |     false |      false |         4 |      16726 |              16726 |        |
| 10.240.0.19:2379 | 94df724b66343e6c |  3.5.0  |   45 kB |     false |      false |         4 |      16726 |              16726 |        |
+------------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------|

[root@k8s017 ~]# etcdctl --endpoints=localhost:2379 endpoint health
10.240.0.17:2379 is healthy: successfully committed proposal: took = 3.345431ms
10.240.0.19:2379 is healthy: successfully committed proposal: took = 3.767967ms
10.240.0.18:2379 is healthy: successfully committed proposal: took = 4.025451ms

10、快照snapshot

快照功能可以按时间点保存数据快照到etcd的数据库【持久化】。仅支持单个节点的快照保存。

## 保存快照
[root@k8s101 ~]# etcdctl --endpoints=localhost:2379 snapshot save my.db
{"level":"info","ts":1652800978.911471,"caller":"snapshot/v3_snapshot.go:68","msg":"created temporary db file","path":"my.db.part"}
{"level":"info","ts":1652800978.9126697,"logger":"client","caller":"v3/maintenance.go:211","msg":"opened snapshot stream; downloading"}
{"level":"info","ts":1652800978.9127922,"caller":"snapshot/v3_snapshot.go:76","msg":"fetching snapshot","endpoint":"localhost:2379"}
{"level":"info","ts":1652800978.9173183,"logger":"client","caller":"v3/maintenance.go:219","msg":"completed snapshot read; closing"}
{"level":"info","ts":1652800978.9183495,"caller":"snapshot/v3_snapshot.go:91","msg":"fetched snapshot","endpoint":"localhost:2379","size":"33 kB","took":"now"}
{"level":"info","ts":1652800978.918557,"caller":"snapshot/v3_snapshot.go:100","msg":"saved","path":"my.db"}
Snapshot saved at my.db

## 查看保存的快照状态
[root@k8s101 ~]# etcdctl --write-out=table --endpoints=localhost:2379 snapshot status my.db
Deprecated: Use `etcdutl snapshot status` instead.

+----------+----------+------------+------------+
|   HASH   | REVISION | TOTAL KEYS | TOTAL SIZE |
+----------+----------+------------+------------+
| ab1955c4 |       58 |         73 |      33 kB |
+----------+----------+------------+------------+

四、ETCD工作原理

相关链接:https://etcd.io/docs/v3.5/learning/

ETCD使用Raft协议来维护集群内各个节点状态的一致性。简单说,ETCD集群是一个分布式系统,由多个节点相互通信构成整体对外服务,每个节点都存储了完整的数据,并且通过Raft协议保证每个节点维护的数据是一致的

每个ETCD节点都维护了一个状态机,并且,任意时刻至多存在一个有效的主节点。主节点处理所有来自客户端写操作,通过Raft协议保证写操作对状态机的改动会可靠的同步到其他节点。

ETCD工作原理核心部分在于Raft协议和watch机制

1、etcd架构

在这里插入图片描述

1、http server:用于处理用户发送的API请求及其他etcd节点的同步与心跳信息请求。

2、store:用于处理etcd支持的各类功能的事务,包括:数据索引、节点状态变更、监控与反馈、事件处理与执行等等,是etcd对用户提供大多数API功能的具体实现。

3、raft:强一致性算法,是etcd的核心。

4、wal(write ahead log):预写式日志,是etcd的数据存储方式。除了在内存中存有所有数据的状态及节点的索引外,还通过wal进行持久化存储。【在wal中,所有的数据提交前都会事先记录日志;entry是存储的具体日志内容;snapshot是为了防止数据过多而进行的状态快照】

2、状态机

为了机器之间能协调一致的工作,raft协议规定,每个机器的状态【FollowerCandidateLeader】以及状态之间的转变如下:

在这里插入图片描述

raft动画演示:http://thesecretlivesofdata.com/raft/#election

  • 几个基本概念:

currentTerm:服务器已知最新的任期(在服务器首次启动的时候初始化为0,单调递增)
votedFor:当前任期内收到选票的候选者id,如果没有投给任何候选者则为空
log[]:日志条目;每个条目包含了用于状态机的命令,以及领导者接收到该条目时的任期(第一个索引为1)
commitIndex:已知已提交的最高的日志条目的索引(初始值为0,单调递增)
lastApplied:已经被应用到状态机的最高的日志条目的索引(初始值为0,单调递增)
nextlndex[]:对于每一台服务器,发送到该服务器的下一个日志条目的索引(初始值为领导者最后的日志条目的索引+1)
matchIndex[]:对于每一台服务器,已知的已经复制到该服务器的最高日志条目的索引(初始值为0,单调递增)

  • RPC

1、候选人发起选举投票RPC到跟随者或候选人

candidate 请求参数:term、candidateId、lastLogIndex、lastLogTerm
term:候选人的任期号;
candidateId:候选人的ld
lastLogIndex:候选人的最后日志条目的索引值
lastLogTerm:候选人最后日志条目的任期号

得到的返回值:term、voteGranted
term:当前任期号,以便于候选人去更新自己的任期号
voteGranted:候选人赢得了此张选票时为true

2、由领导者发起RPC到跟随者
a.日志追加
b.心跳通知

leader 请求参数:term、leaderId、prevLogIndex、prevLogTerm、entries[]、leaderCommit
term:当前领导者的任期
leaderId:领导者Id
prevLogIndex:紧邻新日志条目之前的那个日志条目的索引
prevLogTerm:紧邻新日志条目之前的那个日志条目的任期
entries[]:需要被保存的日志条目
leaderCommit:领导者的已知已提交的最高的日志条目的索引

得到的返回值:term、success
term:当前任期,对于领导者而言它会更新自己的任期
success:如果跟随者所含有的条目和prevLoglndex以及prevLog Term匹配上则结果为true。

以下图片来自:https://hardcore.feishu.cn/docs/doccnMRVFcMWn1zsEYBrbsDf8De

  • candidate

在这里插入图片描述

  • follower

在这里插入图片描述

  • leader

在这里插入图片描述

3、选举流程步骤

  • step1:由追随者follower或者候选人candidate在选举时间超时之后进行
  • step2:raft协议的具体实现的大部分逻辑是由Step函数驱动的。Step的主要作用是处理不同的消息。
    当收到MsgHup消息时,则进入竞选。
  • step3:再真正进入选举竞选前,会先做一些校验。
  • step4:进入campaign竞选,campaign则会调用becomeCandidate把自己切换到candidate模式,并递增Term值。然后再将自己的Term及日志信息发送给其他的节点,请求投票。
  • step5:其他节点在接受到投票请求后,会首先比较接收到的Tem是不是比自己的大,以及接受到的日志信息是不是比自己的要新,从而决定是否投票。
  • step6:最后当candidate节点收到投票回复后,就会计算收到的选票数目是否大于所有节点数的一半,如果大于则自己成为leader,并昭告天下,否则将自己置为follower。

4、etcd数据写入流程步骤

在这里插入图片描述

  • 1、当客户端对etcd发起请求的时候,如果etcd不是leader的状态而是follower,follower则会将请求转发leader; 如果是leader后, 会对其进行预检查,检查(配额、限速、鉴权【判断请求是否合法】、包大小【需要小于1.5M,过大则会拒绝】)。
  • 2、如果请求本身是合法的,会将请求转发给KVServer处理。
  • 3、KVserver一致性模块对数据进行处理,一致性模块是基于raft协议实现的,这时候的数据本身是处于unstable状态。
  • 4、当leader处理该数据unstable状态后,会通过rpc通知其他follower也来同步该数据,并且leader本身会在数据同步到日志模块【wal日志, wal日志通过fsync落盘到磁盘中】。而其他follow在同步该数据的时候,本身完成的是步骤3和数据同步到日志模块,follower一致性模块数据变成commited状态,当完成了这些后通过上次rpc返回响应体给leader。
  • 5、leader在收到了超过半数集群本身确认后,更新MatchIndex,一致性模块中数据本身由unstable变化成commited状态。这时候通过MVCC模块treeIndex和 BoltDB开源组件 组成】进行状态机的写入,将数据同步到 treeIndex 【会更新modified版本[当前版本号], generations信息[创建的版本,当前版本数,过往的所有版本号]】。再通过 BoltDB 落盘到磁盘中。这时候一致性模块数据由commited变化为applied状态。【在这里如果没有要求数据强一致性,弱一致性的话,那么数据在commited状态就认为数据已经同步完成】。
  • 6、再通过heatbeat将数据同步到follower中MVCC模块中。最终完成数据的一致性。【如果follower比leader落后好几个版本,leader会通过headbeat带到follower进行同步】。

5、性能

etcd或者说是raft协议在性能方面做了很多巧妙的设计。

  • 1、生成快照

    日志如果无限增长会将本地磁盘打满,这会造成可用性问题。
    etcd基于raft协议定时的将状态机中的状态生成快照,而将之前的日志全部删除,这是一种常见的压缩方式

    问题一:快照何时创建?过于频繁会浪费性能,过于低频日志占用磁盘的量更大,重建时间更长。
    限定日志文件大小到达某一个阈值后立刻生成快照。

    问题二:写入快照花费的时间昂贵如何处理?如何保证不影响节点的正常工作?
    使用写时复制技术,状态机的函数式顺序性天然支持。

在这里插入图片描述

  • 2、参数调节

    a、心跳的随机时间,过快会增加网络负载,过慢则会导致感知领导者崩溃的时间更长
    b、选举的随机时间,如果大部分跟随者同时变为候选人则会导致选票被瓜分

  • 3、流批结合

    可以做的就是batch,在很多情况下面,使用batch能明显提升性能,譬如对于RocksDB的写入来说,我们通常不会每次写入一个值,而是会用一个WriteBatch缓存一批修改,然后在整个写入。对于Raft来说,Leader可以一次收集多个requests,然后一批发送给Follower。当然,我们也需要有一个最大发送size来限制每次最多可以发送多少数据。

  • 4、并行追加

    对于一次request简易Raft流程来说,Leader可以先并行的将log发送给Followers,然后再将log append。在append log的时候会涉及到落盘,有开销,所以完全可以在Leader落盘的同时让Follower也尽快的收到log并append。

  • 5、异步应用

    当一个log被大部分节点append之后,我们就可以认为这个Iog被committed,被committed的log在什么时候被apply都不会再影响数据的一致性。所以当一个log被committed之后,我们可以用另一个线程去异步的apply这个log

    所以整个Raft流程就可以变成:
    a、Leader接受一个client发送的request.
    b、Leader将对应的log发送给其他follower并本地append.
    c、Leader继续接受其他client的requests,持续进行步骤2.
    d、Leader发现log已经被committed,在另一个线程apply.
    e、Leader异步apply log之后,返回结果给对应的client。

    使用asychronous apply的好处在于我们现在可以完全的并行处理append log和apply log,虽然对于一个client来说,它的一次request仍然要走完完整的Raft流程,但对于多个clients来说,整体的并发和吞吐量得到了提高。

五、源码

1、Entry

// 从整体上来说,一个集群中的每个节点都是一个状态机,而raft管理的就是对这个状态机进行更改的一些操作,
// 这些操作在代码中被封装为一个个Entry。
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"`
}

2、Message

// Raft集群中节点之间的通讯都是通过传递不同的Message来完成的,这个Message结构就是一个非
// 常general的大容器,它涵盖了各种消息所需的字段。
type Message struct {
	Type MessageType `protobuf:"varint,1,opt,name=type,enum=raftpb.MessageType" json:"type"` //心跳,追加日志,投票,上层应用消息等很多个
	To   uint64      `protobuf:"varint,2,opt,name=to" json:"to"` //接受者
	From uint64      `protobuf:"varint,3,opt,name=from" json:"from"`  //发送者
	Term uint64      `protobuf:"varint,4,opt,name=term" json:"term"` //任期逻辑时钟
	// logTerm is generally used for appending Raft logs to followers.
	LogTerm    uint64   `protobuf:"varint,5,opt,name=logTerm" json:"logTerm"` //发送者最后一条日志的任期号
	Index      uint64   `protobuf:"varint,6,opt,name=index" json:"index"`  //如果是投票请求时其表示发送者最后一条日志的索引号
	Entries    []Entry  `protobuf:"bytes,7,rep,name=entries" json:"entries"` //需要存储的日志
	Commit     uint64   `protobuf:"varint,8,opt,name=commit" json:"commit"` //已提交的日志
	Snapshot   Snapshot `protobuf:"bytes,9,opt,name=snapshot" json:"snapshot"` //存放快照
	Reject     bool     `protobuf:"varint,10,opt,name=reject" json:"reject"` //对方节点拒绝了当前的请求
	RejectHint uint64   `protobuf:"varint,11,opt,name=rejectHint" json:"rejectHint"` //对方节点拒绝了当前的请求
	Context    []byte   `protobuf:"bytes,12,opt,name=context" json:"context,omitempty"` //上下文信息 用于跟踪
}

3、log_unstable.go

// unstable.entries[i] has raft log position i+unstable.offset.
// Note that unstable.offset may be less than the highest log
// position in storage; this means that the next write to storage
// might need to truncate the log before persisting unstable.entries.
// 注:unstable数据结构用于还没有被用户层持久化的数据,它维护了两部分内容snapshot和entries
// 这里的前半部分是快照数据,而后半部分是日志条目组成的数组entries,另外unstable.offset成员保存的是
//entries数组中的第一条数据在raft日志中的索引,即第i条entries在raft日志中的索引为i+unstable.offset。
type unstable struct {
	// the incoming unstable snapshot, if any.
	snapshot *pb.Snapshot
	// all entries that have not yet been written to storage.
	entries []pb.Entry
	offset  uint64 // snapshot和entries的分界偏移
	logger Logger
}

4、storage.go

// Storage is an interface that may be implemented by the application
// to retrieve log entries from storage.
//
// If any Storage method returns an error, the raft instance will
// become inoperable and refuse to participate in elections; the
// application is responsible for cleanup and recovery in this case.
//这个文件定义了一个Storage接口,因为etcd中的raft实现并不负责数据的持久化,所以它希望上面的
//应用层能实现这个接口,以便提供给它查询log的能力。
type Storage interface {
	InitialState() (pb.HardState, pb.ConfState, error)
	Entries(lo, hi, maxSize uint64) ([]pb.Entry, error)
	Term(i uint64) (uint64, error)
	LastIndex() (uint64, error)
	FirstIndex() (uint64, error)
	Snapshot() (pb.Snapshot, error)
}

// MemoryStorage implements the Storage interface backed by an
// in-memory array.
// 另外,这个文件也提供了Storage接口的一个内存版本的实现MemoryStorage,这个实现同样也维
// 护了snapshot和entries这两部分,他们的排列跟unstable中的类似,也是snapshot在前,entries在后。
// 从代码中看来etcdserver和raftexample都是直接用的这个实现来提供log的查询功能的。
type MemoryStorage struct {
	sync.Mutex
	hardState pb.HardState
	snapshot  pb.Snapshot
	ents []pb.Entry
}

5、log.go

// raftLog结构体承担了raft日志相关的操作。
type raftLog struct {
	// 前面提到的存放已经持久化数据的Storage接口。
	// storage contains all stable entries since the last snapshot.
	storage Storage
	// 前面分析过的unstable结构体,用于保存应用层还没有持久化的数据。
	unstable unstable
	// 保存当前提交的日志数据索引。
	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 uint64
}

// 从raftLog的初始化函数可以看出raftLog的布局
// raftLog分为两部分,持久化存储和非持久化存储,它们之间的分界线就是lastIndex,
// 在此之前都是Storage管理的已经持久化的数据,而在此之后都是unstable管理的还没有持久化的数据。
// newLogWithSize returns a log using the given storage and max
// message size.
func newLogWithSize(storage Storage, logger Logger, maxNextEntsSize uint64) *raftLog {
	if storage == nil {
		log.Panic("storage must not be nil")
	}
	log := &raftLog{
		storage:         storage,
		logger:          logger,
		maxNextEntsSize: maxNextEntsSize,
	}
	firstIndex, err := storage.FirstIndex()
	if err != nil {
		panic(err) // TODO(bdarnell)
	}
	lastIndex, err := storage.LastIndex()
	if err != nil {
		panic(err) // TODO(bdarnell)
	}
	log.unstable.offset = lastIndex + 1
	log.unstable.logger = logger
	// Initialize our committed and applied pointers to the time of the last compaction.
	log.committed = firstIndex - 1
	log.applied = firstIndex - 1

	return log
}


  • 图示raftlog日志结构布局

在这里插入图片描述

6、progress.go

// Progress represents a follower’s progress in the view of the leader. Leader
// maintains progresses of all followers, and sends entries to the follower
// based on its progress.
//
// Leader通过Progress这个数据结构来追踪一个follower的状态,并根据Progress里的信息来决定每次同步的日志项
type Progress struct {
	Match, Next uint64
	// State defines how the leader should interact with the follower.
	//
	// When in StateProbe, leader sends at most one replication message
	// per heartbeat interval. It also probes actual progress of the follower.
	//
	// When in StateReplicate, leader optimistically increases next
	// to the latest entry sent after sending replication message. This is
	// an optimized state for fast replicating log entries to the follower.
	//
	// When in StateSnapshot, leader should have sent out snapshot
	// before and stops sending any replication message.
	// 从这里可以看出,Progress也是一个状态机,可以据此画出它的状态转移图
	State StateType
	// PendingSnapshot is used in StateSnapshot.
	PendingSnapshot uint64
	RecentActive bool
	// ProbeSent is used while this follower is in StateProbe
	ProbeSent bool
	Inflights *Inflights
	IsLearner bool
}

  • Progress也是一个状态机StateProbeStateReplicateStateSnapshot
                            +--------------------------------------------------------+          
                            |                  send snapshot                         |          
                            |                                                        |          
                  +---------+----------+                                  +----------v---------+
              +--->       probe        |                                  |      snapshot      |
              |   |  max inflight = 1  <----------------------------------+  max inflight = 0  |
              |   +---------+----------+                                  +--------------------+
              |             |            1. snapshot success                                    
              |             |               (next=snapshot.index + 1)                           
              |             |            2. snapshot failure                                    
              |             |               (no change)                                         
              |             |            3. receives msgAppResp(rej=false&&index>lastsnap.index)
              |             |               (match=m.index,next=match+1)                        
receives msgAppResp(rej=true)                                                                   
(next=match+1)|             |                                                                   
              |             |                                                                   
              |             |                                                                   
              |             |   receives msgAppResp(rej=false&&index>match)                     
              |             |   (match=m.index,next=match+1)                                    
              |             |                                                                   
              |             |                                                                   
              |             |                                                                   
              |   +---------v----------+                                                        
              |   |     replicate      |                                                        
              +---+  max inflight = n  |                                                        
                  +--------------------+                                                 

7、raft.go

//  raft协议的具体实现的大部分逻辑是由Step函数驱动的。Step的主要作用是处理不同的消息。
//  step2:处理消息
func (r *raft) Step(m pb.Message) error {
	// Handle the message term, which may result in our stepping down to a follower.
	switch {
	case m.Term == 0:
		// local message
	case m.Term > r.Term:
        ...
	case m.Term < r.Term:
        ...
	}
	switch m.Type {
	case pb.MsgHup:
		if r.preVote {
			r.hup(campaignPreElection)
		} else {
			r.hup(campaignElection)
		}
		// step5:其它节点决定要不要投票
	case pb.MsgVote, pb.MsgPreVote:
        ...
	default:
		err := r.step(r, m)
		if err != nil {
			return err
		}
	}
	return nil
}

8、node.go

//node的主要作用是应用层(etcdserver)和共识模块(raft)的衔接。将应用层的消息传递给底层共
//识模块,并将底层共识模块共识后的结果反馈给应用层。所以它的初始化函数创建了很多用来通信的
//channel,然后就在另一个goroutine里面开始了事件循环,不停的在各种channel中倒腾数据
//(貌似这种由for-select-channel组成的事件循环在Go里面很受欢迎)。
// Node represents a node in a raft cluster.
type Node interface {
	Tick()
	Campaign(ctx context.Context) error
	Propose(ctx context.Context, data []byte) error
	ProposeConfChange(ctx context.Context, cc pb.ConfChangeI) error
	Step(ctx context.Context, msg pb.Message) error
	// Ready returns a channel that returns the current point-in-time state.
    // node使用ready这个channel对外通知是否有数据需要处理
	Ready() <-chan Ready
	Advance()
	ApplyConfChange(cc pb.ConfChangeI) *pb.ConfState
	TransferLeadership(ctx context.Context, lead, transferee uint64)
	ReadIndex(ctx context.Context, rctx []byte) error
	Status() Status
	ReportUnreachable(id uint64)
	ReportSnapshot(id uint64, status SnapshotStatus)
	Stop()
}

//下面来解释下ready的作用。在etcd的这个实现中,node并不负责数据的持久化、网络消息的通
//信、以及将已经提交的log应用到状态机中,所以node使用ready这个channel对外通知有数据要
//处理了,并将这些需要外部处理的数据打包到一个Ready结构体中:
// Ready encapsulates the entries and messages that are ready to read,
// be saved to stable storage, committed or sent to other peers.
// All fields in Ready are read-only.
type Ready struct {
	*SoftState
	pb.HardState
	ReadStates []ReadState
	Entries []pb.Entry
	Snapshot pb.Snapshot
	CommittedEntries []pb.Entry
	Messages []pb.Message
	MustSync bool
}

9、选举流程

  • step1:raft.tickElection()
    tickElection 由追随者follower或者候选人candidate在选举时间超时之后进行
// step1:选举入口
func (r *raft) tickElection() {
	r.electionElapsed++

	if r.promotable() && r.pastElectionTimeout() {
		r.electionElapsed = 0
        // step2:进入处理消息
		if err := r.Step(pb.Message{From: r.id, Type: pb.MsgHup}); err != nil {
			r.logger.Debugf("error occurred during election: %v", err)
		}
	}
}

  • step2:raft.Step()
    raft协议的具体实现的大部分逻辑是由Step函数驱动的。Step()的主要作用是处理不同的消息。
    当收到MsgHup消息,则进入竞选。
//  step2:处理消息
func (r *raft) Step(m pb.Message) error {
	// Handle the message term, which may result in our stepping down to a follower.
	switch {
	case m.Term == 0:
		// local message
	case m.Term > r.Term:
        ...
	case m.Term < r.Term:
        ...
	}
	switch m.Type {
    // step3:收到MsgHup消息,开始进入竞选
	case pb.MsgHup:
		if r.preVote {
			r.hup(campaignPreElection)
		} else {
			r.hup(campaignElection)
		}
		// step5:其它节点决定要不要投票
	case pb.MsgVote, pb.MsgPreVote:
        ...
	default:
		err := r.step(r, m)
		if err != nil {
			return err
		}
	}
	return nil
}

  • step3:raft.hup()
    选举竞选前的一些校验。
// step3:开始竞选
func (r *raft) hup(t CampaignType) {
    //... ... 一些校验
	r.campaign(t)
}

  • step4:raft.campaign()
    campaign则会调用becomeCandidate把自己切换到candidate模式,并递增Term值。然后再将自己的Term及日志信息发送给其他的节点,请求投票。
// campaign transitions the raft instance to candidate state. This must only be
// called after verifying that this is a legitimate transition.
// step4:进入竞选状态→becomePreCandidate
func (r *raft) campaign(t CampaignType) {
	... ...
	if t == campaignPreElection {
        //campaign则会调用becomeCandidate把自己切换到candidate模式,并递增Term值。然后再
		//将自己的Term及日志信息发送给其他的节点,请求投票。
		r.becomePreCandidate()
        // 进入step5
		voteMsg = pb.MsgPreVote
		// PreVote RPCs are sent for the next term before we've incremented r.Term.
		term = r.Term + 1
	} else {
		r.becomeCandidate()
		voteMsg = pb.MsgVote
		term = r.Term
	}
	if _, _, res := r.poll(r.id, voteRespMsgType(voteMsg), true); res == quorum.VoteWon {
		// We won the election after voting for ourselves (which must mean that
		// this is a single-node cluster). Advance to the next state.
		if t == campaignPreElection {
			r.campaign(campaignElection)
		} else {
			r.becomeLeader()
		}
		return
	}
	var ids []uint64
	{
        ... ...
		r.send(pb.Message{Term: term, To: id, Type: voteMsg, Index: r.raftLog.lastIndex(), LogTerm: r.raftLog.lastTerm(), Context: ctx})
	}
}

  • step5:MsgPreVote
    其他节点在接受到投票请求后,会首先比较接收到的Tem是不是比自己的大,以及接受到的日志信息是不是比自己的要新,从而决定是否投票。
//  step2:处理消息
func (r *raft) Step(m pb.Message) error {
		//   step5:其它节点决定要不要投票
		//其他节点在接受到这个请求后,会首先比较接收到的Tem是不是比自己的大,以及接
		//受到的日志信息是不是比自己的要新,从而决定是否投票。
	case pb.MsgVote, pb.MsgPreVote:
		canVote := r.Vote == m.From ||
			(r.Vote == None && r.lead == None) ||
			(m.Type == pb.MsgPreVote && m.Term > r.Term)
		if canVote && r.raftLog.isUpToDate(m.Index, m.LogTerm) {
			r.logger.Infof("%x [logterm: %d, index: %d, vote: %x] cast %s for %x [logterm: %d, index: %d] at term %d",
				r.id, r.raftLog.lastTerm(), r.raftLog.lastIndex(), r.Vote, m.Type, m.From, m.LogTerm, m.Index, r.Term)
			r.send(pb.Message{To: m.From, Term: m.Term, Type: voteRespMsgType(m.Type)})
			if m.Type == pb.MsgVote {
				// Only record real votes.
				r.electionElapsed = 0
				r.Vote = m.From
			}
     ... ...
}

  • step6:stepCandidate
    最后当candidate节点收到投票回复后,就会计算收到的选票数目是否大于所有节点数的一半,如果大于则自己成为leader,并昭告天下,否则将自己置为follower。
// stepCandidate is shared by StateCandidate and StatePreCandidate; the difference is
// whether they respond to MsgVoteResp or MsgPreVoteResp.
//最后当candidate节点收到投票回复后,就会计算收到的选票数目是否大于所有节点数的一半,如
//果大于则自己成为leader,并昭告天下,否则将自己置为follower:
func stepCandidate(r *raft, m pb.Message) error {
	var myVoteRespType pb.MessageType
	switch m.Type {
	case pb.MsgProp:
		r.logger.Infof("%x no leader at term %d; dropping proposal", r.id, r.Term)
		return ErrProposalDropped
	case pb.MsgApp:
		r.becomeFollower(m.Term, m.From) // always m.Term == r.Term
		r.handleAppendEntries(m)
	case pb.MsgHeartbeat:
		r.becomeFollower(m.Term, m.From) // always m.Term == r.Term
		r.handleHeartbeat(m)
	case pb.MsgSnap:
		r.becomeFollower(m.Term, m.From) // always m.Term == r.Term
		r.handleSnapshot(m)
	case myVoteRespType:
		gr, rj, res := r.poll(m.From, m.Type, !m.Reject)
		r.logger.Infof("%x has received %d %s votes and %d vote rejections", r.id, gr, m.Type, rj)
		switch res {
		case quorum.VoteWon:
			if r.state == StatePreCandidate {
				r.campaign(campaignElection)
			} else {
				r.becomeLeader()
				r.bcastAppend()
			}
		case quorum.VoteLost:
			// pb.MsgPreVoteResp contains future term of pre-candidate
			// m.Term > r.Term; reuse r.Term
			r.becomeFollower(r.Term, None)
		}
	case pb.MsgTimeoutNow:
		r.logger.Debugf("%x [term %d state %v] ignored MsgTimeoutNow from %x", r.id, r.Term, r.state, m.From)
	}
	return nil
}

10、写入流程

  • step1:node.Propose()
    一个写清求一般会通过调用node.Propose开始,Propose方法将这个写清求封装到一个 MsgProp消息里面,发送给自己处理。
//一个写清求一般会通过调用node.Propose开始,Propose方法将这个写清求封装到一个 MsgProp消息里面,发送给自己处理。
func (n *node) Propose(ctx context.Context, data []byte) error {
	return n.stepWait(ctx, pb.Message{Type: pb.MsgProp, Entries: []pb.Entry{{Data: data}}})
}

  • step2:raft.Step()
    Step()的主要作用是处理不同的消息。
//  step2:处理消息
func (r *raft) Step(m pb.Message) error {
	// Handle the message term, which may result in our stepping down to a follower.
	switch m.Type {
	case pb.MsgHup: ... ... 
	case pb.MsgVote, pb.MsgPreVote: ... ...
	default:
		err := r.step(r, m)
		if err != nil {
			return err
		}
	}
	return nil
}

  • step3:MsgProp
    如果当前是follower,那它会把这个消息转发给leader。
    Leader收到消息后(不管是follower转发过来的还是自己内部产生的)会有两步操作:
    a.将这个消息添加到自己的log里
    b.向其他follower广播这个消息
// 如果当前是follower,那它会把这个消息转发给leader。
func stepFollower(r *raft, m pb.Message) error {
	switch m.Type {
	case pb.MsgProp:
		if r.lead == None {
			r.logger.Infof("%x no leader at term %d; dropping proposal", r.id, r.Term)
			return ErrProposalDropped
		} else if r.disableProposalForwarding {
			r.logger.Infof("%x not forwarding to leader %x at term %d; dropping proposal", r.id, r.lead, r.Term)
			return ErrProposalDropped
		}
		m.To = r.lead
		r.send(m)
	return nil
}
    
//Leader收到消息后(不管是follower转发过来的还是自己内部产生的)会有两步操作:
//a.将这个消息添加到自己的log里
//b.向其他follower广播这个消息
func stepLeader(r *raft, m pb.Message) error {
	// These message types do not require any progress for m.From.
	switch m.Type {
        ... ...
	case pb.MsgProp:
		for i := range m.Entries {
			e := &m.Entries[i]
					m.Entries[i] = pb.Entry{Type: pb.EntryNormal}
				} else {
					r.pendingConfIndex = r.raftLog.lastIndex() + uint64(i) + 1
				}
			}
		}
		if !r.appendEntry(m.Entries...) {
			return ErrProposalDropped
		}
		r.bcastAppend()
		return nil
	}
    ... ...
	return nil
}

  • step4:MsgAppResp

在follower接收完这个Nog后,会返回一个MsgAppResp消息。
当leadert确认已经有足够多的followert接受了这个Nog后,它首先会commiti这个log,然后再广播一
次,告诉别人它的commit状态。这里的实现就有点像两阶段提交。

func stepLeader(r *raft, m pb.Message) error {
	switch m.Type {
	//在follower接收完这个Nog后,会返回一个MsgAppResp消息。
	case pb.MsgAppResp:
		pr.RecentActive = true
        if pr.MaybeUpdate(m.Index) {
				switch {    
// 当leader确认已经有足够多的follower接受了这个log后,它首先会commit这个log,然后再广播一
// 次,告诉别人它的commit状态。这里的实现就有点像两阶段提交了。
				if r.maybeCommit() {
					r.bcastAppend()
				}
			}
	return nil
}
        
// maybeCommit attempts to advance the commit index. Returns true if
// the commit index changed (in which case the caller should call
// r.bcastAppend).
func (r *raft) maybeCommit() bool {
	mci := r.prs.Committed()
	return r.raftLog.maybeCommit(mci, r.Term)
}

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

进击的程序猿~

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

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

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

打赏作者

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

抵扣说明:

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

余额充值