Iawen's Blog

我喜欢这样自由的随手涂鸦, 因为我喜欢风......

1. etcd简介

etcd是CoreOS团队于2013年6月发起的开源项目. 作为一个受到ZooKeeper与doozer启发而催生的项目, 它的目标是构建一个高可用的分布式键值(key-value)数据库. etcd内部采用raft协议作为一致性算法, etcd基于Go语言实现, 在V3+版本, 底层数据采用了BoltDB 数据库. etcd项目地址: https://github.com/coreos/etcd/

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

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

etcd具备以下特点:

  • 完全复制: 集群中的每个节点都可以使用完整的存档
  • 高可用性: Etcd可用于避免硬件的单点故障或网络问题
  • 一致性: 每次读取都会返回跨多主机的最新写入
  • 简单: 安装配置简单, 而且提供了HTTP API进行交互, 使用也很简单
  • 安全: 支持SSL证书验证
  • 快速: 根据官方提供的benchmark数据, 单实例支持每秒2k+读操作
  • 可靠: 采用raft算法, 实现分布式系统数据的可用性和一致性

1.1 etcd应用场景

1.1.1 配置中心

etcd是一个分布式的键值存储系统, 其优秀的读写性能、一致性和可用性的机制, 非常适合用来做配置中心角色. etcd的应用场景优化都是围绕存储的东西是“配置” 来设定的:

  • 配置的数据量通常都不大, 所以默认etcd的存储上限是1GB
  • 配置通常对历史版本信息是比较关心的, 所以etcd会保存 版本(revision) 信息
  • 配置变更是比较常见的, 并且业务程序会需要实时知道, 所以etcd提供了watch机制, 基本就是实时通知配置变化
  • 配置的准确性一致性极其重要, 所以etcd采用raft算法, 保证系统的CP
  • 同一份配置通常会被大量客户端同时访问, 针对这个做了grpc proxy对同一个key的watcher做了优化
  • 配置会被不同的业务部门使用, 提供了权限控制和namespace机制

1.1.2 分布式锁

因为 etcd 使用 Raft 算法保持了数据的强一致性, 某次操作存储到集群中的值必然是全局一致的, 所以很容易实现分布式锁, 可以用来做分布式场景下的同步机制保证.

测试如下:

func etcdLock2(cli *client.Client, key string) {
	ctx := context.Background()
	lease, err := cli.Grant(ctx, 10)
	if err != nil {
		log.Println("cli.Grant:", err)
		return
	}

	s, err := concurrency.NewSession(cli, concurrency.WithLease(lease.ID))
	if err != nil {
		log.Println("concurrency.NewSession:", err)
		return
	}
	mutex := concurrency.NewMutex(s, key)

	log.Printf("%-5d: Try Lock\n", os.Getpid())
	if err := mutex.Lock(ctx); err != nil {
		fmt.Printf("%-5d: mutex.Lock: %s\n", os.Getpid(), err)
		return
	}
	// defer cli.Revoke(ctx, lease.ID)
	defer mutex.Unlock(ctx)

	log.Printf("%-5d: Begin\n", os.Getpid())
	log.Printf("%-5d: Do Something (Sleep 10 seonds) ...\n", os.Getpid())
	time.Sleep(time.Second * 10)
	log.Printf("%-5d: End\n", os.Getpid())
}

效果如下(为了获取锁, 足足等待了8 seconds:
7

  • 租约防止了客户端崩溃没有来得及释放锁, 避免死锁
  • 租约不影响持锁客户端运行超时, 如果要实现超时, 可以使用context 来实现
  • 主动释放锁比等待锁的租约到期要有效

1.1.3 leader选举组件

分布式场景下, 常采用leader-follower模式来保证有状态服务的高可用(即使leader挂掉, 其他follower立马补上), 比如k8s和kafka partition高可用机制. 可以很方便的借助etcd来实现leader选举机制, 这里有个leader election实现: https://github.com/willstudy/leaderelection

func etcdElection(cli *client.Client, name string) {
	var getLeader = func(ctx context.Context, elc *concurrency.Election, pid int) {
		resp, err := elc.Leader(ctx)
		if err != nil {
			log.Printf(" [%-5d] elc.Leader: %s", pid, err)
			return
		}
		if len(resp.Kvs) > 0 {
			log.Printf(" [%-5d] Leader: %s\n", pid, resp.Kvs[0].Value)
		} else {
			log.Printf(" [%-5d] Leader: None\n", pid)
		}
	}

	s, err := concurrency.NewSession(cli)
	if err != nil {
		log.Println("concurrency.NewSession:", err)
		return
	}

	ctx := context.Background()
	elc := concurrency.NewElection(s, "/demo/election")
	pid := os.Getpid()

	time.Sleep(2 * time.Second)
	getLeader(ctx, elc, pid)
	log.Printf(" [%-5d] Campaign\n", pid)
	if err = elc.Campaign(ctx, strconv.Itoa(pid)); err != nil {
		log.Printf(" [%-5d] elc.Campaign: %s", pid, err)
		return
	}

	getLeader(ctx, elc, pid)
	time.Sleep(10 * time.Second)

	elc.Resign(ctx)
	log.Printf(" [%-5d] Resign\n", pid)

	time.Sleep(5 * time.Second)
	getLeader(ctx, elc, pid)
}

运行效果如下:
6

1.1.4 服务注册与服务发现

etcd比较多的应用场景是用于服务发现, 服务发现(Service Discovery)要解决的是分布式系统中最常见的问题之一, 即在同一个分布式集群中的进程或服务如何才能找到对方并建立连接.
从本质上说, 服务发现就是要了解集群中是否有进程在监听upd或者tcp端口, 并且通过名字就可以进行查找和链接.

要解决服务发现的问题, 需要下面三大支柱, 缺一不可.

  • 一个强一致性、高可用的服务存储目录.
    基于Ralf算法的etcd天生就是这样一个强一致性、高可用的服务存储目录.

  • 一种注册服务和健康服务健康状况的机制.
    用户可以在etcd中注册服务, 并且对注册的服务配置key TTL, 定时保持服务的心跳以达到监控健康状态的效果.

  • 一种查找和连接服务的机制.
    通过在etcd指定的主题下注册的服务业能在对应的主题下查找到. 为了确保连接, 我们可以在每个服务机器上都部署一个proxy模式的etcd, 这样就可以确保访问etcd集群的服务都能够互相连接.

1.1.5 消息订阅和发布

etcd内置watch机制, 完全可以实现一个小型的消息订阅和发布组件.

1.1.6 负载均衡

此处指的负载均衡均为软负载均衡, 分布式系统中, 为了保证服务的高可用以及数据的一致性, 通常都会把数据和服务部署多份, 以此达到对等服务, 即使其中的某一个服务失效了, 也不影响使用。由此带来的坏处是数据写入性能下降, 而好处则是数据访问时的负载均衡。因为每个对等服务节点上都存有完整的数据, 所以用户的访问流量就可以分流到不同的机器上。

1.1.7 分布式队列

分布式队列的常规用法与分布式锁的控制时序用法类似, 即通过创建一个先进先出的队列来保证顺序。底层的实现其实是在指定key下, 以 time.Now().UnixNano() 为顺序, 生成一系列的KV 对, 同时 Key 上的 create_revision 和 mod_revision 保持递增, 取值是使用 WithFirstRev() :
10

// go.etcd.io/etcd/client/v3/experimental/recipes/key.go
func newUniqueKV(kv v3.KV, prefix string, val string) (*RemoteKV, error) {
	for {
		newKey := fmt.Sprintf("%s/%v", prefix, time.Now().UnixNano())
		rev, err := putNewKV(kv, newKey, val, v3.NoLease)
		if err == nil {
			return &RemoteKV{kv, newKey, rev, val}, nil
		}
		if err != ErrKeyExists {
			return nil, err
		}
	}
}

// Demo
func etcdQueue(cli *client.Client, name string) {
	queue := recipe.NewQueue(cli, name)

	pid := os.Getpid()
	for i := 0; i < 5; i++ {
		val := fmt.Sprintf("%5d - %2d", pid, i)
		if err := queue.Enqueue(val); err != nil {
			fmt.Println("queue.Enqueue:", err)
			continue
		}
		log.Println(" --> ", val)
		time.Sleep(time.Second)
	}

	for {
		time.Sleep(time.Second)
		val, err := queue.Dequeue()
		if err != nil {
			fmt.Println("queue.Dequeue:", err)
			continue
		}
		log.Println(" <-- ", val)
	}
}

运行效果如下:
8
9

1.2 etcd安装

etcd在生产环境中一般推荐集群方式部署. 本文定位为入门, 主要讲讲单节点安装和基本使用.

etcd目前默认使用2379端口提供HTTP API服务, 2380端口和peer通信(这两个端口已经被IANA官方预留给etcd); 在之前的版本中可能会分别使用4001和7001, 在使用的过程中需要注意这个区别.

因为etcd是go语言编写的, 安装只需要下载对应的二进制文件, 并放到合适的路径就行.

wget https://github.com/coreos/etcd/releases/download/v3.5.1/etcd-v3.5.1-linux-amd64.tar.gz
tar xzvf etcd-v3.5.1-linux-amd64.tar.gz
mv etcd-v3.5.1-linux-amd64 /usr/local/etcd

解压后是一些文档和三个二进制文件etcd和etcdctl、etcdutl. etcd是server端, etcdctl是客户端, etcdutl是一个命令行管理实用程序.

$ ls
Documentation  etcd  etcdctl  etcdutl  README-etcdctl.md  README-etcdutl.md  README.md  READMEv2-etcdctl.md

如果在测试环境, 启动一个单节点的etcd服务, 只需要运行etcd命令就行.

$ ./etcd
2017-04-10 11:46:44.772465 I | etcdmain: etcd Version: 3.1.5
2017-04-10 11:46:44.772512 I | etcdmain: Git SHA: 20490ca
2017-04-10 11:46:44.772607 I | etcdmain: Go Version: go1.7.5
2017-04-10 11:46:44.772756 I | etcdmain: Go OS/Arch: linux/amd64
2017-04-10 11:46:44.772817 I | etcdmain: setting maximum number of CPUs to 2, total number of available CPUs is 2
2017-04-10 11:46:44.772851 W | etcdmain: no data-dir provided, using default data-dir ./default.etcd
2017-04-10 11:46:44.773298 I | embed: listening for peers on http://localhost:2380
2017-04-10 11:46:44.773583 I | embed: listening for client requests on localhost:2379
2017-04-10 11:46:44.775967 I | etcdserver: name = default
2017-04-10 11:46:44.775993 I | etcdserver: data dir = default.etcd
2017-04-10 11:46:44.776167 I | etcdserver: member dir = default.etcd/member
2017-04-10 11:46:44.776253 I | etcdserver: heartbeat = 100ms
2017-04-10 11:46:44.776264 I | etcdserver: election = 1000ms
2017-04-10 11:46:44.776270 I | etcdserver: snapshot count = 10000
2017-04-10 11:46:44.776285 I | etcdserver: advertise client URLs = http://localhost:2379
2017-04-10 11:46:44.776293 I | etcdserver: initial advertise peer URLs = http://localhost:2380
2017-04-10 11:46:44.776306 I | etcdserver: initial cluster = default=http://localhost:2380
2017-04-10 11:46:44.781171 I | etcdserver: starting member 8e9e05c52164694d in cluster cdf818194e3a8c32
2017-04-10 11:46:44.781323 I | raft: 8e9e05c52164694d became follower at term 0
2017-04-10 11:46:44.781351 I | raft: newRaft 8e9e05c52164694d [peers: [], term: 0, commit: 0, applied: 0, lastindex: 0, lastterm: 0]
2017-04-10 11:46:44.781883 I | raft: 8e9e05c52164694d became follower at term 1
2017-04-10 11:46:44.795542 I | etcdserver: starting server... [version: 3.1.5, cluster version: to_be_decided]
2017-04-10 11:46:44.796453 I | etcdserver/membership: added member 8e9e05c52164694d [http://localhost:2380] to cluster cdf818194e3a8c32
2017-04-10 11:46:45.083350 I | raft: 8e9e05c52164694d is starting a new election at term 1
2017-04-10 11:46:45.083494 I | raft: 8e9e05c52164694d became candidate at term 2
2017-04-10 11:46:45.083520 I | raft: 8e9e05c52164694d received MsgVoteResp from 8e9e05c52164694d at term 2
2017-04-10 11:46:45.083598 I | raft: 8e9e05c52164694d became leader at term 2
2017-04-10 11:46:45.083654 I | raft: raft.node: 8e9e05c52164694d elected leader 8e9e05c52164694d at term 2
2017-04-10 11:46:45.084544 I | etcdserver: published {Name:default ClientURLs:[http://localhost:2379]} to cluster cdf818194e3a8c32
2017-04-10 11:46:45.084638 I | etcdserver: setting up the initial cluster version to 3.1
2017-04-10 11:46:45.084857 I | embed: ready to serve client requests
2017-04-10 11:46:45.085918 E | etcdmain: forgot to set Type=notify in systemd service file?
2017-04-10 11:46:45.086668 N | embed: serving insecure client requests on 127.0.0.1:2379, this is strongly discouraged!
2017-04-10 11:46:45.087004 N | etcdserver/membership: set the initial cluster version to 3.1
2017-04-10 11:46:45.087195 I | etcdserver/api: enabled capabilities for version 3.1

从上面的输出中, 我们可以看到很多信息. 以下是几个比较重要的信息:

  • name表示节点名称, 默认为default.
  • data-dir 保存日志和快照的目录, 默认为当前工作目录default.etcd/目录下.
  • 在http://localhost:2380和集群中其他节点通信.
  • 在http://localhost:2379提供HTTP API服务, 供客户端交互.
  • heartbeat为100ms, 该参数的作用是leader多久发送一次心跳到
  • followers, 默认值是100ms.
  • election为1000ms, 该参数的作用是重新投票的超时时间, 如果follow在该+ 时间间隔没有收到心跳包, 会触发重新投票, 默认为1000ms.
  • snapshot count为10000, 该参数的作用是指定有多少事务被提交时, 触发+ 截取快照保存到磁盘.
  • 集群和每个节点都会生成一个uuid.
  • 启动的时候会运行raft, 选举出leader.

1.3 创建systemd服务

上面的方法只是简单的启动一个etcd服务, 但要长期运行的话, 还是做成一个服务好一些. 下面将以systemd为例, 介绍如何建立一个etcd服务.

1.3.1 设定etcd配置文件,建立相关目录

mkdir -p /data/etcd

# 创建etcd配置文件
cat <<EOF | tee /etc/etcd.conf
#节点名称
ETCD_NAME=etcd1
#数据存放位置
ETCD_DATA_DIR=/data/etcd
EOF

1.3.2 创建systemd配置文件

cat <<EOF | tee //usr/lib/systemd/system/etcd.service

[Unit]
Description=Etcd Server
Documentation=https://github.com/coreos/etcd
After=network.target

[Service]
User=root
Type=notify
EnvironmentFile=-/etc/etcd.conf
ExecStart=/usr/local/etcd/etcd
Restart=on-failure
RestartSec=10s
LimitNOFILE=40000

[Install]
WantedBy=multi-user.target
EOF

1.3.3 启动etcd

$ systemctl daemon-reload && systemctl enable etcd && systemctl start etcd

2. etcd基本使用

etcdctl是一个命令行客户端, 它能提供一些简洁的命令, 供用户直接跟etcd服务打交道, 而无需基于 HTTP API方式. 可以方便我们在对服务进行测试或者手动修改数据库内容. 建议刚刚接触etcd时通过etdctl来熟悉相关操作. 这些操作跟HTTP API基本上是对应的.

etcd项目二进制发行包中已经包含了etcdctl工具, etcdctl支持的命令大体上分为数据库操作和非数据库操作两类.

$ etcd --version
etcd Version: 3.5.1
Git SHA: e8732fb5f
Go Version: go1.16.3
Go OS/Arch: linux/amd64

2.1 常用命令选项:

  • –debug 输出CURL命令, 显示执行命令的时候发起的请求
  • –no-sync 发出请求之前不同步集群信息
  • –output, -o ‘simple’ 输出内容的格式(simple 为原始信息, json 为进行json格式解码, 易读性好一些)
  • –peers, -C 指定集群中的同伴信息, 用逗号隔开(默认为: “127.0.0.1:4001”)
  • –cert-file HTTPS下客户端使用的SSL证书文件
  • –key-file HTTPS下客户端使用的SSL密钥文件
  • –ca-file 服务端使用HTTPS时, 使用CA文件进行验证
  • –help, -h 显示帮助命令信息
  • –version, -v 打印版本信息

2.2 数据操作

数据库操作围绕对键值和目录的CRUD完整生命周期的管理.
etcd在键的组织上采用了层次化的空间结构(类似于文件系统中目录的概念), 用户指定的键可以为单独的名字, 如:testkey, 此时实际上放在根目录/下面, 也可以为指定目录结构, 如/cluster1/node2/testkey, 则将创建相应的目录结构. 查看具体的API 操作, 详见:https://etcd.io/docs/v3.5/dev-guide/api_reference_v3/, 也可以翻阅源码 go.etcd.io/etcd/api/etcdserverpb/rpc.proto.

注: CRUD 即Create,Read,Update,Delete是符合REST风格的一套API操作.

2.2.1 put

指定某个键的值, 当键存在时, 更新值内容. 例如:

# curl -s http://localhost:2379/v3/kv/put -X POST -d '{"key": "Zm9v", "value": "YmFy"}' | jq .kvs
etcdctl put /testdir/testkey "Hello world"
OK

支持的选项包括:

  • –ttl ‘0’ 该键值的超时时间(单位为秒), 不配置(默认为0)则永不超时
  • –swap-with-value value 若该键现在的值是value, 则进行设置操作
  • –swap-with-index ‘0’ 若该键现在的索引值是指定索引, 则进行设置操作

2.2.2 get

获取指定键的值。etcdctl 有get 命令, 但 API 层其实是没有单独的 GET 接口, 它其实是Range的一个特例。不过一般的Client 对Range 进行了封装).

// OpGet returns "get" operation based on given key and operation options.
func OpGet(key string, opts ...OpOption) Op {
	// WithPrefix and WithFromKey are not supported together
	if IsOptsWithPrefix(opts) && IsOptsWithFromKey(opts) {
		panic("`WithPrefix` and `WithFromKey` cannot be set at the same time, choose one")
	}
	ret := Op{t: tRange, key: []byte(key)}
	ret.applyOpts(opts)
	return ret
}

示例:

# curl -s http://localhost:2379/v3/kv/range -X POST -d '{"key": "L3Rlc3RkaXIvdGVzdGtleQ=="}' | jq .kvs
etcdctl get /testdir/testkey
/testdir/testkey
Hello world 

# 获取Key范围[foo, foo4)
etcdctl get foo foo4
foo
bar
foo3
bar3

# 当键不存在时, 则无返回: 
etcdctl get /testdir/testkey2

# 获取 etcd 中所有的 key
etcdctl get "" --from-key

支持的参数:

  • –sort 对结果进行排序
  • –consistent 将请求发给主节点, 保证获取内容的一致性.
  • –prefix 遍历所有符合的前缀Key
  • –rev 指定版本号
  • –from-key

2.2.3 del

删除某个键值. 当键不存在时, 则返回0. 例如:

# curl -s http://localhost:2379/v3/kv/deleterange -X POST -d '{"key": "L3Rlc3RkaXIvdGVzdGtleQ=="}' | jq .deleted
etcdctl del /testdir/testkey
1
etcdctl del /testdir/testkey
0

支持的选项为:

  • –dir 如果键是个空目录或者键值对则删除
  • –recursive 删除目录和所有子键
  • –with-value 检查现有的值是否匹配
  • –with-index ‘0’检查现有的index是否匹配

2.2.4 txn 事务

etcd中事务是原子执行的, 只支持类似if … then … else …这种表达

# curl -L http://localhost:2379/v3/kv/txn \
#  -X POST \
#  -d '{"compare":[{"target":"CREATE","key":"Zm9v","createRevision":"2"}],"success":[{"requestPut":{"key":"Zm9v","value":"YmFy"}}]}'

# 开启事务
etcdctl txn --interactive
compares:
#  输入以下内容,注意=号两边要有空格, 输入结束按两次回车
value("user1") = "bad"      

# 如果 user1 = bad, 则执行 get user1 
success requests (get, put, del):
get user1

# 如果 user1 != bad, 则执行 put user1 good
failure requests (get, put, del):
put user1 good

# 运行结果, 执行 success
FAILURE

OK

2.2.5 watch

基于Http/2 的server push, 并且对事件进行了多路复用优化。监测一个键值的变化, 一旦键值发生更新, 就会输出最新的值. 例如: 用户更新testkey键值为Hello watch.

$ etcdctl get /testdir/testkey
Hello world
$ etcdctl set /testdir/testkey "Hello watch"
Hello watch
$ etcdctl watch testdir/testkey
PUT
/testdir/testkey
Hello,world

支持的选项包括:

  • –forever 一直监测直到用户按CTRL+C退出
  • –after-index ‘0’ 在指定index之前一直监测
  • –recursive 返回所有的键值和子键值

2.2.6 查看当前 revision

向etcd查询某个 key 即可获得当前 revision, 因此最简单的办法就是查询一个不存在的 key。如下所示, 查询 revisiontestkey , 因为这个 key 不存在, 那么只返回查询的 head, revision 就在 head 中

$ etcdctl get revisiontestkey -w json
{"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":5,"raft_term":4}}

3. etcd 集群管理

etcd 作为一个高可用键值存储系统, 天生就是为集群化而设计的. 由于 Raft 算法在做决策时需要多数节点的投票, 所以 etcd 一般部署集群推荐奇数个节点, 推荐的数量为 3、5 或者 7 个节点构成一个集群.
etcdctl用于与etcd交互的控制台程序。API版本可以通过ETCDCTL_API环境变量设置为2或3版本。默认情况下, v3.4以上的etcdctl使用v3 API, v3.3及更早的版本默认使用v2 API。

注意: 用v2 API创建的任何key将不能通过v3 API查询。同样, 用v3 API创建的任何key将不能通过v2 API查询。

3.1 集群成员管理(Member)

通过list、add、remove、update 命令列出、添加、删除、更新etcd实例到etcd集群中.

3.1.1 查看集群中存在的节点

$ etcdctl member list
8e9e05c52164694d, started, etcd01, http://localhost:2380, http://localhost:2379, false

3.1.2 删除集群中存在的节点

$ etcdctl member remove 8e9e05c52164694d
Removed member 8e9e05c52164694d from cluster

3.1.3 向集群中新加节点

# 启动新节点
etcd --name etcd2 --listen-client-urls http://127.0.0.1:2179 --advertise-client-urls http://127.0.0.1:2179 --listen-peer-urls http://127.0.0.1:2180 \
 --initial-advertise-peer-urls http://127.0.0.1:2180 --initial-cluster-state existing --initial-cluster \ 
 etcd1=http://localhost:2380,etcd2=http://127.0.0.1:2180  --initial-cluster-token etcd-cluster-1

etcd --name etcd3 --listen-client-urls http://127.0.0.1:12179 --advertise-client-urls http://127.0.0.1:12179 --listen-peer-urls http://127.0.0.1:12180 \
 --initial-advertise-peer-urls http://127.0.0.1:12180 --initial-cluster-state existing --initial-cluster \ 
 etcd1=http://localhost:2380,etcd2=http://127.0.0.1:2180,etcd2=http://127.0.0.1:12180  --initial-cluster-token etcd-cluster-1

etcdctl member add etcd2 --peer-urls=http://127.0.0.1:2180
etcdctl member add etcd3 --peer-urls=http://127.0.0.1:12180
Added member named etcd1 with ID 8e9e05c52164694d to cluster

3.2 租约(lease)

lease 可以设置访问的失效时间, 取代 Key TTL 自动过期机制。

$ etcdctl lease grant 300   //创建一个300秒的租约
lease 694d7dc0f30ae917 granted with TTL(300s)

$ etcdctl put sample value -- lease=694d7dc0f30ae917    //写入操作时将租约id为694d7dc0f30ae917的租约分配给sample
OK

$ etcdctl get sample

$ etcdctl lease keep-alive 694d7dc0f30ae917 //续约
$ etcdctl lease revoke 694d7dc0f30ae917     //手动释放租约
lease 694d7dc0f30ae917 revoked

# or after 300 seconds                            //自动释放租约
$ etcdctl get sample

Lease提供了几个功能:

  • grant: 创建一个租约.
  • revoke: 释放一个租约.
  • timetolive: 获取剩余TTL时间.
  • list: 列举所有etcd中的租约.
  • keep-alive: 自动定时的续约某个租约.

3.3 分布式锁(lock)

分布式锁, 一个人操作的时候, 另外一个人只能看, 不能操作

$ etcdctl lock mutex1
mutex1/326963a02758b52d

# 第二终端
$ etcdctl lock mutex1

# 当第一个终端结束了, 第二个终端会显示
mutex1/326963a02758b53

3.4 选举(elect)

选举节点为leader, 只有leader节点才有写入的权限, 普通节点只有读权限, 保证数据一致性; leader节点会定时向普通节点发送心跳, 当普通节点收不到心跳时会自动选举新的leader

$ etcdctl elect one p1

one/326963a02758b539
p1

# another client with the same name blocks
$ etcdctl elect one p2

# 结束第一终端, 第二终端显示
one/326963a02758b53e
p2

3.5 集群状态监控(endpoint)

集群健康状态检查

$ etcdctl --write-out=table endpoint status
$ etcdctl endpoint health

4. ETCD网络层实现

在目前的实现中, ETCD通过HTTP协议对外提供服务, 同样通过HTTP协议实现集群节点间数据交互。(在最新版本中支持Google gRPC方式访问.)
网络层的主要功能是实现了服务器与客户端(能发出HTTP请求的各种程序)消息交互, 以及集群内部各节点之间的消息交互。

4.1 ETCD-SERVER整体架构

ETCD-SERVER 大体上可以分为网络层, Raft模块, 复制状态机, 存储模块, 架构图如图所示。
1

  • 网络层: 提供网络数据读写功能, 监听服务端口, 完成集群节点之间数据通信, 收发客户端数据;
  • Raft模块: 完整实现了Raft协议;
  • 存储模块: KV存储, WAL文件, SNAPSHOT管理
  • 复制状态机: 这个是一个抽象的模块, 状态机的数据维护在内存中, 定期持久化到磁盘, 每次写请求会持久化到WAL文件, 并根据写请求的内容修改状态机数据。

4.2 节点之间网络拓扑结构

ETCD集群的各个节点之间需要通过HTTP协议来传递数据, 表现在:

  • Leader 向Follower发送心跳包, Follower向Leader回复消息;
  • Leader向Follower发送日志追加信息;
  • Leader向Follower发送Snapshot数据;
  • Candidate节点发起选举, 向其他节点发起投票请求;
  • Follower将收的写操作转发给Leader;

各个节点在任何时候都有可能变成Leader, Follower, Candidate等角色, 同时为了减少创建链接开销, ETCD节点在启动之初就创建了和集群其他节点之间的链接。

因此, ETCD集群节点之间的网络拓扑是一个任意2个节点之间均有长链接相互连接的网状结构。如图所示。
2

需要注意的是, 每一个节点都会创建到其他各个节点之间的长链接。每个节点会向其他节点宣告自己监听的端口, 该端口只接受来自其他节点创建链接的请求。

4.3 节点之间消息交互

在ETCD实现中, 根据不同用途, 定义了各种不同的消息类型。各种不同的消息, 最终都通过google protocol buffer协议进行封装。这些消息携带的数据大小可能不尽相同。例如 传输SNAPSHOT数据的消息数据量就比较大, 甚至超过1GB, 而leader到follower节点之间的心跳消息可能只有几十个字节。

因此, 网络层必须能够高效地处理不同数据量的消息。ETCD在实现中, 对这些消息采取了分类处理, 抽象出了2种类型消息传输通道: Stream类型通道和Pipeline类型通道。这两种消息传输通道都使用HTTP协议传输数据。
3

集群启动之初, 就创建了这两种传输通道, 各自特点:

  • Stream类型通道: 点到点之间维护HTTP长链接, 主要用于传输数据量较小的消息, 例如追加日志, 心跳等;
  • Pipeline类型通道: 点到点之间不维护HTTP长链接, 短链接传输数据, 用完即关闭。用于传输数据量大的消息, 例如snapshot数据。

如果非要做做一个类别的话, Stream就向点与点之间维护了双向传输带, 消息打包后, 放到传输带上, 传到对方, 对方将回复消息打包放到反向传输带上; 而Pipeline就像拥有N辆汽车, 大消息打包放到汽车上, 开到对端, 然后在回来, 最多可以同时发送N个消息。

4.3.1 Stream类型通道

Stream类型通道处理数据量少的消息, 例如心跳, 日志追加消息。点到点之间只维护1个HTTP长链接, 交替向链接中写入数据, 读取数据。

Stream 类型通道是节点启动后主动与其他每一个节点建立。Stream类型通道通过Channel 与Raft模块传递消息。每一个Stream类型通道关联2个Goroutines, 其中一个用于建立HTTP链接, 并从链接上读取数据, decode成message, 通过Channel传给Raft模块中, 另外一个通过Channel 从Raft模块中收取消息, 然后写入通道。

具体点, ETCD使用golang的http包实现Stream类型通道:

  • 被动发起方监听端口, 并在对应的url上挂载相应的handler(当前请求来领时, handler的ServeHTTP方法会被调用)
  • 主动发起方发送HTTP GET请求;
  • 监听方的Handler的ServeHTTP访问被调用(框架层传入http.ResponseWriter和http.Request对象), 其中http.ResponseWriter对象作为参数传入Writter-Goroutine(就这么称呼吧), 该Goroutine的主循环就是将Raft模块传出的message写入到这个responseWriter对象里; http.Request的成员变量Body传入到Reader-Gorouting(就这么称呼吧), 该Gorutine的主循环就是不断读取Body上的数据, decode成message 通过Channel传给Raft模块。

4.3.2 Pipeline类型通道

Pipeline类型通道处理数量大消息, 例如SNAPSHOT消息。这种类型消息需要和心跳等消息分开处理, 否则会阻塞心跳。

Pipeline类型通道也可以传输小数据量的消息, 当且仅当Stream类型链接不可用时。

Pipeline类型通道可用并行发出多个消息, 维护一组Goroutines, 每一个Goroutines都可向对端发出POST请求(携带数据), 收到回复后, 链接关闭。

具体地, ETCD使用golang的http包实现的:

  • 根据参数配置, 启动N个Goroutines;
  • 每一个Goroutines的主循环阻塞在消息Channel上, 当收到消息后, 通过POST请求发出数据, 并等待回复。

4.4 网络层与Raft模块之间的交互

在ETCD中, Raft协议被抽象为Raft模块。按照Raft协议, 节点之间需要交互数据。在ETCD中, 通过Raft模块中抽象的RaftNode拥有一个message box, RaftNode将各种类型消息放入到messagebox中, 有专门Goroutine将box里的消息写入管道, 而管道的另外一端就链接在网络层的不同类型的传输通道上, 有专门的Goroutine在等待(select)。

而网络层收到的消息, 也通过管道传给RaftNode。RaftNode中有专门的Goroutine在等待消息。

也就是说, 网络层与Raft模块之间通过Golang Channel完成数据通信。这个比较容易理解。

4.5 ETCD-SERVER处理请求(与客户端的信息交互)

在ETCD-SERVER启动之初, 会监听服务端口, 当服务端口收到请求后, 解析出message后, 通过管道传入给Raft模块, 当Raft模块按照Raft协议完成操作后, 回复该请求(或者请求超时关闭了)。

4.6 主要数据结构

网络层抽象为Transport类, 该类完成网络数据收发。对Raft模块提供Send/SendSnapshot接口, 提供数据读写的Channel, 对外监听指定端口。
4

5. MVCC

多版本并发控制(Multiversion concurrency control, MCC 或 MVCC), 是数据库管理系统常用的一种并发控制, 也用于程序设计语言实现事务内存。

MVCC意图解决读写锁造成的多个、长时间的读操作饿死写操作问题。每个事务读到的数据项都是一个历史快照, 并依赖于实现的隔离级别。写操作不覆盖已有数据项, 而是创建一个新的版本, 直至所在操作提交时才变为可见。快照隔离使得事务看到它启动时的数据状态。

5.1 Etcd 数据模型

etcd v3 将数据存储在一个多版本的持久化 key-value 存储里面。值得注意的是, 作为 key-value 存储的 etcd 会将数据存储在另一个 key-value 数据库(BoltDB)中。

etcd v3 存储的逻辑视图是一个扁平的二进制键空间。该键空间对 key 有一个词法排序索引, 因此范围查询的成本很低。etcd 的键空间可维护多个 revision。每个原子的修改操作(例如, 一个事务操作可能包含多个操作)都会在键空间上创建一个新的 revision。之前 revision的所有数据均保持不变。

revision 在 etcd 中可以起到 逻辑时钟的作用。revision 在集群的生命周期内是单调递增的。如果因为要节省空间而压缩键空间, 那么在此 revision 之前的所有 revision 都将被删除, 只保留该 revision 之后的。

操作 header revision header raftterm KV create_revision KV mod_revision version
创建 2 2 2 2 1
修改1 3 2 2 3 2
修改2 4 2 2 4 3
删除后重建 6 2 6 6 1
创建其他Key后 7 2 6 6 1

etcd 将物理数据存储为一棵持久 B+树中 的键值对。为了高效, 每个revision 的存储状态都只 包含相对于之前 revision 的增量。一个 revision 可能对
应于树中的多个key。

B+树中键值对的 key 即 revision, revision 是一个2元组( main, sub), 其中 main 是该 revision 的主版本号, sub 是同一 revision 的副版本号, 其用于区分同一个 revision 的不同 key。B+树中键值对的 value 包含了相对于之前revision 的修改, 即相对于之前 revision 的一个增量。

type ResponseHeader struct {
	// cluster_id is the ID of the cluster which sent the response.
	ClusterId uint64 `protobuf:"varint,1,opt,name=cluster_id,json=clusterId,proto3" json:"cluster_id,omitempty"`
	// member_id is the ID of the member which sent the response.
	MemberId uint64 `protobuf:"varint,2,opt,name=member_id,json=memberId,proto3" json:"member_id,omitempty"`
	// revision is the key-value store revision when the request was applied.
	// For watch progress responses, the header.revision indicates progress. All future events
	// received in this stream are guaranteed to have a higher revision number than the
	// header.revision number.
	Revision int64 `protobuf:"varint,3,opt,name=revision,proto3" json:"revision,omitempty"`
	// raft_term is the raft term when the request was applied.
	RaftTerm             uint64   `protobuf:"varint,4,opt,name=raft_term,json=raftTerm,proto3" json:"raft_term,omitempty"`
}

type KeyValue struct {
	Key []byte `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
	// create_revision is the revision of last creation on this key.
	CreateRevision int64 `protobuf:"varint,2,opt,name=create_revision,json=createRevision,proto3" json:"create_revision,omitempty"`
	// mod_revision is the revision of last modification on this key.
	ModRevision int64 `protobuf:"varint,3,opt,name=mod_revision,json=modRevision,proto3" json:"mod_revision,omitempty"`
	// 删除会将版本重置为零, 并且对 Key 的任何修改都会增加其版本。
	Version int64 `protobuf:"varint,4,opt,name=version,proto3" json:"version,omitempty"`
	// value is the value held by the key, in bytes.
	Value []byte `protobuf:"bytes,5,opt,name=value,proto3" json:"value,omitempty"`
	// lease is the ID of the lease that attached to key.
	// When the attached lease expires, the key will be deleted.
	// If lease is 0, then no lease is attached to the key.
	Lease                int64    `protobuf:"varint,6,opt,name=lease,proto3" json:"lease,omitempty"`
}

5.2 MVCC 的实现

etcd v3 当前使用BoltDB 将数据存储在磁盘中。
BoltDB 是根据 Howard Chu 的 LMDB 项目开发的一个纯粹的 Go 语言版的key/value 存储。它的目标是为项目提供一个简单、高效、可靠的嵌入式的、可序列化的键/值数据库, 而不是要求一个像 MySQL 那样完整的数据库服务器。BoltDB 还是一个支持事务的键值存储, etcd 的事务就是基于BoltDB 的事务实现的。

etcd 在内存中还维护了一个 kvindex, 保存的就是 key 与 reversion 之前的映射关系, 用来加速查询的。kvindex, 是基于Google开源的 GoLang 的 B 树实现的, 也就是etcd v3 在内存中维护的二级索引。这样当客户端通过 key 来查询 value 的时候, 会先在kvindex中查询这个 key 的所有 revision, 然后再通过revision 从 BoltDB 中查询数据。

5.3 MVCC j原码分析

5.3.1 revision

type revision struct {
	// main is the main revision of a set of changes that happen atomically.
	main int64

	// sub is the sub revision of a change in a set of changes that happen
	// atomically. Each change has different increasing sub revision in that
	// set.
	sub int64
}

// revision 转化为key
func revToBytes(rev revision, bytes []byte) {
	binary.BigEndian.PutUint64(bytes, uint64(rev.main))
	bytes[8] = '_'
	binary.BigEndian.PutUint64(bytes[9:], uint64(rev.sub))
}

在内存的索引中, 每个用户的原始 key 都会关联一个 key_index 结构, 里面维护了多版本信息,

type keyIndex struct {
	key         []byte
	modified    revision // the main rev of the last modification
	generations []generation
}
type generation struct {
	ver     int64
	created revision // when the generation is created (put in first revision).
	revs    []revision
}

最后, 在 bbolt 中存储的 value 是这样一个 JSON 序列化后的结构, 包括key创建时的 revision (对应于某-代 generation 的 created)、本次更新的版本、sub ID(Version ver)、Lease ID(租约ID) 等 。

5.3.2 key 到 revision 之间的映射关系

type treeIndex struct {
	sync.RWMutex
	tree *btree.BTree
	lg   *zap.Logger
}

6. Etcd 事务

6.1 etcd 的Serializability

Serializability 只保证了以某种顺序执行事务, 并不能保证一定要以某个确定的顺序来执行。Strict Serializability (严格的可串行化)则可以保证多个事务并行执行的效果, 与以按照实际的提交时间串行执行多个事务的效果是一样的。

6.2 软件事务内存

etcd 的软件事务内存(Software Transactional Memory, STM) API 则对基于版本号的冲突解决逻辑进行了封装: 它自动检测内存访问时的冲突, 并自动尝试在冲突的时候对事务进行回退和重试。
STM 系统可以确保的事项具体如下:

    1. 事务是原子的, 一个事务提交以后, 如果该事务涉及了对多个key的操作, 那么对多个key的操作要么都成功, 要么都不成功
    1. 事务至少具有可重复读取隔离型, 以保证不会读到脏数据
    1. 数据是一致的, 提交的时候 STM 会自动检测到数据冲突并重试事务以解决这些冲突

7. 其他

7.1 数据管理及维护

7.1.1 backup

备份etcd的数据.

$ etcdutl backup --data-dir /data/etcd  --backup-dir /data/etcd_backup

支持的选项包括:

  • –data-dir etcd的数据目录
  • –backup-dir 备份到指定路径

7.1.2 快照(snapshot)

用于保存etcd数据库的快照

etcdctl snapshot save my.db
Snapshot saved at my.db
etcdctl --write-out=table snapshot status my.db

7.2 etcd和同类型产品的对比

5

7.2.1 etcd vs redis

etcd诞生之日起, 就定位成一种分布式存储系统, 并在k8s 服务注册和服务发现中, 为大家所认识. 它偏重的是节点之间的通信和一致性的机制保证, 并不强调单节点的读写性能. 而redis最早作为缓存系统出现在大众视野, 也注定了redis需要更加侧重于读写性能和支持更多的存储模式, 它不需要care一致性, 也不需要侧重事务, 因此可以达到很高的读写性能, 关键差异如下:

  • redis在分布式环境下不是强一致性的, 可能会丢失数据, 或者读取不到最新数据
  • redis的数据变化监听机制没有etcd完善
  • etcd强一致性保证数据可靠性, 导致性能上要低于redis
  • etcd和ZooKeeper是定位类似的项目, 跟redis定位不一样

总结一下, redis常用来做缓存系统, etcd常用来做分布式系统中一些关键数据的存储服务, 比如服务注册和服务发现.

7.2.2 etcd vs consul

consul定位是一个端到端的服务框架, 提供了内置的监控检查、DNS服务等, 除此之外, 还提供了HTTP API和Web UI, 如果要实现简单的服务发现, 基本上可以开箱即用.
但是缺点同样也存在, 封装有利有弊, 就导致灵活性弱了不少. 除此之外, consul还比较年轻, 暂未在大型项目中实践, 可靠性还未可知.

7.2.3 etcd vs zookeeper

  • 一致性协议: ETCD使用[Raft]协议, ZK使用ZAB(类PAXOS协议), 前者容易理解, 方便工程实现;
  • 运维方面: ETCD方便运维, ZK难以运维;
  • 项目活跃度: ETCD社区与开发活跃, ZK已经快死了;
  • API: ETCD提供HTTP+JSON, gRPC接口, 跨平台跨语言, ZK需要使用其客户端, 官方只提供了 Java 和 C 两种语言的接口;
  • 访问安全方面: ETCD支持HTTPS访问, ZK在这方面缺失;
  • 持久稳定的watch, 而不是简单的单次出发watch

文章参考: