我正在参加「掘金·启航计划」
etcd v3使用grpc作为通信协议,因此可以使用
- 基于gRPC构建的Go client
- 命令行套件 etcdctl
- etcd同时提供gRPC gateway(一个gRPC的RESTful代理),可以使用支持HTTP/JSON请求
关于etcd api参考:etcd.io/docs/v3.5/l…
gRPC Services
etcd3中的rpc根据功能被分成了如下几类
主要处理k-v的
- KV - Creates, updates, fetches, and deletes key-value pairs.
- Watch - Monitors changes to keys.
- Lease - Primitives for consuming client keep-alive messages.
管理etcd集群的
- Auth - Role based authentication mechanism for authenticating users.
- Cluster - Provides membership information and configuration facilities.
- Maintenance - Takes recovery snapshots, defragments the store, and returns per-member status information.
Requests and Responses
在etcd3中所有的RPC请求都是相同的格式
service KV {
Range(RangeRequest) returns (RangeResponse)
...
}
Response header
所有的响应也都包含一个相同的头
message ResponseHeader {
uint64 cluster_id = 1;
uint64 member_id = 2;
int64 revision = 3;
uint64 raft_term = 4;
}
- Cluster_ID - the ID of the cluster generating the response.
- Member_ID - the ID of the member generating the response.
- Revision - the revision of the key-value store when generating the response.
- Raft_Term - the Raft term of the member when generating the response.
新建client
etcd 的client就是一个对grpc client的包装
import clientv3 "go.etcd.io/etcd/client/v3"
func main() {
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379", "localhost:22379", "localhost:32379"},
DialTimeout: 5 * time.Second,
})
if err != nil {
// handle error!
}
defer cli.Close()
}
KV API
K-V 组合
key-value 是kv api能操作的最小单元,每一个k-v组合都有一定数量的字段,他们被定义成了protobuf格式:
message KeyValue {
bytes key = 1;
int64 create_revision = 2;
int64 mod_revision = 3;
int64 version = 4;
bytes value = 5;
int64 lease = 6;
}
- Key - key in bytes. An empty key is not allowed.
- Value - value in bytes.
- Version - version is the version of the key. A deletion resets the version to zero and any modification of the key increases its version.
- Create_Revision - revision of the last creation on the key.
- Mod_Revision - revision of the last modification on the key.
- Lease - the ID of the lease attached to the key. If lease is 0, then no lease is attached to the key.
写入数据
put返回的是protobuf生成put响应结构
message PutResponse {
ResponseHeader header = 1;
mvccpb.KeyValue prev_kv = 2;
}
- Prev_Kv - 代表被修改之前的的值,
mvccpb.KeyValue就是上文提到的k-v组合
基础用法
resp, err := cli.Put(context.Background(), "sample_key", "sample_value")
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", resp)
获取上一个版本的数据
resp, err := cli.Put(context.Background(), "sample_key", "sample_value2", clientv3.WithPrevKV())
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", resp)
更多的参数可以参考这个文档clientv3 option
读取数据
Get 返回Range的响应RangeResponse
message RangeResponse {
ResponseHeader header = 1;
repeated mvccpb.KeyValue kvs = 2;
bool more = 3;
int64 count = 4;
}
基础用法
resp, err := cli.Get(context.Background(), "sample_key");
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", resp)
for _, kv := range resp.Kvs{
fmt.Println(string(kv.Key))
}
按key的前缀获取
// 读取前缀数据
resp, err := cli.Get(context.Background(), "k", clientv3.WithPrefix())
if err != nil {
panic(err)
}
for _, kv := range resp.Kvs {
s, _ := json.MarshalIndent(kv, "", "\t")
fmt.Println(string(s))
}
删除数据
Delete 返回DeleteRange的响应DeleteRangeResponse
message DeleteRangeResponse {
ResponseHeader header = 1;
int64 deleted = 2;
repeated mvccpb.KeyValue prev_kvs = 3 [(versionpb.etcd_version_field)="3.1"];
}
- Deleted - number of keys deleted.
- Prev_Kv - a list of all key-value pairs deleted by the
DeleteRangeoperation.
基础用法
resp, err := cli.Delete(context.Background(), "key")
if err != nil {
panic(err)
}
s, _ := json.MarshalIndent(resp, "", "\t")
fmt.Println(string(s))
// {
// "header": {
// "cluster_id": 14841639068965178418,
// "member_id": 10276657743932975437,
// "revision": 58,
// "raft_term": 23
// },
// "deleted": 1
// }
删除并返回上一个版本数据
// 删除并返回前缀
resp, err := cli.Delete(context.Background(), "key", clientv3.WithPrevKV())
if err != nil {
panic(err)
}
s, _ := json.MarshalIndent(resp, "", "\t")
fmt.Println(string(s))
// {
// "header": {
// "cluster_id": 14841639068965178418,
// "member_id": 10276657743932975437,
// "revision": 73,
// "raft_term": 24
// },
// "deleted": 1,
// "prev_kvs": [
// {
// "key": "a2V5",
// "create_revision": 59,
// "mod_revision": 72,
// "version": 4,
// "value": "djQ="
// }
// ]
// }
Watch
Events
每一个key的变化都是用event来表示,它不仅包含变化的类型也包含变化的数据,它也被定义成了protobuf格式:
message Event {
enum EventType {
PUT = 0;
DELETE = 1;
}
EventType type = 1;
KeyValue kv = 2;
KeyValue prev_kv = 3;
}
- Type - 事件的类型。 删除和新增/修改
- KV - kv对
- Prev_KV - 发生事件前版本的kv
Watch streams
Watch使用gRPC stream的机制建立长期运行的请求。watch数据流是双向流动的。客户端写入数据来建立watch并接受事件
Watches 对于事件做到了如下保证
- 有序 - 事件一定是按vevision顺序的
- 可靠性 - 顺序的事件之间一定不会丢失事件
- 原子性 - 如果在一个revision中更新了多个key,会在一个事件中体现,不会被拆分到多个事件
Watch
无论是watch的响应还是新事件发生,客户端都会收到WatchResponse的消息
message WatchResponse {
ResponseHeader header = 1;
int64 watch_id = 2;
bool created = 3;
bool canceled = 4;
int64 compact_revision = 5;
repeated mvccpb.Event events = 11;
}
- Watch_ID - 此次watch的id
- Created - 当true时代表创建watch请求响应。客户端应该存下Watch_ID并准备从事件流中接收事件。之后所有的事件都会有相同的Watch_ID
- Canceled - 当true时代表取消watch请求。未来将不会再有事件被发送过来了
- Compact_Revision - 这个字段返回可watch的最小的revision,同时Canceled = true。有两种情况会出现
- 当直接watch一个已经被压缩的revision时(在watch之后被压缩没有影响)
- 当服务端推送到客户端进度无法保证时
- Events - 一系列的事件
基础用法
ch := cli.Watch(context.Background(), "key")
// 处理kv变化事件
for c := range ch {
s, _ := json.MarshalIndent(c, "", "\t")
fmt.Println(string(s))
}
// {
// "Header": {
// "cluster_id": 14841639068965178418,
// "member_id": 10276657743932975437,
// "revision": 77,
// "raft_term": 26
// },
// "Events": [
// {
// "kv": {
// "key": "a2V5",
// "create_revision": 74,
// "mod_revision": 77,
// "version": 4,
// "value": "dmFsMg=="
// }
// }
// ],
// "CompactRevision": 0,
// "Canceled": false,
// "Created": false
// }
watch已经被压缩的版本
同时会关闭chan,结束进程
ch := cli.Watch(context.Background(), "key", clientv3.WithRev(64))
// 处理kv变化事件
for c := range ch {
s, _ := json.MarshalIndent(c, "", "\t")
fmt.Println(string(s))
}
// {
// "Header": {
// "cluster_id": 14841639068965178418,
// "member_id": 10276657743932975437,
// "raft_term": 27
// },
// "Events": [],
// "CompactRevision": 65,
// "Canceled": true,
// "Created": false
// }
watch过程中发生版本压缩
已经watch之后发生的版本压缩,不会对进程产生影响,也不会有事件产生。但可能会导致在下一次启动的时候无法watch成功
ch := cli.Watch(context.Background(), "key", clientv3.WithRev(64))
// 处理kv变化事件
for c := range ch {
s, _ := json.MarshalIndent(c, "", "\t")
fmt.Println(string(s))
}
// etcdctl compaction 66
//
watch标准用法
1、当请求的版本被压缩时,自动移动版本到最小可watch版本
2、异常导致chan关闭下,重新watch
var revision int64 = 1
ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()
for {
fmt.Printf("watch revision: %d\n", revision)
ch := cli.Watch(ctx, "key", clientv3.WithRev(revision))
for c := range ch {
s, _ := json.MarshalIndent(c, "", "\t")
fmt.Println(string(s))
// meet compacted error, use the compact revision.
if c.CompactRevision != 0 {
fmt.Printf("required revision has been compacted, use the compact revision:%d, required-revision:%d", c.CompactRevision, revision)
revision = c.CompactRevision
break
}
if c.Canceled {
fmt.Printf("watcher is canceled with revision: %d error: %v", revision, c.Err())
return
}
revision = c.Header.Revision
}
select {
case <-ctx.Done():
// server closed, return
return
default:
}
}
Lock
lock的service也有pb的定义,但它仅是一个client侧的lock service
基础用法
需要注意两点:
- session设置的ttl其实就是grant lease。因为session会自动续期(keepalive),所以如果不主动解锁,锁是不会被释放的。设置ttl的目的在于,如果因为异常而导致进程终止,那么锁会在ttl之后被释放
- 可以给lock或者unlock传入cancel context来设置超时时间。主要避免长期的阻塞(网络不通等其他原因)等待锁
session, err := concurrency.NewSession(cli, concurrency.WithTTL(10))
if err != nil {
panic(err)
}
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelFn()
mutex := concurrency.NewMutex(session, "lock")
err = mutex.Lock(ctx)
if err != nil {
panic(err.Error())
}
fmt.Println("lock success")
time.Sleep(10 * time.Second)
// 释放锁
ctx, cancelFn = context.WithTimeout(context.Background(), 5*time.Second)
defer cancelFn()
err = mutex.Unlock(ctx)
if err != nil {
panic(err.Error())
}
fmt.Println("unlock success")
原理
1、各客户端准备
1.1、 建立连接,包含租约,其中NewSession时会启动keeplive协程,不断续约
$ etcdctl lease grant 3000 lease 694d7ecf1e977505 granted with TTL(3000s)
$ etcdctl lease grant 3000 lease 694d7ecf1e97750d granted with TTL(3000s)
2、 执行Lock操作(此步使用事务)
2.1、 创建唯一key,规则为使用 leaseId 进行key拼接
m.myKey = fmt.Sprintf("%s%x", m.pfx, s.Lease())
2.2、 各客户端 put 各自唯一的 key
例如要上锁的key是(m.pfx): /xxx/lock/,则各客户端写入 /xxx/lock/id1、/xxx/lock/id2等
2.3、各自获得响应的 Revision
2.4、获取(m.pfx): /xxx/lock/创建的最小Revision
此步使用事务,这里拆分成了多步。这里演示的是创建(put),如果已经被创建过了则是(get)
$ etcdctl put /lock/694d7ecf1e977505 "" --lease 694d7ecf1e977505 -w json {"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":182,"raft_term":33}}
$ etcdctl put /lock/694d7ecf1e97750d "" --lease 694d7ecf1e97750d -w json {"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":183,"raft_term":33}}
$ etcdctl get --prefix /lock/ --sort-by CREATE --order ASCEND --limit 1 -w json {"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":183,"raft_term":33},"kvs":[{"key":"L2xvY2svNjk0ZDdlY2YxZTk3NzUwNQ==","create_revision":182,"mod_revision":182,"version":2,"lease":7587860375225726213}],"more":true,"count":2}
3、各客户端判断是否获得锁
3.1、通过对比上一步获取的(m.pfx): /xxx/lock/reversion 是否和自己相等来认定自己是否获得了锁
对于 /lock/694d7ecf1e977505 来说,put响应的"revision":182,
对于/lock/694d7ecf1e97750d 来说,put响应的"revision":183,
而 /lock/* 下面最小的create_revision是182,所以694d7ecf1e977505抢到了锁
4、未获得锁的客户端等待获得锁后执行业务
4.1、 根据自己的[Reversion-1]作为 MaxCreateReversion,监听(Watch)比自己小且最近的key的删除事件,一旦监听到则判定自己获得了锁,再执行后续逻辑
与redis locker方案对比
1)问题一,租约(比工作完成时间)提前到期的问题。
Etcd
本身支持 KeepAlive 机制,来进行租约续期,在 put 操作成功之后,对 KEY 设置 KeepAlive 即可。Etcd 的租约是与 KV 单独分开的,有自己的租约 ID,所以实现起来并不复杂。
Redis
Redis 本身没有 KeepAlive 的机制,所以,只能客户端自己模拟实现:
1、首先客户端 SET 时,VALUE 要是全局唯一的,也可以使用 UUID,并记下这个 VALUE 值;
2、使用单独的线(协程)程 GET KEY,并对比 VALUE 值是否与前面的记录的值相同,如果相同,说明当前客户端仍然持有锁,通过 EXPIRE 更新 KEY 失效时间;
3、当工作完程,释放锁(删除 KEY)之前,先关闭这个续约线程,并且删除 KEY 之前也要比较 VALUE 是否与本客户端设置的一样,防止释放别的客户端持有的锁;
两种续约方式,基本原理,效果都类似,Etcd 更优雅一些。
2)问题二,保证节点数据一致性的问题
Redis 集群一般有多个 Master 节点,数据负载到不同的 Master 节点上(数据分片)。如果在数据还没有同步到从节点时,master挂掉了,那么锁就会被重复抢到。
为了保证当前只会出现一把锁,就必须要设置 KV 到所有 Master 节点才行(实际只要超过一半就行)。为了解决这个问题,Redis 作者基于 Redis 设计实现了 Redlock 算法,实现过程过程如下:
1、得到当前的时间,微妙单位。
2、尝试顺序地在 5 个实例上申请锁,当然需要使用相同的 key 和 random value,这里一个 client 需要合理设置与 master 节点沟通的 timeout 大小,避免长时间和一个 fail 了的节点浪费时间。
3、当 client 在大于等于 3 个 master 上成功申请到锁的时候,且它会计算申请锁消耗了多少时间,这部分消耗的时间采用获得锁的当下时间减去第一步获得的时间戳得到,如果锁的持续时长(lock validity time)比流逝的时间多的话,那么锁就真正获取到了。
4、如果锁申请到了,那么锁真正的 lock validity time 应该是 origin(lock validity time) - 申请锁期间流逝的时间。
5、如果 client 申请锁失败了,那么它就会在少部分申请成功锁的 master 节点上执行释放锁的操作,重置状态。