坚持思考,就会很酷
在 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 中,由一个叫做 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 );
a . 对应写到“lease” 这个桶里;
4 . 设置 Lease.expiry ,这里是设置为 now + ttl 的时间,是未来超时的那个时刻;
5 . 构建一个 LeaseWithTime 的结构体,加入到 heap 里面去管理;
a . 加到 leaseExpiredNotifier 里面,关联超时机制;
b . 加到 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.LeaseItem{{Key: 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 结构,走销毁流程,过期销毁主要做两件事:
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.LeaseItem{{Key: key}})
}
}
做的事情很简单,就是在进程重启的时候,需要加载分析所有的 key ,把那些跟 Lease 关联的 key 捞出来,然后跟 Lease 关联起来。
小知识点:lessor 结构体要先于此创建出来。
租约妙 用多多,童鞋你用过哪些呢?
~完~
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8