[学习笔记] - ETCD

etcd是一个分布式键值对存储,设计用来可靠而快速的保存关键数据并提供访问。通过分布式锁,Leader选举和写屏障(write barriers)来实现可靠的分布式协作。etcd集群是为高可用,持久性数据存储和检索而准备。

概览

etcd设计用于可靠存储不频繁更新的数据,并提供可靠的观察查询。

ectd使用多版本持久化键值存储来存储数据。当键值对的值被新的数据替代时,持久化键值存储保存先前版本的键值对。键值存储事实上是不可变的,它的操作不会就地更新结构,替代的是总是生成一个新的更新后的结构。在修改之后,key的所有先前版本还是可以访问和观察的。为了防止随着时间的过去为了维护老版本导致数据存储无限增长,存储应该压缩来脱离被替代的数据的最旧的版本。

  • Store:为用户提供五花八门的API支持,处理用户的各项请求
  • HTTP Server:用于处理用户发送的API请求以及其它etcd节点的同步与心跳信息请求
  • Raft:Raft状态机,Raft强一致性算法的具体实现,是etcd的核心
  • WAL:Write Ahead Log(预写式日志),是etcd的数据存储方式
    • 除了在内存中存有所有数据的状态以及节点的索引以外,etcd就通过WAL进行持久化存储
    • WAL中,所有的数据提交前都会事先记录日志。Entry表示存储的具体日志内容

随着使用量的增加,当WAL文件中数据项内容过大达到设定值(默认为10000)时,会进行WAL的切分,同时进行snapshot操作,经过snapshot以后的WAL文件就可以删除。而通过API可以查询的历史etcd操作默认为1000条。实际上数据目录中有用的snapshot和WAL文件各只有一个,默认情况下etcd会各保留5个历史文件。

故障快速恢复/回滚(undo)/重做(redo) :所有的修改操作都被记录在WAL,可以通过执行所有WAL中记录的修改操作,快速从最原始的数据恢复到之前的状态。

通常,一个用户的请求发送过来,会经由HTTP Server转发给Store进行具体的事务处理,如果涉及到节点的修改,则交给Raft模块进行状态的变更、日志的记录,然后再同步给别的etcd节点以确认数据提交,最后进行数据的提交,再次同步。

snapshot:etcd防止WAL文件过多而设置的快照,存储etcd数据状态。

etcd概念词汇表

  • Raft:etcd所采用的保证分布式系统强一致性的算法。
  • Node:一个Raft状态机实例。
  • Member: 一个etcd实例。它管理着一个Node,并且可以为客户端请求提供服务。
  • Cluster:由多个Member构成可以协同工作的etcd集群。
  • Peer:对同一个etcd集群中另外一个Member的称呼。
  • Client: 向etcd集群发送HTTP请求的客户端。
  • WAL:预写式日志,etcd用于持久化存储的日志格式。
  • snapshot:etcd防止WAL文件过多而设置的快照,存储etcd数据状态。
  • Proxy:etcd的一种模式,为etcd集群提供反向代理服务。
  • Leader:Raft算法中通过竞选而产生的处理所有数据提交的节点。
  • Follower:竞选失败的节点作为Raft中的从属节点,为算法提供强一致性保证。
  • Candidate:当Follower超过一定时间接收不到Leader的心跳时转变为Candidate开始竞选。
  • Term:某个节点成为Leader到下一次竞选时间,称为一个Term。
  • Index:数据项编号。Raft中通过Term和Index来定位数据。

Raft算法

  • Raft是工程上使用较为广泛的强一致性、去中心化、高可用的分布式协议。
  • Raft 是一种用来管理日志复制(Log Replication)的一致性算法
  • Raft是一个共识算法(consensus algorithm),所谓共识,就是多个节点对某个事情达成一致的看法,即使是在部分节点故障、网络延时、网络分割的情况下。

一致性算法允许一组服务器像一个整体一样工作,该整体能够使它的成员从失败中恢复正常。

高度的概括:Raft会先选举出Leader,Leader完全负责日志复制的管理。Leader负责接受所有客户端更新请求,然后复制到Follower节点,并在安全的时候执行这些请求。如果Leader故障,Follower会重新选举出新的Leader。

复制状态机

一致性算法是在复制状态机(Replicated State Machine)的背景下提出来的。一般使用一个单独的复制状态机来管理Leader选举和存储配置信息,必须能够使Leader从失败中恢复。

一般通过使用复制日志来实现复制状态机。每个Server存储着一份包含命令序列的日志文件,状态机会按顺序执行这些命令。因为每个日志包含相同的命令,并且顺序也相同,所以每个状态机处理相同的命令序列。由于状态机是确定性的,所以处理相同的状态,得到相同的输出

就像MongoDB复制集使用oplog同步数据一样

  • 一个Server上的一致性模块会接收来自Client的命令,并把命令添加到它的日志文件中
  • 它同其它Server上的一致性模块进行通信,确保每一个日志最终包含相同的请求且顺序也相同
    • 即使某些Server故障。一旦这些命令被正确复制
  • 每个Server的状态机都会按照日志中的顺序去处理,将输出结果返回给客户端
  • 最终,这些Server看起来就像一个单独的、高可靠的状态机。

节点状态

Raft协议中,一个节点任一时刻处于以下三个状态之一

  • Leader
  • Follower
  • Candidate

所有节点启动时都是Follower状态,在一段时间内如果没有收到来自Leader的心跳,从Follower切换到Candidate,发起选举。如果收到大多数的投票(含自己的一票)则切换到Leader状态。果发现其他节点比自己更新,则主动切换到Follower。

系统中最多只有一个Leader,剩下都是Follower

如果在一段时间里发现没有Leader,则大家通过选举-投票选出Leader。Leader会不停的给follower发心跳消息,表明自己的存活状态。如果Leader故障,那么follower会转换成candidate,重新选出Leader。

任期

哪个节点做Leader是大家投票选举出来的,每个Leader都只工作一段时间,然后选出新的Leader继续负责。 Term(任期)以选举(Election)开始,然后就是一段或长或短的稳定工作期(normal operation)

  • 任期用连续的数字进行表示。每一个任期的开始都是一次选举
  • 一个或多个Candidate会试图成为Leader。
    • 如果一个Candidate赢得了选举,它就会在该任期的剩余时间担任Leader
  • 在某些情况下,可能票数相等,那么,将会开始另一个任期,并且立刻开始下一次选举。

在某些情况下,一台服务器可能看不到一次选举或者一个完整的任期。任期在 Raft 中充当逻辑时钟的角色

  • 每一台服务器都存储着一个当前任期的数字,这个数字会单调的增加。当服务器之间进行通信时,会互相交换当前任期号
    • 如果一台服务器的当前任期号比其它服务器的小,则更新为较大的任期号
    • 如果一个Candidate或者Leader意识到它的任期号过时了,它会立刻转换为Follower状态
    • 如果一台服务器收到的请求的任期号是过时的,那么它会拒绝此次请求

互相通信

Raft中的服务器通过远程过程调用(RPC)来通信

  • RequestVote RPC:Candidate在选举过程中触发的
  • AppendEntries RPC:Leader触发的
    • 复制日志条目和提供一种心跳(Heartbeat)机制

心跳(不带有任何日志条目的 AppendEntries RPC)

Leader选举

Follower在一个周期内没有收到来自Leader的心跳,则会主动发起选举

  • Follower会自增它的当前任期并且转换状态为Candidate
  • 投自己一票并行给其他节点发送RequestVote RPC
  • 等待其他节点的回复
    • 收到大部分的投票,则赢得选举,成为Leader
    • 被告知别人已当选,那么自行切换到Follower
      • 任期需要比当前任期大,如果小,则拒绝承认
    • 一段时间内没有收到投票,则保持Candidate状态,重新发出选举

赢得了选举之后,新的Leader会立刻给所有节点发消息,广而告之,避免其余节点触发新的选举。

  • 在一个任期内,单个节点最多只能投一票
  • 候选人知道的信息不能比自己的少
  • 投票按照先来先得

Raft引入了randomized election timeouts来尽量避免平票情况。Leader-based共识算法中,节点的数目都是奇数个,尽量保证Leader的出现。

如果一个Follower在一个周期内没有收到心跳信息,就叫做选举超时,然后它就会认为没有可用的Leader,并且开始一次选举以选出一个新的Leader。

选举限制

Raft使用投票的方式来处理,避免一个没有包含全部日志条目的Candidate赢得选举,除非该Candidate的日志包含了所有已提交的条目。

  • 每一个已提交的日志条目至少在其中一个Server上出现
  • 如果Candidate的日志至少和大多数Server上的日志一样新,那么它将包含有所有已经提交的日志条目
  • RequestVote RPC实现了这个限制:这个RPC中包含Candidate的日志信息,如果它自己的日志比其它Candidate的日志要新,那么它会拒绝其它Candidate的投票请求

Raft通过比较日志中最后一个条目的索引和任期号,来决定两个日志哪一个更新。

日志复制

一旦选出了 Leader,它就开始接收客户端的请求。每一个客户端请求都包含一条需要被复制状态机(Replicated State Machine)执行的命令。

  • Leader把这条命令作为新的日志条目加入到它的日志中去
  • 然后并行的向其它服务器发起AppendEntries RPC,要求其它服务器复制这个条目
  • Leader会将这个条目应用到它的状态机中并且会向客户端返回执行结果
  • 如果Follower崩溃了或者运行缓慢或者是网络丢包了,Leader会无限的重试 AppendEntries RPC
  • 直到大部分Follower最终存储了所有的日志条目,Leader则把这个条目设置为已提交的,并回复Client
  • 然后Leader会通知Follower这个条目已经提交,然后Follower才会使用这个条目

Leader 决定什么时候将日志条目应用到状态机是安全的;这种条目被称为是已提交的(Committed)。一旦被 Leader 创建的条目已经复制到了大多数的服务器上,这个条目就称为已提交的。Raft保证可已提交的日志条目是持久化的,并且最终会被所有可用的状态机执行

Leader跟踪记录它所知道的已提交的条目的最大索引值,并且这个索引值会包含在之后的AppendEntries RPC中(包括心跳中),为的是让其他服务器都知道这个条目已经提交。一旦一个Follower知道了一个日志条目已经是已提交的,它会将该条目应用至本地的状态机(按照日志顺序)。

  • 如果在不同日志中的两个条目有着相同的索引和任期号,则它们所存储的命令是相同的。
  • 如果在不同日志中的两个条目有着相同的索引和任期号,则它们之间的所有条目都是完全一样的。

Leader 的崩溃会导致日志不一致(旧的 Leader 可能没有完全复制完日志中的所有条目)。这些不一致会导致一系列 Leader 和 Follower 崩溃。

一个Follower可能会丢失掉Leader上的一些条目,也有可能包含Leader没有的一些条目,也有可能两者都会发生,丢失的或者多出来的条目可能会持续多个任期。

Leader通过强制Follower复制它的日志来处理日志的不一致。这就意味着,在 Follower上的冲突日志会被Leader的日志覆盖

Leader给每一个Follower维护了一个nextIndex:它表示Leader将要发送给该追随者的下一条日志条目的索引

  • 当一个Leader开始掌权时,它会将nextIndex初始化为它的最新的日志条目索引数+1
    • 如果一个Follower的日志和Leader的不一致,AppendEntries一致性检查会在下一次AppendEntries RPC时返回失败。在失败之后,Leader会将nextIndex递减然后重试AppendEntries RPC
    • 最终nextIndex会达到一个Leader和Follower日志一致的地方。这时,AppendEntries RPC会返回成功,Follower 中冲突的日志条目都被移除了,并且添加所缺少的上了 Leader 的日志条目。

一旦AppendEntries RPC返回成功,Follower和Leader的日志就一致了,这样的状态会保持到该任期结束。

时序与可用性

broadcastTime << electionTimeout << MTBF

  • broadcastTime:一个Server并行的向集群中的其它Server发送RPC,并收到这些Server的响应的平均时间
  • electionTimeout:选举超时时间
  • MTBF:单个Server发生故障的间隔时间的平均数

broadcastTime应该比electionTimeout小一个数量级,为的是使Leader能够持续发送心跳信息(heartbeat)来阻止Follower开始选举

electionTimeout也要比MTBF小几个数量级,为的是使得系统稳定运行。当Leader崩溃时,大约会在整个electionTimeout的时间内不可用;我们希望这种情况仅占全部时间的很小一部分。

脑裂问题

当Raft在集群中遇见网络分区的时候,集群就会因此而相隔开,在不同的网络分区里会因为无法接收到原来的Leader发出的心跳而超时选主,这样就会造成多Leader现象。当网络恢复的时候,集群不再是双分区,Raft会有如下操作:

  • leader A发现自己的Term小于Leader B,会自动成为Follower,Leader B保持不变
  • 分区中的所有节点会回滚自己的数据日志,并匹配新Leader的日志,然后实现同步提交更新自身的值
  • 最终集群达到整体一致,集群存在唯一Leader

etcd版本

etcd从v3版本开始与之前版本不兼容,需要设置

1
export ETCDCTL_API=3
  • 获得了IANA认证的端口,2379用于客户端通信,2380用于节点通信,与原先的(4001 peers / 7001 clients)共用。
  • 每个节点可监听多个广播地址。监听的地址由原来的一个扩展到多个,用户可以根据需求实现更加复杂的集群环境,如一个是公网IP,一个是虚拟机(容器)之类的私有IP。
  • etcd可以代理访问leader节点的请求,所以如果你可以访问任何一个etcd节点,那么你就可以无视网络的拓扑结构对整个集群进行读写操作。
  • etcd集群和集群中的节点都有了自己独特的ID。这样就防止出现配置混淆,不是本集群的其他etcd节点发来的请求将被屏蔽。
  • etcd集群启动时的配置信息目前变为完全固定,这样有助于用户正确配置和启动。
  • 运行时节点变化(Runtime Reconfiguration)。用户不需要重启 etcd 服务即可实现对 etcd 集群结构进行变更。启动后可以动态变更集群配置。
  • 重新设计和实现了Raft算法,使得运行速度更快,更容易理解,包含更多测试代码。
  • Raft日志现在是严格的只能向后追加、预写式日志系统,并且在每条记录中都加入了CRC校验码。
  • 启动时使用的_etcd/* 关键字不再暴露给用户
  • 废弃集群自动调整功能的standby模式,这个功能使得用户维护集群更困难。
  • 新增Proxy模式,不加入到etcd一致性集群中,纯粹进行代理转发。
  • ETCD_NAME(-name)参数目前是可选的,不再用于唯一标识一个节点。
  • 摒弃通过配置文件配置 etcd 属性的方式,你可以用环境变量的方式代替。
  • 通过自发现方式启动集群必须要提供集群大小,这样有助于用户确定集群实际启动的节点数量。

交互

1
etcdctl
  • put
  • delete
  • get
  • watch
  • transactions
  • leases
    • grant
    • revoke
    • keepalive

写入指令

1
2
3
4
5
# 设置键 foo 的值为 bar 的命令
etcdctl put foo bar
# foo1 的值设置为 bar1 并维持 10s 的命令
# 租约id 1234abcd 是创建租约时返回的. 这个id随即被附加到键
etcdctl put foo1 bar1 --lease=1234abcd

读取指令

1
2
3
4
5
6
7
8
9
10
11
12
# 读取键 foo 的值的命令
etcdctl get foo
# 以16进制格式读取键的值的命令
etcdctl get foo --hex
# 读取键 foo 的值的命令
tcdctl get foo --print-value-only
# 范围覆盖从 foo to foo3 的键的命令
etcdctl get foo foo3
# 范围覆盖以 foo 为前缀的所有键的命令
etcdctl get --prefix foo
# 范围覆盖以 foo 为前缀的所有键的命令,结果数量限制为2
etcdctl get --prefix --limit=2 foo

读取以前版本

1
2
3
4
5
6
7
8
9
10
# 访问键的最新版本
etcdctl get --prefix foo
# 访问修订版本为4时的键的版本
etcdctl get --prefix --rev=4 foo
# 访问修订版本为3时的键的版本
etcdctl get --prefix --rev=3 foo
# 访问修订版本为2时的键的版本
etcdctl get --prefix --rev=2 foo
# 访问修订版本为1时的键的版本
etcdctl get --prefix --rev=1 foo

读取大于等于指定键的值的键

1
2
3
4
5
6
a = 123
b = 456
z = 789

# 读取大于等于键b的值的键的命令
etcdctl get --from-key b

删除操作

1
2
3
4
5
6
7
8
9
10
# 删除键 foo 的命令
etcdctl del foo
# 删除从 foo 到 foo9 范围的键的命令
etcdctl del foo foo9
# 删除键 zoo 并返回被删除的键值对的命令
etcdctl del --prev-kv zoo
# 删除前缀为 zoo 的键的命令
etcdctl del --prefix zoo
# 删除大于等于键 b 的值的键的命令
etcdctl del --from-key b

监控操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 监控键 foo 的命令
etcdctl watch foo
# 以16进制监控键 foo 的命令
etcdctl watch foo --hex
# 监控从 foo to foo9 范围内键的命令
etcdctl watch foo foo9
# 监控前缀为 foo 的键的命令
etcdctl watch --prefix foo
# 从修订版本 2 开始观察键 foo 的改动
etcdctl watch --rev=2 foo

# 监控多个键的命令
etcdctl watch -i
watch foo
watch zoo

压缩修订版本

etcd 保存修订版本以便应用可以读取键的过往版本。但是,为了避免积累无限数量的历史数据,压缩过往的修订版本就变得很重要。压缩之后,etcd 删除历史修订版本,释放资源来提供未来使用。所有修订版本在压缩修订版本之前的被替代的数据将不可访问。

1
2
3
etcdctl compact 5
# 在压缩修订版本之前的任何修订版本都不可访问
etcdctl get --rev=4 foo

授予租约

应用可以为 etcd 集群里面的键授予租约。当键被附加到租约时,它的存活时间被绑定到租约的存活时间,而租约的存活时间相应的被 time-to-live (TTL)管理。在租约授予时每个租约的最小TTL值由应用指定。租约的实际 TTL 值是不低于最小 TTL,由 etcd 集群选择。一旦租约的 TTL 到期,租约就过期并且所有附带的键都将被删除。

1
2
3
4
5
6
# 授予租约,TTL为10秒
$ etcdctl lease grant 10
lease 32695410dcc0ca06 granted with TTL(10s)

# 附加键 foo 到租约32695410dcc0ca06
etcdctl put --lease=32695410dcc0ca06 foo bar

撤销租约

1
2
3
4
5
$ etcdctl lease revoke 32695410dcc0ca06
lease 32695410dcc0ca06 revoked

$ etcdctl get foo
# 空应答,因为租约撤销导致foo被删除

维持租约

1
2
3
4
$ etcdctl lease grant 10
lease 32695410dcc0ca06 granted with TTL(10s)

etcdctl lease keep-alive 32695410dcc0ca06

获取租约信息

应用程序可能想知道租约信息,以便可以更新或检查租约是否仍然存在或已过期。应用程序也可能想知道有那些键附加到了特定租约。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 授予租约,TTL为500秒
$ etcdctl lease grant 500
lease 694d5765fc71500b granted with TTL(500s)

# 将键 zoo1 附加到租约 694d5765fc71500b
$ etcdctl put zoo1 val1 --lease=694d5765fc71500b
OK

# 将键 zoo2 附加到租约 694d5765fc71500b
$ etcdctl put zoo2 val2 --lease=694d5765fc71500b
OK

# 获取租约信息的命令
$ etcdctl lease timetolive 694d5765fc71500b
lease 694d5765fc71500b granted with TTL(500s), remaining(258s)

# 获取租约信息和租约附带的键的命令
etcdctl lease timetolive --keys 694d5765fc71500b

服务发现协议

服务发现协议通过一个共享的发现URL帮助etcd新成员在启动阶段找到集群中所有成员。

服务发现协议只会用在集群启动阶段,不能用于允许中。

服务发现协议使用一个新的发现token来启动一个唯一的etcd集群。每一个token只能代表一个集群,这个token绝不能用于启动第二个集群

服务发现协议流程

  • 所有成员和发现服务交互,扩展成员列表
    • 之后新成员启动都使用这个列表

一般etcd服务发现协议使用_etcd/registry作为键前缀,如果http://example.com允许着一个用于服务发现的etcd集群,那么用于发现的完整的URL为http://example.com/v3/keys/_etcd/registry

生成发现token

生成一个发现token会创建一个新的集群。这会被用于发现键空间的唯一前缀,一个简单地方法是使用uuidgen

1
UUID=$(uuidgen)

指定集群期望大小

发现token必须指定一个集群期望大小,这个大小用于发现服务知道什么适合所有成员都初始化好了

一般来说,集群大小是3,5,7

启动etcd程序

-discovry指定发现URL,启动etcd,每个etcd程序都需要指定发现URL

注册自己

etcd首先会通过发现URL注册自己为成员,通过创建一个成员ID作为键完成

1
curl -X PUT http://example.com/v2/keys/_etcd/registry/${UUID}/${member_id}?prevExist=false -d value="${member_name}=${member_peer_url_1}&${member_name}=${member_peer_url_2}"

检测状态

通过URL检查集群期望大小,注册状态,然后决定下一步做什么

1
2
curl -X GET http://example.com/v2/keys/_etcd/registry/${UUID}/_config/size
curl -X GET http://example.com/v2/keys/_etcd/registry/${UUID}
  • 如果注册成员不够,则会等待。
  • 如果注册成员数量大于期望大小,则会把先注册的n个成员作为集群成员。
    • 如果自己在这个列表里面,则获取其他成员的列表
    • 如果自己不在这个列表里,则失败退出

奇数

etcd集群需要大部分节点(称为quorum)来决定更新集群的状态。对于有n个节点的集群,quorum(n/2)+1

  • 奇数个节点与和其配对的偶数个节点相比(比如 3节点和4节点对比),容错能力相同,却可以少一个节点。
  • 偶数个节点集群不可用风险更高,表现在选主过程中,有较大概率等额选票,从而出发下一轮选举。

Proxy模式

etcd作为一个反向代理把客户的请求转发给可用的etcd集群。这样,你就可以在每一台机器都部署一个Proxy模式的etcd作为本地服务,如果这些etcd Proxy都能正常运行,那么你的服务发现必然是稳定可靠的。所以Proxy并不是直接加入到符合强一致性的etcd集群中,也同样的,Proxy并没有增加集群的可靠性,当然也没有降低集群的写入性能。

Proxy取代Standby模式的原因

  • 实际上etcd每增加一个核心节点(peer),都会增加Leader节点一定程度的包括网络、CPU和磁盘的负担,因为每次信息的变化都需要进行同步备份。
  • 增加etcd的核心节点可以让整个集群具有更高的可靠性,但是当数量达到一定程度以后,增加可靠性带来的好处就变得不那么明显,反倒是降低了集群写入同步的性能。

新版etcd中,只会在最初启动etcd集群时,发现核心节点的数量已经满足要求时,自动启用Proxy模式,反之则并未实现。

  • etcd是用来保证高可用的组件,因此它所需要的系统资源(包括内存、硬盘和CPU等)都应该得到充分保障以保证高可用。任由集群的自动变换随意地改变核心节点,无法让机器保证性能。
  • 因为etcd集群是支持高可用的,部分机器故障并不会导致功能失效。所以机器发生故障时,管理员有充分的时间对机器进行检查和修复。
  • 自动转换使得etcd集群变得复杂,尤其是如今etcd支持多种网络环境的监听和交互。在不同网络间进行转换,更容易发生错误,导致集群不稳定。

Proxy模式的本质就是起一个HTTP代理服务器,把客户发到这个服务器的请求转发给别的etcd节点。

数据存储

etcd的存储分为内存存储和持久化(硬盘)存储两部分

  • 内存中的存储除了顺序化的记录下所有用户对节点数据变更的记录外,还会对用户数据进行索引、建堆等方便查询的操作
  • 持久化则使用预写式日志(WAL:Write Ahead Log)进行记录存储

在WAL的体系中,所有的数据在提交之前都会进行日志记录。在etcd的持久化存储目录中,有两个子目录。

  • 一个是WAL,存储着所有事务的变化记录
  • 另一个则是snapshot,用于存储某一个时刻etcd所有目录的数据。

通过WAL和snapshot相结合的方式,etcd可以有效的进行数据存储和节点故障恢复等操作。

随着使用量的增加,WAL存储的数据会暴增,为了防止磁盘很快就爆满,etcd默认每10000条记录做一次snapshot,经过snapshot以后的WAL文件就可以删除。

etcd中数据存储在b+tree结构里,毕竟有范围查询

预写式日志

WAL(Write Ahead Log)最大的作用是记录了整个数据变化的全部历程。

在etcd中,所有数据的修改在提交前,都要先写入到WAL中。使用WAL进行数据的存储使得etcd拥有两个重要功能。

  • 故障快速恢复: 当你的数据遭到破坏时,就可以通过执行所有WAL中记录的修改操作,快速从最原始的数据恢复到数据损坏前的状态。
  • 数据回滚(undo)/重做(redo):因为所有的修改操作都被记录在WAL中,需要回滚或重做,只需要方向或正向执行日志中的操作即可。

WAL两种模式

  • 读模式(read)
  • 数据添加模式(append)

一个新创建的WAL文件处于append模式,并且不会进入到read模式。
一个本来存在的WAL文件被打开的时候必然是read模式,并且只有在所有记录都被读完的时候,才能进入append模式,进入append模式后也不会再进入read模式。这样做有助于保证数据的完整与准确。


集群在进入到etcdserver/server.goNewServer函数准备启动一个etcd节点时,会检测是否存在以前的遗留WAL数据。

  • 检测的第一步是查看snapshot文件夹下是否有符合规范的文件,若检测到snapshot格式是v0.4的,则调用函数升级到v0.5。
  • 从snapshot中获得集群的配置信息,包括token、其他节点的信息等等
  • 然后载入WAL目录的内容,从小到大进行排序。根据snapshot中得到的term和index,找到WAL紧接着snapshot下一条的记录,然后向后更新,直到所有WAL包的entry都已经遍历完毕,Entry记录到ents变量中存储在内存里
  • 此时WAL就进入append模式,为数据项添加进行准备
  • 当WAL文件中数据项内容过大达到设定值(默认为10000)时,会进行WAL的切分,同时进行snapshot操作

拜占庭问题

拜占庭问题中提出,允许n个节点宕机还能提供正常服务的分布式架构,需要的总节点数量为3n+1,而Raft只需要2n+1就可以了

其主要原因在于,拜占庭将军问题中存在数据欺骗的现象,而etcd中假设所有的节点都是诚实的。

  • etcd在竞选前需要告诉别的节点自身的term编号以及前一轮term最终结束时的index值,这些数据都是准确的,其他节点可以根据这些值决定是否投票。
  • 另外,etcd严格限制Leader到Follower这样的数据流向保证数据一致不会出错。

用户从集群中哪个节点读写数据

Raft为了保证数据的强一致性,所有的数据流向都是一个方向,从Leader流向Follower,也就是所有Follower的数据必须与Leader保持一致,如果不一致会被覆盖。即所有用户更新数据的请求都最先由Leader获得,然后存下来通知其他节点也存下来,等到大多数节点反馈时再把数据提交。

经典应用场景

  • 服务发现
    • 一个强一致性、高可用的服务存储目录
    • 一种注册服务和监控服务健康状态的机制
    • 一种查找和连接服务的机制
  • 云平台多实例透明化
  • 微服务协同工作
  • 消息发布与订阅
  • 分布式日志收集系统
  • 系统中信息需要动态自动获取与人工干预修改信息请求内容的情况
  • 负载均衡
    • etcd 本身分布式架构存储的信息访问支持负载均衡
    • 利用 etcd 维护一个负载均衡节点表
  • 分布式通知与协调
    • 通过 etcd 进行低耦合的心跳检测
    • 通过 etcd 完成系统调度
    • 通过 etcd 完成工作汇报
  • 分布式锁
    • 保持独占即所有获取锁的用户最终只有一个可以得到
    • 控制时序,即所有想要获得锁的用户都会被安排执行
  • 分布式队列
  • 集群监控与Leader竞选

etcd分布式锁

  • Lease机制:即租约机制(TTL,Time To Live),Etcd 可以为存储的 KV 对设置租约,当租约到期,KV 将失效删除;同时也支持续约,即 KeepAlive。
  • Revision机制:每个 key 带有一个Revision属性值,etcd每进行一次事务对应的全局Revision值都会加一,因此每个key对应的Revision属性值都是全局唯一的。通过比较Revision的大小就可以知道进行写操作的顺序。
  • 在实现分布式锁时,多个程序同时抢锁,根据Revision值大小依次获得锁,可以避免羊群效应(也称惊群效应),实现公平锁。
  • Prefix机制:即前缀机制,也称目录机制。可以根据前缀(目录)获取该目录下所有的key及对应的属性(包括key, value以及revision等)。
  • Watch机制:即监听机制,Watch机制支持Watch某个固定的key,也支持Watch一个目录(前缀机制),当被Watch的key或目录发生变化,客户端将收到通知。

为什么不用redis:redis集群无法保证一致性问题,在master节点宕机的瞬间,master和slave节点之间的数据可能是不一致的。这将会导致服务a从master节点拿到了锁a,然后master节点宕机,在slave节点尚未完全同步完master的数据之前,服务b将从slave节点上成功拿到同样的锁a。

etcd引入了租约的概念,我们首先需要授予一个租约,然后同时设置租约的有效时间。租约的有效时间我们可以用来作为锁的有效时间。

然后我们可以直接调用etcd的lock功能,在指定的租约上对指定的lockName进行加锁操作。如果当前没有其他线程持有该锁,则该线程能直接持有锁。否则需要等待。

解锁的过程,我们放弃了etcd的unlock操作,而直接使用了etcd的revoke操作。之所以没采用unlock操作,一是因为unlock所需要的参数是上一步lock操作返回的lockKey,我们并不希望多维护一个字段,二是因为我们最终会执行revoke操作,而revoke操作会将该租约下的所有key都失效,因为我们目前目前设计的是一个租约对应一个锁,不存在会释放其它业务场景中的锁的情况

此外,为了保证线程在等待获取锁的过程中租约不会过期,所以我们得为这个线程设置一个守护线程,在该线程授予租约后就开启守护线程,定期去判断是否需要续期。

和redis分布式锁不一样的是,redis分布式锁的有效时间是缓存的有效时间,所以可以在获取锁成功后再开启用于续期的守护线程,而etcd分布式锁的有效时间是租约的有效时间,在等待获取锁的过程中可能租约会过期,所以得在获取租约后就得开启守护线程。这样就增加了很多的复杂度。

  • /lock/mylock为前缀创建全局唯一的key
  • 客户端分别为自己的key创建租约 - Lease
  • 当一个客户端持有锁期间,其它客户端只能等待
    • 为了避免等待期间租约失效,客户端需创建一个定时任务作为“心跳”进行续约
    • 如果持有锁期间客户端崩溃,心跳停止,key 将因租约到期而被删除,从而锁释放,避免死锁
  • 客户端将自己全局唯一的 key写入Etcd
    • 客户端需记录Revision用以接下来判断自己是否获得锁
  • 客户端判断是否获得锁
    • 客户端以前缀/lock/mylock读取keyValue列表
    • 判断自己 key 的 Revision 是否为当前列表中最小的
      • 如果是则认为获得锁
      • 否则监听列表中前一个Revision比自己小的key的删除事件
      • 一旦监听到删除事件或者因租约失效而删除的事件,则自己获得锁
  • 执行业务
  • 释放锁

对比ZooKeeper

  • ZooKeeper部署维护复杂
  • Java编写
  • 发展缓慢

参考