etcd 基础入门:
一)etcd 功能介绍
- 数据存储在集群中的
高可用
K-V存储。 - 允许应用
实时监听
存储中的K-V变化。 - 可以容忍
单点故障
,并支持网络分区
。
在传统的存储模型中:
- 如果存储节点是单点存储,呢么遇到宕机,即刻不可用;
- 如果是主从架构,当主库不可用的使用,虽然可以继续基于从库来读,单主从同步时延容忍度又是新的问题。
etcd 基于抽屉理论来解决该点,所谓的抽屉理论
指:
假如我们有一个30人的班级,我将一个秘密告诉其中的16位同学,呢么随便挑选16个同学中,必然有一个是知道我秘密的同学。呢么假如班里一直会有一半以上的同学正常上课,呢么我这个秘密就能正确获取;
etcd 与 Raft 的关系
- Raft 是强一致的集群日志同步算法
- etcd是一个分布式KV存储
- etcd利用raft算法在集群中同步key-value的
Raft 日志概念、异常安全
名词:
replication
: 日志在leader生成,向follower赋值,达到各个节点的日志序列组中一致;
term
: 任期,重新选举产生的leader, term会单调递增;
log index
: 日支行在日志序列的下标;
选举:
- raft选举leader需要半数以上的节点参与;
- 节点commit日志最多的选举为Leader;
- commit日志同样多,则term、index越大的允许选举为leader;
quorum(大多数) 模型
该模型要求集群中至少有2N+1个节点;
- 调用者向leader写入信息后,leader并不会立马同步返回给调用者,而是会follower进行同步。
- 同步的follower+leader至少占半数以上的时候(既大于等于N+1个节点后),leader完成本地提交,此时才会返回客户端。
- 随后leader会异步通知follower自己完成提交操作,所以该模型也是两阶段提交。
二) etcd相关特性
- 交互协议支持GRPC,内部基于ProtoBuffer;
- 底层存储是按key有序排列,支持顺序遍历;
- 因为key有序,所以etcd天然支持按目录结构高效遍历;
- 支持复杂事物,提供类型if…then…else…的事务能力;
- 基于
租约
机制实现key的TTL过期; - etcd 支持MVCC多版本控制(提交会在version单调递增,同key维护多个历史版本),以实现
watch
机制; - 对于多版本控制,可以执行compact命令完成删除。
watch 工作原理
lease 租约
- 调用者通过sdk向etcd申请一个单位时长的租约,etcd返回该租约的id;
- 随后调用者带着这个租约ID,向etcd申请K-V存储;
- K-V存储引擎与租约建立了关联,当该租约过期的时候,便会想K-V存储引擎删除该记录;
- 而续租面向的仍旧是租约,需要调用者想租约申请 ‘续租’ ;
三)etcd 功能实践
安装:为了方便学习,本地安装单机环境;
启动日志如下:
相关ctl 指令介绍 :
基础API:
1: put
2:get
3: 根据前缀查询
4: watch
第一个ctl watch 指定模糊前缀,第二个ctl 对其进行变更;于是 ctl1 收到了变更。
相关sdk指令case :
这个etcd 的依赖包直接使用go get 下载不下来,我是使用 gopath + copy 相关的进行的引入;
第一步:创建一个空的文件夹,copy 绝对路径
第二步:export GOPATH= 刚copy的绝对路径
第三步:于上面的绝对路径下创建src包
第四步:将偷渡过来的第三方包放到src下;
GO 代码启动测试:
import (
"fmt"
"go.etcd.io/etcd/clientv3"
"time"
)
func main() {
var (
config clientv3.Config
client *clientv3.Client
err error
)
config = clientv3.Config{
Endpoints: []string{"127.0.0.1:2379"},
DialTimeout: 1 * time.Second,
}
if client, err = clientv3.New(config); err != nil {
fmt.Println(err)
return
}
client = client
}
PUT操作
//用于读写etcd中的kv对
kv = clientv3.NewKV(client)
//put
if putResp, err = kv.Put(context.TODO(), "/cron/jobs/job1", "helloOld"); err != nil {
fmt.Println(err)
} else {
fmt.Println("revision ", putResp.Header.Revision)
}
//put and get perv
if putResp, err = kv.Put(context.TODO(), "/cron/jobs/job1", "hello", clientv3.WithPrevKV()); err != nil {
fmt.Println(err)
} else {
fmt.Println("revision ", putResp.Header.Revision)
if putResp.PrevKv != nil {
fmt.Printf("prevValue : k = %s, v = %s ", string(putResp.PrevKv.Key), string(putResp.PrevKv.Value))
}
}
GET操作
if getResp, err = kv.Get(context.TODO(), "/cron/jobs/job1"); err != nil {
fmt.Println(err)
} else {
fmt.Println(getResp.Kvs)
}
kv.Get(context.TODO(), “/cron/jobs/job1” ,OpOption) ;
Opoption中可以下达查询是否要仅查个数、是否要查当前游标下几个元素等。
if getResp, err = kv.Get(context.TODO(), "/cron/jobs/",clientv3.WithPrefix()); err != nil {
fmt.Println(err)
} else {
fmt.Println(getResp.Kvs)
}
DEL操作
if delResp, err = kv.Delete(context.TODO(), "/cron/jobs/job1", clientv3.WithPrevKV()); err != nil {
fmt.Println(err)
} else {
if len(delResp.PrevKvs) != 0 {
for idx, kvPair = range delResp.PrevKvs {
fmt.Printf(" index =%d ,del key =%s del value =%s: ", idx, string(kvPair.Key), string(kvPair.Value))
}
}
}
del中,也同样支持追加Opoption行为,比如从某个Key开始,删除limit个;
租约
case 1 : 创建一个简单的KV,并挂接租约
//通过客户端申请租约
lease = clientv3.NewLease(client)
//默认时间是秒
if leaseGrantResp, err = lease.Grant(context.TODO(), 5); err != nil {
fmt.Println(err)
return
}
leaseId = leaseGrantResp.ID
kv = clientv3.NewKV(client)
if putResp, err = kv.Put(context.TODO(), "/cron/lock/job1", "default", clientv3.WithLease(leaseId)); err != nil {
fmt.Println(err)
return
}
fmt.Println("写入成功", putResp.Header.Revision)
for {
if getResp, err = kv.Get(context.TODO(), "/cron/lock/job1"); err != nil {
fmt.Println(err)
return
}
if getResp.Count == 0 {
fmt.Println("kv 被移除了")
} else {
fmt.Println(getResp.Kvs)
}
time.Sleep(2 * time.Second)
}
case 2: 创建一个简单的KV,并对其进行续约
if keepRespChan, err = lease.KeepAlive(context.TODO(), leaseId); err != nil {
fmt.Println(err)
return
}
go func() {
for {
select {
case keepResp = <-keepRespChan:
if keepResp == nil {
fmt.Println("因为长时间失联等异常情况,租约已过期,无法续约")
goto END
} else {
fmt.Println("续约成功", keepResp.ID)
}
}
}
END:
}()
keepRespChan的类型是chan of *clientv3.LeaseKeepAliveResponse
,在lease.KeepAlive()的返回值中被初始化过了,keepRespChan一直会是一个地址,KeepAlive函数将会close chan。此时<-keepRespChan返回nil,基于此点判断续约是否成功
监听
//创建一个协程,用于变化指定key
go func() {
for {
kv.Put(context.TODO(), "/cron/jobs/job7", "i am job7")
kv.Delete(context.TODO(), "/cron/jobs/job7")
time.Sleep(1 * time.Second)
}
}()
// 先GET到当前的值,并监听后续变化
if getResp, err = kv.Get(context.TODO(), "/cron/jobs/job7"); err != nil {
fmt.Println(err)
return
}
// 现在key是存在的
if len(getResp.Kvs) != 0 {
fmt.Println("当前值 kvs:", getResp.Kvs) // [key:"/cron/jobs/job7" create_revision:24 mod_revision:24 version:1 value:"i am job7" ]
fmt.Println("当前值:", string(getResp.Kvs[0].Value)) //i am job7
}
// watch当前 revision之后的变化记录
watchStartRevision = getResp.Header.Revision + 1
// 创建一个watcher
watcher = clientv3.NewWatcher(client)
// 启动监听
fmt.Println("从该版本向后监听:", watchStartRevision)
// 监听5秒后进行删除移除
ctx, cancelFunc := context.WithCancel(context.TODO())
time.AfterFunc(5*time.Second, func() {
cancelFunc()
})
watchRespChan = watcher.Watch(ctx, "/cron/jobs/job7", clientv3.WithRev(watchStartRevision))
//处理变化
for watchResp = range watchRespChan {
for _, event = range watchResp.Events {
switch event.Type {
case mvccpb.PUT:
fmt.Println("修改:", string(event.Kv.Value), "Revision:", event.Kv.CreateRevision, event.Kv.ModRevision)
case mvccpb.DELETE:
fmt.Println("删除", "Revision:", event.Kv.ModRevision)
}
}
}
clientv3.Op Get/Put/Del 操作
同样的, 后面也可以加WithPrefix 等其他op操作。
// 创建Op
putOp = clientv3.OpPut("/cron/jobs/job8", "123123123")
// 执行OP
if opResp, err = kv.Do(context.TODO(), putOp); err != nil {
fmt.Println(err)
return
} else {
fmt.Println("写入Revision:", opResp.Put().Header.Revision)
}
// 创建Op
getOp = clientv3.OpGet("/cron/jobs/job8")
// 执行OP
if opResp, err = kv.Do(context.TODO(), getOp); err != nil {
fmt.Println(err)
return
} else {
fmt.Println("数据Revision:", opResp.Get().Kvs[0].ModRevision) // create rev == mod rev
fmt.Println("数据value:", string(opResp.Get().Kvs[0].Value))
}
乐观锁 case
基于etcd 的乐观锁 与 java+zookeeper/redis 的同步抢锁,整体思路一致,回答清楚下面的问题,剩下的基于上述的case学习,可以独立完成。(以后有时间可以试试写一个tryLock(Timeout time) 的方法试试)
F:若单次加锁时间为1S,但是作业任务超过了1秒,如何保证在接下来的时间仍旧作业的时候,资源依然独占?
A:在Java+redis 的分布式锁中, 可以通过当前作业线程创建相关子线程进行定时重置声明周期。而在etcd中可以通过向租约进行续约
,来保证租约不过期;
F: 向redis中进行事务操作依赖lua编排指令成’原子‘执行 ,或者通过redission 才可以做到。呢么etcd如何保证事务呢?
A:天然支持,有蛮好用的API;
F:当任务执行完毕后,如何进行释放lock;
A:java+redis 中,往往是在finally中做del操作; etcd 中可以通过defer 函数,当函数退时进行租约进行取消;
F:这种分布式锁都会确保一个前提,若加锁节点宕机,相关的key或path要如何删除呢?
A:在redis
中设置expire
和etcd
中通过租约
可以达成同样的目的。
代码关键部分:
- 申请一个租约,并对做到两点。
a:租约可进行续约,并且当前函数退出的时候,取消续约;
b:租约可被取消,触发条件为当前函数退出的时候;
// 1: 申请租约
lease = clientv3.NewLease(client)
if leaseGrantResp, err = lease.Grant(context.TODO(), 5); err != nil {
fmt.Println(err)
return
}
leaseId = leaseGrantResp.ID
//创建一个用于取消自动续约的context
ctx, cancelFunc = context.WithCancel(context.TODO())
//确保函数退出后,自动续租会停止
defer cancelFunc()
if keepRespChan, err = lease.KeepAlive(ctx, leaseId); err != nil {
fmt.Println(err)
return
}
go func() {
for {
select {
case keepResp = <-keepRespChan:
if keepResp == nil {
fmt.Println("因为长时间失联等异常情况,租约已过期,无法续约")
goto END
} else {
fmt.Println("续约成功", keepResp.ID)
}
}
}
END:
}()
defer lease.Revoke(context.TODO(), leaseId)
- 基于txn事务进行相关操作
//如key不存在,则说明为新建事务
txnResp, err = txn.If(clientv3.Compare(clientv3.CreateRevision("/cron/lock/job9"), "=", 0)).
Then(clientv3.OpPut("/cron/lock/job9", "", clientv3.WithLease(leaseId))).
Else(clientv3.OpGet("/cron/lock/job9")).Commit()