云原生 etcd 系列-6|租约机制

Posted 琦彦

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了云原生 etcd 系列-6|租约机制相关的知识,希望对你有一定的参考价值。

云原生 etcd 系列-6|用“租约”给 key 加一个期限!

什么是租约 ?

在 redis 中有一个 ttl 的功能。ttl 是 time to live 的缩写。在 redis 里我们可以设置 key 的 ttl ,从而指定这个 key 存活的时间,过期就会自动销毁。

在 etcd 也有一个类似的机制:租约( Lease )机制。从效果上来讲,租约机制也能做到类似的过期自动删除 key 的功能。但是两者细节大有不同。

租约( Lease )是什么?

简单讲就是一个具有一个时间期限的“对象”

划重点:时间期限。

举个不准确的栗子:

有一个大公司(代表一个中心权威组织,比如 etcd )有个粗活,并且工作特殊明确只需要一个程序猿( worker )。

那一般怎么操作呢?

有个程序猿 A 想做这个事,来找公司申请,于是公司给了他一个 3 天期限的租约( Lease ),并承诺该权限 3 天内不会再给这个权限给别人,但是 3 天之后,公司就可以另寻他猿了( 注意:如果猿 A 在 3 天内续约了,A 就可以延续他的权限了,那就是另外一回事了 )。

这样的话,就能保证始终只有一个猿有合法权限做这件事。 如果猿 A 三天后不抗压、失联了,那 3 天之后公司也能安全的(没有违反承诺)再找一只猿。

划重点:租约就是一个带时间期限的承诺。

怎么使用租约?

和 redis 不同,etcd 中把这个时间相关的概念抽离出来,命名为 Lease 对象。所以,要使用租约则先要创建这么一个 Lease 对象。然后把 key 绑定到这个 Lease 上,就相当于设置了这个 key 的生命周期。

细节来了,key 和 Lease 是怎么对应的?

划重点:key 和 Lease 是多对一的关系。一个 key 最多只能挂绑定一个 Lease ,但是一个 Lease 上能挂多个 key 。 这种设计提高 etcd 整体的性能。Lease 刷新一次就对应了一批的 key ,否则每一个 key 都独立刷新 ttl 的话,开销可不小呢。

举个 etcd 实际的栗子,怎么设置一个 60 秒有效的 key ?如下:

先创建一个 Lease 对象:

root@ubuntu:~/# etcdctl lease grant 60

lease 694d7d17eaab280f granted with TTL(60s)

再把一个 key 绑定到这个 Lease :

root@ubuntu:~/# etcdctl put hello world --lease=694d7d17eaab280f

OK

这样 hello/world 这对 key/value 就创建好了,并且 60 秒后将被自动删除。

租约机制的适用场景 ?

有些童鞋可能会好奇,租约一般用来做什么呢?

就本质上来讲,租约就是一个具有生命周期的对象。怎么使用它?这依赖于用户的想象力。

曾经我在分布式的分享章节里提到过,lease 是分布式的基石技术之一,lease 就是一个有时间限定的权限(承诺)。分布式的冗余节点都可以来申请一段时间的权限(有了这个权限就可以做某件事情),租约过期之后就可以回收,租约没过期之前就维持承诺。这个租约的管理一般放在一个中心化的节点(或者集群中),比如 etcd 集群。

为什么申请的权限一定要附上时间期限呢?

因为在分布式的恶劣环境下,谁都有可能挂。挂了的话,冗余节点要能顶上来,这个权限要能安全移交。租约没过期之前,权限的移交都是不安全的。租约过期之后,权限就能安全移交。所以,租约常常用在恶劣的分布式系统中做可靠的授权管理

还一个 etcd 最常见的场景是当作注册中心来用,worker 节点注册到 etcd 集群。每个都申请了租约,并且定期的会续约( keep-alive 保活),一旦长久失效,那么就可以剔除。这样起到一个节点的管理之用。

etcd 的租约原理

下面从 etcd 内部的实现原理出发,来看租约机制的核心知识点。在 etcd 中,由一个叫做 lessor 的对象来管理租约,并且关于续租等等操作都必须要是 leader 才能操作。

1 租约的创建

租约的创建必须走 raft 状态机,把 Lease 创建这个消息在集群中达到一致,达到一致之后,每个节点就可以构建 Lease 结构体,并且持久化这个结构体到 boltdb 中,存储在一个叫做 “lease” 的 bucket 中

func (le *lessor) Grant(id LeaseID, ttl int64) (*Lease, error) 
    // 构造一个 Lease 结构体
    l := &Lease
        ID:      id,
        ttl:     ttl,
        // ..
    
    // 设置 expire time( primary 可做)
    l.refresh(0)
    // Lease 在内存的 map 里也放一份,好索引呀
    le.leaseMap[id] = l
    // 持久化到 boltdb 里去
    l.persistTo(le.b)
    // 投递到一个带小堆的队列中,这个关联超时机制(primary 可做)
    le.leaseExpiredNotifier.RegisterOrUpdate(item)
    // 投递到 checkpoint 的队列中,这个关联 checkpoint 机制(primary 可做)
    le.scheduleCheckpointIfNeeded(l)

租约创建很简单,最关键的是先要走 raft 机制,然后走上面的 grant 流程,传入一个 LeaseID,一个 ttl ,持久化到 boltdb 并修改内存结构,主要步骤:

  1. 构建一个 Lease 结构体;

  2. 修改 leaseMap,id => lease ;

  3. 持久化,把 Lease 这个结构体写到磁盘( boltdb );

    1. 对应写到“lease” 这个桶里;
  4. 设置 Lease.expiry ,这里是设置为 now + ttl 的时间,是未来超时的那个时刻;

  5. 构建一个 LeaseWithTime 的结构体,加入到 heap 里面去管理;

    1. 加到 leaseExpiredNotifier 里面,关联超时机制;
    2. 加到 leaseCheckpointHeap 里面,关联 checkpoint 机制;

划重点:Lease 的创建是要持久化的,并且是先走 raft 的状态机在 etcd 集群达到一致后,才持久化到 boltdb 中。

2 租约的绑定

key 是怎么绑定到 Lease 的呢?这是一个非常关键的问题。

时机肯定在 key/value 上传的时候,也就是 put 的时候,位于 storeTxnWrite.put 方法之中:

// etcd/mvcc/kvstore_txn.go
func (tw *storeTxnWrite) put(key, value []byte, leaseID lease.LeaseID) 
    // ...
    // 存储到 bolt db 文件
    tw.tx.UnsafeSeqPut(keyBucketName, ibytes, d)
    // ...
    // 如果 leaseID 有效,那么说明要绑定 Lease 了
    if leaseID != lease.NoLease 
        // LeaseID 和 key 关联起来
        err = tw.s.le.Attach(leaseID, []lease.LeaseItemKey: string(key))
    

划重点:数据持久化到 boltdb 之后,再去关联对应的 Lease 结构体。 那关联是什么操作呢?很简单,就是把这个 key 加到 Lease 内部的 map 中:

// etcd/lease/lessor.go
func (le *lessor) Attach(id LeaseID, items []LeaseItem) error 
    for _, it := range items 
        // 把这个 key 放到 Lease 结构体里
        l.itemSet[it] = struct
        // 把这个 key 放到 lessor 的结构体里,这里作为一个平坦的 map
        le.itemMap[it] = id
    

跟这个 Lease 关联的所有 key 都在 Lease.itemSet 这个 map 中。

3 租约的过期

租约的过期和销毁是 etcd 内部的流程触发。租约的过期在创建的时候就关联上了,还记得创建的时候有一个加队列的代码吗?

// 投递到一个带小堆的队列中,这个关联超时机制(primary 可做)
le.leaseExpiredNotifier.RegisterOrUpdate(item)

这行代码把 Lease 加到一个内含最小堆的结构中。每次都看小堆顶即可(因为它生命剩余最小,最有可能超时),小堆顶的 Lease 超时了,那么就取出来,直到取到没超时的 Lease ,那么本轮结束。

// 取出一个超时的 Lease 结构,它上面可能有一批的 key 
func (le *lessor) expireExists() (l *Lease, ok bool, next bool) 
    // 取小堆顶
    item := le.leaseExpiredNotifier.Poll()
    now := time.Now()
    // 看是否超时
    if now.UnixNano() < item.time /* expiration time */ 
        return l, false, false
    

这样每次都处理一批超时的 Lease 结构,走销毁流程,过期销毁主要做两件事:

  1. 销毁 Lease 本身;
  2. 销毁 Lease 关联的 key/value 键值对 ;

key 被销毁之后就相当于被自动删除了,用户就下载不到了。 销毁的流程在 lessor.Revoke :

func (le *lessor) Revoke(id LeaseID) error 
    // 遍历删除这个 Lease 关联的所有 key (从 boltdb 里删除)
    for _, key := range keys 
        txn.DeleteRange([]byte(key), nil)
    
    // 销毁内存结构
    delete(le.leaseMap, l.ID)
    // 把这个 Lease 从 boltdb 的 lease 桶里删除
    le.b.BatchTx().UnsafeDelete(leaseBucketName, int64ToBytes(int64(l.ID)))

划重点:Lease 的销毁不仅是内存的,还有 boltdb 的 lease 桶里的都要清理。是设计到持久化的。

4 租约的续租

续租需要由 leader 完成,但是 etcd 的续租并没有走 raft 在集群中达到一致性。它仅仅是在内存中修改过期时间。

func (le *lessor) Renew(id LeaseID) (int64, error) 
    // 必须要 leader 节点才能做这个事情
    if !le.isPrimary() 
        return -1, ErrNotPrimary
    
    // ...
    // 重置超时时间
    l.refresh(0)
    // 更新小堆的里面对应的元素
    le.leaseExpiredNotifier.RegisterOrUpdate(item)

可以看到,在 leader 节点里对于续租做的事情很简单,就是刷新过期( expiry )时间,并且刷新最小堆的元素,这样它就相当于续命了,不会超时啦。

划重点:续租没啥持久化的。

5 租约的 CheckPoint 机制

在上面我们看到, Lease 创建和销毁是涉及到持久化的,对于续租则存储是内存操作。那这里在集群异常的场景可能导致一个不准确的问题。

比如 Lease 是 300 秒,已经过去 100 秒,突然切主。那起来的时候 Lease 就不知道多少了?

对于这个 etcd 有一个 checkpoint 机制,这个机制本质上就是定期让 leader 看一眼剩余的 ttl 还有多少,然后同步给集群其他节点,以此为准。

所以,CheckPoint 要走 raft 状态机。

6 租约加载恢复

在 storeTxnWrite.put 里面我们看到了 key 上传的时候会和 Lease 关联,其实还有一个时机:etcd 进程启动的时候。

func (s *store) restore() error 
   // 遍历 bucket 里面所有的 key ,把所有跟 Lease 关联的 key 放到 keyToLease
    
    // 遍历这些和有 Lease 关联的 key
    for key, lid := range keyToLease 
        // Lease ID 和 key 关联起来
        err := s.le.Attach(lid, []lease.LeaseItemKey: key)
    

做的事情很简单,就是在进程重启的时候,需要加载分析所有的 key ,把那些跟 Lease 关联的 key 捞出来,然后跟 Lease 关联起来。

小知识点:lessor 结构体要先于此创建出来。

总结

  1. 租约是一个有时间期限的承诺
  2. Lease 是一个独立的结构对象,它具有一段指定的生命期限;
  3. key 绑定到 Lease 上就能实现 ttl 自动删除的功能;
  4. key 和 Lease 是多对一的关系,这种设计能够提升 etcd 的内部性能,并且具有相当的灵活性;
  5. Lease 的创建( Grant )和销毁( Revoke )都要持久化( 到 boltdb ),这之前要走 raft 状态机( 涉及到 wal 的持久化 );
  6. Lease 结构体数据存储在 boltdb 中一个叫做 “lease” 的 bucket 中;
  7. Lease 的 CheckPoint 会走 raft 状态机,但是不会持久化到后端 boltdb
  8. Lease 机制要慎用( 明确自己场景 ),因为它并没有严格保证 ttl 的时间准确的增减

以上是关于云原生 etcd 系列-6|租约机制的主要内容,如果未能解决你的问题,请参考以下文章

go任务调度6(etcd租约机制/自动过期)

云原生 etcd 系列-7|深入剖析数据多版本 MVCC 机制

云原生 etcd 系列-1|为什么值得学习?

深入浅出etcd系列 – 心跳和选举

etcd 租约Watch功能分布式锁的golang实践

云原生训练营模块五 Kubernetes 控制平面组件:etcd