etcd 介绍
etcd 简介
etc (基于 Go 语言实现)在 Linux 系统中是配置文件目录名;etcd 就是配置服务;
etcd 诞生于 CoreOS 公司,最初用于解决集群管理系统中 os 升级时的分布式并发控制、配置文件的存储与分发等问题。基于此,etcd 设计为提供高可用、强一致性的小型** kv 数据存储**服务。项目当前隶属于 CNCF 基金会,被包括 AWS、Google、Microsoft、Alibaba 等大型互联网公司广泛使用;
etcd 是一个可靠的分布式 KV 存储,其底层使用 Raft 算法保证一致性,主要用于共享配置、服务发现、集群监控、leader 选举、分布式锁等场景;
1)共享配置:配置文件的存储与分发,将配置文件存储在 etcd 中,其它节点加载配置文件;如需修改配置文件,则修改后,其它节点只需重新加载即可;
2)服务发现:客户端请求经过 etcd 分发给各个服务端,此时如果增加一个服务端并连接到 etcd,由于客户端在一直监听着 etcd,所以客户端能够快速拉去新增服务端地址,并将客户端请求通过 etcd 下发到新增的服务端;
3)集群监控:客户端会通过 etcd 监控所有的 master、slave 节点,一旦有节点发生宕机,客户端能够及时发现;
4)leader 选举:假设一个 master 节点连接多个 slave 节点,如果 master 节点发生宕机,此时 etcd 会从 slave 节点中选取一个节点作为 master 节点;
5)分布式锁:常规情况下锁的持有者和释放者是进程中的线程,而在分布式情况下,锁的持有者和释放者可以是微服务或进程;
etcd 安装
1)安装 golang 环境;
2)下载并编译安装 etcd;
javascript
// 下载源代码
git clone https://gitee.com/k8s_s/etcd.git
// 设置源代理
go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.cn,direct
// 进入 etcd 目录
cd etcd
// 切换最新分支
git checkout release-3.5
go mod vendor
./build
在 etcd/bin 目录生成对应的执行文件 etcd、etcdctl 和 etcdutl
// 查看 etcd 版本
./etcdctl version
说明:可以到 Gitee - 基于 Git 的代码托管和研发协作平台 网站搜 etcd 下载最新的即可!
执行结果如下:
etcd 使用
etcd 的启动与使用
javascript
cd etcd/bin
// 启动 etcd
nohup ./etcd > ./start.log 2>&1 &
// 使用 v3 版本 api
export ETCDCTL_API=3
// ./etcdctl + etcd 命令即可
./etcdctl put key val
执行结果如下所示:
etcd v2 和 v3 比较
扩展:一般情况下一个请求需要建立一条连接,比较浪费资源,所以有了 http + json 通信模式(json 是一种协议),但 json 加解密非常慢;
- 使用 gRPC + protobuf 取代 http + json 通信,提高通信效率;gRPC 只需要一条连接;http 是每个请求建立一条连接;protobuf(是一种二进制协议所以包体小)加解密比 json 加解密速度得到数量级的提升;包体也更小;
- v3 使用 lease (租约)替换 key ttl 自动过期机制(lease 将过期日期一致的 key 绑定到实体(该实体被称为 lease),通过检测实体的过期时间达到批量检查 key 过期时间的效果,效率更高);
- v3 支持事务和多版本并发控制(一致性非锁定读)的磁盘数据库;而 v2 是简单的 kv 内存数据库(可靠性低,一旦服务器宕机数据无法得到保存);
- v3 是扁平的 kv 结构;v2 是类型文件系统的存储结构;
扩展:
1)文件系统的存储结构
- /node
- /node/node1
- /node/node2
- /node/node1/sub1
- /node/node1/sub2
2)扁平的 kv 结构
- node
- node1
- node2
- node3
- 使用 get node --prefix 命令获取对应文件
etcd 架构(体系结构)
etcd 体系结构如下所示:
- boltdb 是一个单机的支持事务的 kv 存储,etcd 的事务是基于 boltdb 的事务实现的;boltdb 为每一个 key 都创建一个索引(B+树);该 B+ 树存储了 key 所对应的版本数据;
- wal(write ahead log)预写式日志实现事务日志的标准方法;执行写操作前先写日志,跟 mysql 中 redo 类似,wal 实现的是顺序写,而若按照 B+ 树写,则涉及到多次 io 以及随机写;
- snapshot 快照数据,用于其他节点同步主节点数据从而达到一致性地状态;类似 redis 中主从复制中 rdb 数据恢复;流程:1. leader 生成 snapshot;2. leader 向 follower 发送 snapshot;3. follower 接收并应用 snapshot;gRPC server ectd 集群间以及 client 与 etcd 节点间都是通过 gRPC 进行通讯;
etcd APIs
数据版本号机制
- term:leader 任期,leader 切换时 term 加一;全局单调递增,64bits;
- revision:etcd 键空间版本号,key 发生变更,则 revision 加一;全局单调递增,64bits;
- kvs:
- create_revision 创建数据时,对应的版本号;
- mod_revision 数据修改时,对应的版本号;
- version 当前的版本号;标识该 val 被修改了多少次;
示例分析:
javascript
# ./etcdctl put key2 val2
OK
# ./etcdctl get key2
key2
val2
# ./etcdctl get key2 -w json
{"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":9,"raft_term":7},"kvs":[{"key":"a2V5Mg==","create_revision":9,"mod_revision":9,"version":1,"value":"dmFsMg=="}],"count":1}
参数说明:
- cluster_id:集群 id;
- member_id:当前 etcd 节点 id;
- revision:整个 etcd 的版本 id,且只要 key 发生变更(增、删、改),则 revision 加一;全局单调递增,64bits;
- raft_term:leader 任期,leader 切换时 term 加一;全局单调递增,64bits;
- kvs:
- create_revision 创建数据时,对应的版本号;
- mod_revision 数据修改时,对应的版本号;
- version 当前的版本号;标识该 val 被修改了多少次;
注:"key":"a2V5Mg==" 和 "value":"dmFsMg==" 是因为值被加密了,在 get 时会对其进行解密!
执行结果:
设置
设置即存储共享配置信息;
javascript
NAME:
put - Puts the given key into the store
USAGE:
etcdctl put [options] <key> <value> (<value> can also be given fromstdin) [flags]
DESCRIPTION:
Puts the given key into the store.
When <value> begins with '-', <value> is interpreted as a flag.
Insert '--' for workaround:
$ put <key> -- <value>
$ put -- <key> <value>
If <value> isn't given as a command line argument and '--ignorevalue' is not specified,this command tries to read the value from standard input.
If <lease> isn't given as a command line argument and '--ignorelease' is not specified,this command tries to read the value from standard input.
For example,
$ cat file | put <key>
will store the content of the file to <key>.
OPTIONS:
-h, --help[=false] help for put
--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 thekey
--prev-kv[=false] return the previous key-value pair beforemodification
语法命令:
javascript
put key val
// 存储 key value 的同时返回上一次存储的 key value
put key val --prev-kv
删除
删除 key vla;
javascript
NAME:
del - Removes the specified key or range of keys [key, range_end)
USAGE:
etcdctl del [options] <key> [range_end] [flags]
OPTIONS:
--from-key[=false] delete keys that are greater than or equal to the given key using byte compare
-h, --help[=false] help for del
--prefix[=false] delete keys with matching prefix
--prev-kv[=false] return deleted key-value pairs
语法命令:
javascript
del key
// 删除成功,返回 1
// 若 key 不存在,则返回 0
获取
获取 key vla;
javascript
NAME:
get - Gets the key or a range of keys
USAGE:
etcdctl get [options] <key> [range_end] [flags]
OPTIONS:
--consistency="l" Linearizable(l) or Serializable(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
-h, --help[=false] help for get
--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
语法命令:
javascript
get key
// 获取前缀匹配 key 的所有 key val
get key --prefix
// 获取字符串小于 key2 的所有 key val
get key key2
// 获取字符串大于等于 key2 的所有 key val
get key2 --from-key
// 只获取字符串等于 key2 的 key
get key2 --keys-only
// 获取前缀匹配 key 的所有 key
get key --prefix --keys-only
// 获取前缀匹配 key 的前两个 key
get key --prefix --keys-only --limit=2
// 先排序,再获取前缀匹配 key 的前两个 key
get key --prefix --keys-only --limit=2 --sort-by=value
get "小于" 案例
javascript
// 获取所有前缀和 key 匹配的 key val
# ./etcdctl get key --prefix
key
val2023
key1
val1
key2
val2
key20
val20
key2024
val2024
// 范围查询,获取 key2 之前(范围区间为左闭右开)的 key val
# ./etcdctl get key key2
key
val2023
key1
val1
注:比较范围区间时是按字符串进行比较的,如:key、key1、key2、key20、key2024 中只有 key、key1 小于 key2;
执行结果:
get "大于等于" 案例
javascript
# ./etcdctl get key --prefix
key
val2023
key1
val1
key2
val2
key20
val20
key2024
val2024
# ./etcdctl get key2 --from-key
key2
val2
key20
val20
key2024
val2024
执行结果:
监听
用来实现监听和推送服务;
javascript
NAME:
watch - Watches events stream on keys or prefixes
USAGE:
etcdctl watch [options] [key or prefix] [range_end] [--] [execcommand arg1 arg2 ...] [flags]
OPTIONS:
-h, --help[=false] help for watch
-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
语法命令:
javascript
// 监听 key 的变动
watch key
1) 启两个 session
2) 在 session A 中执行:WATCH key
3) 在 session B 中执行操作 key 的命令,如:PUT key val,DEL key 等,则同时会在 session A 中显示具体操作
// 当前事件发生前先获取前一个 key val
watch key --prev-kv
// 监听多个 key 的变动
watch key --prefix
说明:监听时也可以指定监听范围和版本等信息;
事务
用于分布式锁以及 leader 选举;保证多个操作的原子性;确保多个节点数据读写的一致性;
有关数据版本号信息请参考上述:数据版本号机制 部分;
javascript
NAME:
txn - Txn processes all the requests in one transaction
USAGE:
etcdctl txn [options] [flags]
OPTIONS:
-h, --help[=false] help for txn
-i, --interactive[=false] Input transaction in interactive mode
事务
1. 比较
1. 比较运算符 > = < !=
2. create 获取key的create_revision
3. mod 获取key的mod_revision
4. value 获取key的value
5. version 获取key的修改次数
2. 比较成功,执行下述代码
1. 成功后可以操作多个 del put get
2. 这些操作保证原子性
3. 比较失败,执行下述代码
1. 成功后可以操作多个 del put get
2. 这些操作保证原子性
语法命令:
javascript
TXN if/ then/ else ops
mod 比较案例
javascript
# ./etcdctl put key val1995
OK
# ./etcdctl get key -w json
{"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":12,"raft_term":7},"kvs":[{"key":"a2V5","create_revision":2,"mod_revision":12,"version":5,"value":"dmFsMTk5NQ=="}],"count":1}
# ./etcdctl put key val2024
OK
# ./etcdctl get key -w json
{"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":13,"raft_term":7},"kvs":[{"key":"a2V5","create_revision":2,"mod_revision":13,"version":6,"value":"dmFsMjAyNA=="}],"count":1}
# ./etcdctl txn -i
compares:
mod("key")="9"
Error: malformed comparison: mod("key")="9"; got mod("key") ""
# ./etcdctl txn -i
compares:
mod("key") = "12"
success requests (get, put, del):
get key
failure requests (get, put, del):
get key --rev=12
FAILURE
key
val1995
从上述执行结果来看,代码走的是 比较失败 的逻辑;
注:mod("key") = "12" 等号前后要有空格,不然会报错!
执行结果:
create 比较案例
javascript
# ./etcdctl txn -i
compares:
create("key") = "2"
success requests (get, put, del):
get key
failure requests (get, put, del):
del key
SUCCESS
key
val2024
执行结果:
version 比较案例
javascript
# ./etcdctl put key val2020
OK
# ./etcdctl get key -w json
{"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":14,"raft_term":7},"kvs":[{"key":"a2V5","create_revision":2,"mod_revision":14,"version":7,"value":"dmFsMjAyMA=="}],"count":1}
# ./etcdctl put key val2023
OK
# ./etcdctl get key -w json
{"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":15,"raft_term":7},"kvs":[{"key":"a2V5","create_revision":2,"mod_revision":15,"version":8,"value":"dmFsMjAyMw=="}],"count":1}
# ./etcdctl txn -i
compares:
version("key") = "7"
success requests (get, put, del):
get key
failure requests (get, put, del):
get key --rev=14
FAILURE
key
val2020
执行结果:
租约
用于集群监控以及服务注册发现;
javascript
etcdctl lease grant <ttl> [flags] 创建一个租约
etcdctl lease keep-alive [options] <leaseID> [flags] 续约
etcdctl lease list [flags] 枚举所有的租约
etcdctl lease revoke <leaseID> [flags] 销毁租约
etcdctl lease timetolive <leaseID> [options] [flags] 获取租约信息
OPTIONS:
--keys[=false] Get keys attached to this lease
语法命令:
javascript
// 创建一个 100 秒的租约
lease grant 100
// 如果租约创建成功会显示如下输出
lease 694d7b82c54a9309 granted with TTL(100s)
// 将多个 key 绑定到租约
put key1 vla1 --lease=694d7b82c54a9309
put key2 vla2 --lease=694d7b82c54a9309
put key3 vla3 --lease=694d7b82c54a9309
// 获取具有匹配前缀的 key(包括:绑定租约的 key 和未绑定租约的 key)
get key --prefix
// 输出结果
key1
vla1
key2
vla2
key3
vla3
// 销毁租约
lease revoke 694d7b82c54a9309
// 获取具有匹配前缀的 key(因为租约已被销毁,所以此时返回的只有未绑定租约的 key)
get key --prefix
// 获取租约信息(如果租约未过期,则输出结果会显示租约的剩余日期;如果租约已过期,则显示已过期)
lease timetolive 694d7b82c54a9309
// 输出结果(租约已过期)
lease 694d7b82c54a9309 already expired
// 续约(可以让租约剩余日期一直保持在设定时间;续约前提是当前租约未过期)
lease keep-alive 694d7b82c54a9309
锁
javascript
USAGE:
etcdctl lock <lockname> [exec-command arg1 arg2 ...] [flags]
OPTIONS:
-h, --help[=false] help for lock
--ttl=10 timeout for session
Go 操作 etcd
驱动包安装
不能直接 go get go.etcd.io/etcd/clientv3(官方提供驱动包)不然会报错的;因为 gRPC 版本过新的缘故;
这里我们需要指定 gRPC 的版本信息;
javascript
# 指定 gRPC 版本为 v1.26.0
go mod edit --require=google.golang.org/grpc@v1.26.0
# 下载安装 gRPC 驱动包
go get -u -x google.golang.org/grpc@v1.26.0
# 下载安装 etcd 驱动包
go get go.etcd.io/etcd/clientv3
Go 操作 etcd 实例
启动 etcd
1)方式一
javascript
nohup ./etcd > ./start.log 2>&1 &
// 查看端口对外开放情况(etcd 默认端口为 2379)
lsof -i:2379
执行结果:
从上述执行结果可知,使用方式一启动时,etcd 的端口号只能在本地连接。
2)方式二
javascript
nohup ./etcd --listen-client-urls 'http://0.0.0.0:2379' --advertise-client-urls 'http://0.0.0.0:2379' > ./start.log 2>&1 &
// 查看端口对外开放情况(etcd 默认端口为 2379)
lsof -i:2379
执行结果:
从上述执行结果可知,使用方式一启动时,etcd 的端口号可以被外部连接。
注:使用方式二启动 etcd!
注:如果 etcd 所在机器是公司内部机器,需要把安全组对应端口号放开,即需要放开 2379!
put、get 使用
Go
package main
import (
"context"
"fmt"
"time"
"github.com/coreos/etcd/clientv3"
)
func main() {
// 创建连接
cli, err := clientv3.New(clientv3.Config{
// Endpoints 是一个切片,可同时连接多个服务器
Endpoints: []string{"120.92.144.250:2379"},
DialTimeout: 5 * time.Second, // 连接超时时间
})
if err != nil {
panic(err)
}
// 程序执行结束前释放连接资源
defer cli.Close()
// v3 通讯服务使用的是 gRPC,需设置超时控制(即如果 put 命令执行后,在超时时间内没有返回结果,则取消 put 命令的执行)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
_, err = cli.Put(ctx, "key", "mark")
cancel()
if err != nil {
panic(err)
}
// 获取 key
ctx, cancel = context.WithTimeout(context.Background(), time.Second)
/*
此处的 get 等同于在终端执行 ./etcdctl get key -w json
输出结果:
{"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":17,"raft_term":7},
"kvs":[{"key":"a2V5","create_revision":2,"mod_revision":15,"version":8,"value":"dmFsMjAyMw=="}],"count":1}
*/
resp, err := cli.Get(ctx, "key")
cancel()
if err != nil {
panic(err)
}
for _, ev := range resp.Kvs {
fmt.Printf("%s:%s\n", ev.Key, ev.Value)
}
}
watch 使用
Go
package main
import (
"context"
"fmt"
"github.com/coreos/etcd/clientv3"
)
func main() {
// 创建连接
cli, err := clientv3.NewFromURL("120.92.144.250:2379")
if err != nil {
panic(err)
}
defer cli.Close()
// watch key 的操作
//watch := cli.Watch(context.Background(), "key")
// watch 大于等于 key3 的操作,监听对象由第三个参数控制
watch := cli.Watch(context.Background(), "key", clientv3.WithFromKey())
for resp := range watch {
for _, ev := range resp.Events {
fmt.Printf("Type: %s Key: %s Value: %s\n", ev.Type, ev.Kv.Key, ev.Kv.Value)
}
}
}
执行上述代码后会阻塞等待其它用户操作 etcd,如下所示:
1)在终端执行 etcd 操作
2)在 go 客户端查看监听情况
lease 使用
Go
package main
import (
"context"
"fmt"
"github.com/coreos/etcd/clientv3"
)
func main() {
// 创建连接
cli, err := clientv3.NewFromURL("120.92.144.250:2379")
if err != nil {
panic(err)
}
defer cli.Close()
// 创建租约
lease, err := cli.Grant(context.Background(), 5)
if err != nil {
panic(err)
}
fmt.Println("lease id", lease.ID)
// 把 key-val 绑定到租约
_, err = cli.Put(context.Background(), "key", "mark", clientv3.WithLease(lease.ID))
if err != nil {
panic(err)
}
// 续租:长期续租、短期续租
// 长期续租:不停的续租
if false {
ch, err := cli.KeepAlive(context.Background(), lease.ID)
if err != nil {
panic(err)
}
for {
recv := <-ch
fmt.Println("time to live", recv.TTL)
}
}
// 短期续租:只续租一次
if true {
res, err := cli.KeepAliveOnce(context.Background(), lease.ID)
if err != nil {
panic(err)
}
fmt.Println("time to live", res.TTL)
}
}
lock 使用
Go
package main
import (
"context"
"fmt"
"github.com/coreos/etcd/clientv3"
"github.com/coreos/etcd/clientv3/concurrency"
)
func main() {
// 创建连接
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"127.0.0.1:2379"},
})
if err != nil {
panic(err)
}
defer cli.Close()
// 创建 session1
s1, err := concurrency.NewSession(cli, concurrency.WithContext(context.Background()), concurrency.WithTTL(10))
if err != nil {
panic(err)
}
defer s1.Close()
// 为 session1 创建锁
m1 := concurrency.NewMutex(s1, "lock")
// 创建 session2
s2, err := concurrency.NewSession(cli, concurrency.WithContext(context.Background()))
if err != nil {
panic(err)
}
defer s2.Close()
// 为 session2 创建锁
m2 := concurrency.NewMutex(s2, "lock")
// 对 session1 加锁
if err := m1.Lock(context.Background()); err != nil {
panic(err)
}
fmt.Println("s1 acquired lock")
// 创建管道
m2ch := make(chan struct{})
// 开启协程,对 session2 加锁,但由于已经被 session1 锁住,所以 session2 的加锁操作,阻塞等待
go func() {
defer close(m2ch)
if err := m2.Lock(context.Background()); err != nil {
panic(err)
}
}()
// session1 释放锁
if err := m1.Unlock(context.Background()); err != nil {
panic(err)
}
fmt.Println("s1 released lock")
// 通知 session2 session1 已经释放锁,此时 session2 可执行加锁操作
<-m2ch
fmt.Println("s2 acquired lock")
}
注:Go 项目在创建好之后,需要在终端执行:go mod init 项目名称,生成 go.mod 文件。
etcd 存储原理及读写机制
存储原理
etcd 为每个 key 创建一个索引;一个索引对应着一个 B+ 树;B+ 树 key 为 revision,B+ 树节点存储的值为 value;B+ 树存储着 key 的版本信息从而实现了 etcd 的 mvcc;etcd 不会任由版本信息膨胀,通过定期的 compaction 来清理历史数据;
etcd 为了加速索引数据,在内存中维持着一个 B 树;B 树 key 为 key-val 中的 key,value 为该 key 的 revision;示意图如下:
etcd 不同命令执行流程:
- etcd get 命令执行流程:etcd 在执行 get 获取数据时,先从内存中的 B 树中寻找,如果找不到,再从 B+ 树中寻找,从 B+ 树中找到数据后,将其缓存到 B 树并输出到客户端;
- etcd put 命令执行流程:etcd 在执行 put 插入或修改数据时,先从内存中的 B 树中寻找,如果找到了,则对其进行修改并将其写入到 B+ 树;
问题:mysql 的 mvcc 是通过什么实现的?
答:undolog;
问题:mysql B+ 树存储什么内容?
答:具体分为聚簇索引和二级索引;
问题:mysql 为了加快索引数据,采用什么数据结构?
答:MySQL 采用自适应 hash 来加速索引;
扩展:B-树和 B+ 树区别?
- B-树和 B+ 树都是多路平衡搜索树;采用中序遍历的方式会得到一个有序的结构;都是通过 key 的方式来维持树的有序性;
- B-树一个节点中 n 个元素对应着 n+1 个指针;而 B+ 树一个节点中 n 个元素对应着 n 个指针;
- B-树每个节点都存储节点信息,B+ 树只有叶子节点存储节点信息,非叶子节点只存储索引信息;
- B+ 树叶子节点之间通过双向链表连接,对于范围查询速度更快,这样减少了磁盘 io;
读写机制
etcd 是串行写(避免不必要的加锁),并发读;
并发读写时(读写同时进行),读操作是通过 B+ 树 mmap 访问磁盘数据;写操作走日志复制流程;可以得知如果此时读操作走 B 树出现脏读幻读问题;通过 B+ 树访问磁盘数据其实访问的事务开始前的数据,由 mysql 可重复读隔离级别下 MVCC 读取规则可智能避免脏读和幻读问题;
并发读时,可走内存 B 树;
注:由于 etcd 写的时候是先写到内存中的 B 树,然后再写到磁盘上的 B+ 树,因此并发读写时需要读 B+ 树数据,否则容易出现脏读幻读问题;