1、简介
etcd 诞生于 CoreOS 公司,最初用于解决集群管理系统中 os 升级时的分布式并发控制、配置文件的存储与分发等问题。基于此,etcd 设计为提供高可用、强一致性的小型 kv 数据存储服务。项目当前隶属于 CNCF 基金会,被包括 AWS、Google、Microsoft、Alibaba 等大型互联网公司广泛使用。
etcd 基于 Go 语言实现,主要用于共享配置和服务发现。
etc 在 Linux 系统中是配置文件目录名;etcd 就是配置服务
etcd是提供配置服务的中间件;存储少量的重要信息:高可用、强一致;适合读多写少的场景。
2、安装
(1)在github release page下载相应平台的软件压缩包;
(2)解压缩;
(3)如果是 linux 系统,进入解压目录,将 etcd 和 etcdctl 可执行文件移动到 /usr/local/bin 目录下;
(4)执行 etcd --version 如果能看到版本信息,说明安装成功。
3、v2和v3比较
- 使用 gRPC + protobuf 取代 http + json 通信,提高通信效率;gRPC 只需要一条连接;http 是每个请求建立一条连接;protobuf 加解密比 json 加解密速度得到数量级的提升;包体也更小;
grpc是流式流程;一条长链接,多个请求、多个回应;区别于http的一请求、一回应。
- v3 使用 lease (租约)替换 key ttl 自动过期机制;
redis有设置key的过期时间expire,但都需要独立设置(数量太大很费时间),租约lease就是组合设置。
- v3 支持事务和多版本并发控制(一致性非锁定读)的磁盘数据库;而v2 是简单的 kv 内存数据库;
- v3 是扁平的kv结构;v2 是类型文件系统的存储结构。
etcd是扁平结构,组合拼接名称来操作。v2版本类似zookeeper,是树结构,以文件路径名称来操作。
4、体系结构
- grpc server
里面还有grpc gateway网关代理层,把client http转换成grpc方式进行通信;grpc server除了跟client交互,跟其他grpc server之间也是进行grpc交互。
- 强一致:raft算法保证一致性。
具体的:假如client端发送一个kv对上去(写),leader grpc server收到后,先写日志,再广播给follower grpc server,超过半数以上follow grpc server收到后(写成功),返回一个值给client,确保数据一致。
- 数据同步
- mysql redis主从复制,都是异步复制。客户端写完主redi server后立即返回,然后从redis再来主redis同步数据。
- etcd同步复制:客户端写完leader grpc server、超过半数的follow grpc server同步之后,才会返回。原理就是通过raft算法保证强一致性,保证所有follow都同步后,再返回给客户端。
- wal(write ahead log)预写式日志
执行写操作前先写日志,跟 mysql中 redo 类似,wal 实现的是顺序写,而若按照 B+ 树写,则涉及到多次 io 以及 随机写。
先是顺序写、追加写的方式写磁盘,提升写性能;然后再去修改内存,然后再异步写对应的数。
rocksdb、etcd里用wal文件、mysql用redolog文件:
- rocksdb:有两块存储内存,一块是可变的、一块是不可变的,都是跳表;从可变跳表变成不可变跳表的这段时间,数据都写在wal文件中,等不可变跳表都落盘之后,才会删除相对应的wal。
- mysql:通过redolog。写一个数据,本来是要写B+树的,需要经过多次磁盘io;所以先写redolog,写完之后,用异步的mmap映射的方式去刷磁盘,也是通过顺序写提升写性能。mysql磁盘数据是通过B+树映射,内存数据是通过hash组织数据。
- snap shot快照
用于其他节点同步主节点数据从而达到一致性地状态;类似 redis 中主从复制中 rdb 数据恢复;流程:1. leader 生成 snapshot;2. leader 向 follower 发送 snapshot;3.follower 接收并应用snapshot;
主要用在leader和 follow数据版本差异过大时,同步使用。跟redis数据同步类似,主redis中用rdb结构数据同步到从redis。
- boltdb:负责kv存储
是一个单机的支持事务的 kv 存储,etcd 的事务是基于 boltdb 的事务实现的;boltdb 为每一个 key 都创建一个索引(B+树);该 B+ 树存储了 key 所对应的版本数据;
嵌入式kv数据库,跟rocksdb kv类似,但有本质差别:
- rocksdb组织文件用lsm-tree多层级方式,解决写多读少问题,为了提升写性能,用在分布式关系数据库中;而boltdb用B+树组织文件,解决读多写少问题。
- 内存组织方式:rocksdb用跳表,boltdb用B树(为什么不用红黑树或跳表?见下文)
5、etcd APIs
短链接:一次请求、一次返回,就断开。
(1)设置 put
# Puts the given key into the store
PUT key val
--ignore-lease[=false] updates the key using its current lease
--ignore-value[=false] updates the key using its current value
--lease="0" lease ID (in hexadecimal) to attach to the key
--prev-kv[=false] return the previous key-value pair before modification
(2)删除 del
成功返回1、失败返回0。
# Removes the specified key or range of keys [key, range_end)
DEL key
DEL keyfrom keyend
--from-key[=false] #delete keys that are greater than or equal to the given key using byte compare
--prefix[=false] #delete keys with matching prefix --prev-kv[=false] #return deleted key-value pairs
(3)获取 get
get是重点,因为etcd解决读多写少的问题
# Gets the key or a range of keys
GET key
GET keyfrom keyend
--consistency="l" #Linearizable(l) or Serializable(s) 隔离级别,l是线性隔离,效率最高,但可能取到脏数据;s是串行隔离,最严格
--count-only[=false] #Get only the count
--from-key[=false] #Get keys that are greater than or equal to the given key using byte compare
--keys-only[=false] #Get only the keys
--limit=0 #Maximum number of results
--order="" #order of results; ASCEND or DESCEND (ASCEND by default)
--prefix[=false] #Get keys with matching prefix
--print-value-only[=false] #Only write values when using the "simple" output format
--rev=0 #Specify the kv revision 指定版本
--sort-by="" #Sort target; CREATE, KEY, MODIFY, VALUE, or VERSION # CREATE: 根据创建版本号create_version进行排序; MODIFY: 根据修改版本号mod_version进行排序; VERSION: 根据修改次数version进行排序
limit限定返回个数:
etcd版本号机制: 通过-rev=157 获取指定版本的kv
- term: #进行了多少次的leader选取,etcd重启时+1,如果只有1个节点,term=重启次数
leader 任期,leader 切换时 term 加一;全局单调递增,64 bits;- revision: #全局版本号,增加kv、修改kv,都会+1
etcd 键空间版本号,key 发生变更,则 revision 加一;全局单调递增,64 bits;用来支持MVCC;- kv: #每个kv对应的版本号
(1)create_revision #创建数据时,对应的版本号;
(2)mod_revision #数据修改时,对应的版本号;
(3)version #当前的版本号;标识该 val 被修改了多少次;
redis、rocksdb同一个key多次赋值,是覆盖前一次,而etcd是保留每次记录,用版本号区分,会维持一定数量的信息(v2=1000、v3是指定的数量级)。
get key -w json 打印key对应的值, -w fileds也是:
排序:
(4)监听 watch
用来实现监听和推送服务。
# Watches events stream on keys or prefixes
WATCH key
-i, --interactive[=false] #Interactive mode
--prefix[=false] #Watch on a prefix if prefix is set
--prev-kv[=false] #get the previous key-value pair before the event happens
--progress-notify[=false] #get periodic watch progress notification from server
--rev=0 #Revision to start watching
(5)事务
用于分布式锁以及 leader 选举;保证多个操作的原子性;确保多个节点数据读写的一致性。
问题:通过事务实现 redis 中的 setnx ; //setnx :set no exist设置不存在的kv
注意:
- 比较
1. 比较运算符 > = < != ; 比较运算符两侧需要空格;
2. create 获取 key 的 create_revision
3. mod 获取 key 的 mod_revision
4. value 获取 key 的 value
5. version 获取 key 的修改次数
6. - 比较成功
1. 成功后可以操作多个 del put get
2. 这些操作保证原子性 - 比较失败
1. 失败后可以操作多个 del put get
2. 这些操作保证原子性
# Txn processes all the requests in one transaction
TXN if/ then/ else ops
-i, --interactive[=false] #Input transaction in interactive mode
- redis中:setnx 设置不存在的kv:(setnx key value)
(1)获取key
(2)如果key不存在则设置kv
(3)如果key存在,直接返回key对应的value值 - etcd中事务就是类似redis的setnx操作:
txn:tranction,-i:交互式展示
(6)租约 lease
用于集群监控以及服务注册发现。
lease grant # 创建一个租约
lease keep-alive # 续约
lease list # 枚举所有的租约
lease revoke # 销毁租约
lease timetolive # 获取租约信息
6、数据存储原理
etcd 为每个 key 创建一个索引;一个索引对应着一个 B+ 树;B+ 树 key 为 revision,B+ 节点存储的值为value;B+ 树存储着 key 的版本信息从而实现了 etcd 的 mvcc;etcd 不会任由版本信息膨胀,通过定期的 compaction 来清理历史数据。
etcd,在内存中boltdb使用B树存储,复杂度hOlog2n;rocksdb使用跳表存储,复杂度log2n。
rocksdb内存中是跳表,mysql内存中是hash,etcd内存是B树。
etcd B树跟B+树有一个共同点:一个节点存储多个kv对。B+树一个节点是16k连续大小的磁盘空间,假如里面有k1v1、k2v2、k3v3等多个kv对(有序),如果要取k1v1的话,只能一次取16k全部空间。
如果放到红黑树、或者跳表的话,要一个个取k1v1 k2v2等多次运算才能维持红黑树有序,缺点是要一个一个插入,有n个kv,复杂度就是n * log2n。
etcd采用丢到B树的16k里,形成一一映射的关系,复杂度则是O(1),这就是内存使用B树而不使用红黑树、跳表的原因。
(1)读写机制
etcd 是串行写、并发读;
并发读写时(读写同时进行),读操作是通过 B+ 树 mmap 访问磁盘数据;写操作走日志复制流程;可以得知如果此时读操作走 B 树出现脏读幻读问题;通过 B+ 树访问磁盘数据其实访问的事务开始前的数据,由 mysql 可重复读隔离级别下 MVCC 读取规则可知能避免脏读和幻读问题;
并发读时,可走内存 B 树。
(2)节点在内存
(3)节点在磁盘
7、分布式锁
(1)使用背景
- 单线程锁:
lock(&mtx)
操作临界资源
unlock(&mtx)
- 多线程锁:
保证多个线程有序、不冲突访问同一空间内容,使用分布式锁。
(2)技术重点
- 锁是一种资源
- 存储在哪个位置
- 资源所在地的高可用性,即要有备份。
所以:redis有哨兵、cluster;etcd/zookeeper有半数以上的强一致、高可用性。
- 互斥语义
保证只有一个线程操作锁
- 获取锁和释放锁必须为同一对象
- 锁超时(获取锁和释放锁是通过网络通讯实现的)
获取锁的进程/线程宕机了,无法释放锁,锁自身要有超时机制
- 锁释放通知问题
- 主动问询:非公平锁
- 被动通知:公平锁
- 是否允许同一对象多次获取锁(可选)
- 可以多次:重入锁
- 不可以多次:非重入锁
(3)etcd实现
获取锁
获取锁时,需要在 etcd 做一个标记;根据这些标记进行排序(根据标记的创建版本号排序);
做标记的同时,需要获取当前持有锁的对象;
如果持有锁的对象不是自己,监听版本号刚好小于自己的对象的删除信息;
问题1:如果当前 watch 的对象已经退出但自己仍没有获取锁的权限,怎么修改监听对象?
答案1:继续监听版本号刚好小于自己的对象的删除信息;
问题2:监听动作什么情况下结束?
答案2:没有比自己更小的版本号对象,那么自己就获取了持锁权限。
func (m *Mutex) Lock(ctx context.Context) error {
s := m.s
client := m.s.Client()
m.myKey = fmt.Sprintf("%s%x", m.pfx, s.Lease())
cmp := v3.Compare(v3.CreateRevision(m.myKey), "=", 0) //判断key有没有创建过
// put self in lock waiters via myKey; oldest waiter holds lock
put := v3.OpPut(m.myKey, "", v3.WithLease(s.Lease())) //没有创建过则插入,设置lease值
// reuse key in case this session already holds the lock
get := v3.OpGet(m.myKey) //获取当前谁持有锁
// fetch current holder to complete uncontended path with only one RPC
getOwner := v3.OpGet(m.pfx, v3.WithFirstCreate()...)
resp, err := client.Txn(ctx).If(cmp).Then(put, getOwner).Else(get, getOwner).Commit()
if err != nil {
return err
}
m.myRev = resp.Header.Revision
if !resp.Succeeded {
m.myRev = resp.Responses[0].GetResponseRange().Kvs[0].CreateRevision
}
// if no key on prefix / the minimum rev is key, already hold the lock
ownerKey := resp.Responses[1].GetResponseRange().Kvs
if len(ownerKey) == 0 || ownerKey[0].CreateRevision == m.myRev { //自己持有锁,直接返回
m.hdr = resp.Header
return nil
}
// wait for deletion revisions prior to myKey
hdr, werr := waitDeletes(ctx, client, m.pfx, m.myRev-1) //不是我持有锁,则监听上一个的删除锁事件
// release lock key if wait failed
if werr != nil {
m.Unlock(client.Ctx())
} else {
m.hdr = hdr
}
return werr
}
func waitDeletes(ctx context.Context, client *v3.Client, pfx string, maxCreateRev int64) (*pb.ResponseHeader, error) {
getOpts := append(v3.WithLastCreate(), v3.WithMaxCreateRev(maxCreateRev)) //找恰好比我小1的kv版本,即revison-1
for {
resp, err := client.Get(ctx, pfx, getOpts...) //获取比我小的那个kv
if err != nil {
return nil, err
}
if len(resp.Kvs) == 0 {
return resp.Header, nil
}
lastKey := string(resp.Kvs[0].Key)
if err = waitDelete(ctx, client, lastKey, resp.Header.Revision); err != nil { //等待删除事件
return nil, err //当前自己持有锁
}
}
}
释放锁
func (m *Mutex) Unlock(ctx context.Context) error {
client := m.s.Client()
if _, err := client.Delete(ctx, m.myKey); err != nil {
return err
}
m.myKey = "\x00"
m.myRev = -1
return nil
}
(4)总结
- 整体行为
etcd是kv的,如果有lock:leaseid1、lock:leaseid2、lock:leaseid3、lock:leaseid4四个key,那么:
(1)锁是一种资源:
锁存在lock,以前缀方式存储;第一个设置成功的获取锁成功。
(2)互斥:
创建对象是有先后顺序的,先创建先有;
(3)watch:
lock:leaseid2监听lock:leaseid1的删除,删除后lock:leaseid2获取锁;lock:leaseid3监听lock:leaseid2、lock:leaseid4监听lock:leaseid3。
总的来说,先判断持有锁的是不是自己,是的话直接返回,如果不是则监听上一个kv的删除事件。
[推荐一个零声学院免费公开课程,个人觉得老师讲得不错,分享给大家:Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,点击立即学习: