1. 引言
Lease 顾名思义,client 和 etcd server 之间存在一个约定,内容是 etcd server 保证在约定的有效期内(TTL),不会删除你关联到此 Lease 上的 key-value。如果未在有效期内续租,那么 etcd server 就会删除 Lease 和其关联的 key-value。
2. 使用
租约的使用分为两步:
- 创建lease
- key与lease关联
下面是etcdctl的使用例子
# 创建一个TTL为600秒的lease,etcd server返回LeaseID
$ etcdctl lease grant 600
lease 326975935f48f814 granted with TTL(600s)
# 查看lease的TTL、剩余时间
$ etcdctl lease timetolive 326975935f48f814
lease 326975935f48f814 granted with TTL(600s), remaining(590s)
$ etcdctl put node healthy --lease 326975935f48f818
OK
$ etcdctl get node -w=json | python -m json.tool
{
"kvs":[
{
"create_revision":24,
"key":"bm9kZQ==",
"Lease":3632563850270275608,
"mod_revision":24,
"value":"aGVhbHRoeQ==",
"version":1
}
]
}
3. 源码
Q1: 如何存储一个租约?
每个租约都有一个唯一的ID以确保其唯一性。与租约关联的键(key)使用集合(Set)存储,此外还会记录该租约的相关时间值。这些时间值包括租约的创建时间和过期时间等信息,以便管理和检查租约的有效性。
Q2: 租约的过期淘汰是如何处理的?
所有租约会存储在一个最小堆(Min-Heap)中,其中堆顶元素是最早到期的租约。通过定时任务定期检查堆顶元素,如果发现其已过期,则将其移除。这种方法可以高效地管理大量租约,并确保最先到期的租约会首先被处理。
Q3: 不同节点的租约是如何同步的?
为了保持不同节点间租约状态的一致性,系统会基于租约的剩余时间进行同步。具体做法是每隔一段时间,各个节点之间就会交换一次关于租约剩余时间的信息。这有助于确保即使在网络分区或节点故障的情况下,也能尽量保持数据的一致性和准确性。
Q4: 为什么不用绝对的时间戳?
在分布式系统中,由于时钟漂移的存在,使用绝对时间戳可能会导致不准确的租约管理。例如,不同机器上的时钟可能以不同的速率运行,这会导致租约的过期时间计算出现误差。因此,相对时间(如租约的剩余时间)更适用于分布式环境下的租约管理。
Q5: 只有Leader节点会处理租约吗?
是的,为了简化Lease特性的实现复杂度,所有与租约相关的操作均由Leader节点负责。这包括检查租约是否过期、维护最小堆结构、以及针对过期租约发起撤销(revoke)操作等。这种设计不仅减少了多节点间协调的复杂性,还提高了系统的稳定性和一致性。
3.1. 租约
下图是租约的结构体:
type Lease struct {
ID LeaseID // 租约唯一id
// 当租约被创建,ttl 定义了该租约的总存活时间
ttl int64
// remainingTTL 表示租约的剩余存活时间,可以优化续约和计算逻辑,避免频繁计算租约剩余时间
// 距离最近检查点的,如果remainingTTL为0,则使用ttl
remainingTTL int64
expiryMu sync.RWMutex
expiry time.Time // 表示租约的到期时间,可以被设置为forever
mu sync.RWMutex
itemSet map[LeaseItem]struct{} // 记录绑定了这个租约的key set
revokec chan struct{} // 一个信号,用于租约被撤销
}
type LeaseItem struct {
Key string
}
var(
forever = time.Time{}
)
3.2. 租约队列
LeaseWithTime的time 有两个作用:
到期时间:租约过期的时间点,用于判断何时清理绑定到租约的键值对,对应下面的第一个定时任务revokeExpiredLeases
检查点时间:租约的下一个检查点时间,用于同步剩余时间,对应下面的第二个定时任务checkpointScheduledLeases
type LeaseWithTime struct {
id LeaseID
// time 表示与租约关联的具体时间,可能是以下几种
time time.Time
index int
}
type LeaseQueue []*LeaseWithTime // 使用数组实现的队列,按时间先后排序
// 通知租约到期数据结构,使用堆实现
// 使用heap包,进行优先级排序
type LeaseExpiredNotifier struct {
m map[LeaseID]*LeaseWithTime // 快速判断队列内部是否存在LeaseID
queue LeaseQueue
}
func (mq *LeaseExpiredNotifier) Init() {
heap.Init(&mq.queue)
mq.m = make(map[LeaseID]*LeaseWithTime)
for _, item := range mq.queue {
mq.m[item.id] = item
}
}
3.3. Lessor
Lessor 是 etcd 中的一个核心接口,负责授予、撤销、续约和修改租约,同时可以管理租约的到期和与租约相关的资源,lessor 实现Lessor接口的结构体
type Lessor interface{
...
// Grant 发布一个在 TTL 秒后到期的租约
Grant(id LeaseID, ttl int64) (*Lease, error)
// Revoke 撤销 LeaseID 为ID 的租约,附加到给定租约的项目会被删除
Revoke(id LeaseID) error
// Checkpoint 应用租约的剩余 TTL(remainingTTL)
Checkpoint(id LeaseID, remainingTTL int64) error
// Attach 将给定的租约项附加到指定的租约 ID
Attach(id LeaseID, items []LeaseItem) error
// Detach 从指定的租约 ID 中移除给定的租约项
Detach(id LeaseID, items []LeaseItem) error
// Lookup 查找指定租约 ID 的租约(如果存在)。
Lookup(id LeaseID) *Lease
...
}
type lessor struct {
mu sync.RWMutex
// demotec is set when the lessor is the primary.
// demotec will be closed if the lessor is demoted.
demotec chan struct{}
leaseMap map[LeaseID]*Lease
leaseExpiredNotifier *LeaseExpiredNotifier
leaseCheckpointHeap LeaseQueue
itemMap map[LeaseItem]LeaseID
// When a lease expires, the lessor will delete the
// leased range (or key) by the RangeDeleter.
rd RangeDeleter
// When a lease's deadline should be persisted to preserve the remaining TTL across leader
// elections and restarts, the lessor will checkpoint the lease by the Checkpointer.
cp Checkpointer
// backend to persist leases. We only persist lease ID and expiry for now.
// The leased items can be recovered by iterating all the keys in kv.
b backend.Backend
// minLeaseTTL is the minimum lease TTL that can be granted for a lease. Any
// requests for shorter TTLs are extended to the minimum TTL.
minLeaseTTL int64
// maximum number of leases to revoke per second
leaseRevokeRate int
expiredC chan []*Lease
// stopC is a channel whose closure indicates that the lessor should be stopped.
stopC chan struct{}
// doneC is a channel whose closure indicates that the lessor is stopped.
doneC chan struct{}
lg *zap.Logger
// Wait duration between lease checkpoints.
checkpointInterval time.Duration
// the interval to check if the expired lease is revoked
expiredLeaseRetryInterval time.Duration
// whether lessor should always persist remaining TTL (always enabled in v3.6).
checkpointPersist bool
// cluster is used to adapt lessor logic based on cluster version
cluster cluster
}
3.4. 过期淘汰机制
为了降低 Lease 特性的实现复杂度,检查 Lease 是否过期、维护最小堆、针对过期的 Lease ,发起 revoke 操作,都是 由Leader 节点负责。
在etcd的Lessor模块中,一个异步goroutine负责处理租约过期淘汰和租约同步的任务。此goroutine定期(默认每500毫秒)执行两个关键的定时任务:
- RevokeExpiredLease:负责检查并处理已过期的租约。它会从最小堆中提取那些已经到达其TTL期限的租约,并执行相应的清理操作,包括删除这些租约及其关联的key列表数据。
- CheckpointScheduledLease:负责维持不同节点之间的租约状态一致性。通过同步剩余的TTL信息来避免因本地时钟差异导致的状态不一致问题
在分布式系统中,不同节点的租约(lease)过期时间是基于剩余的TTL(Time To Live)来同步的,而不是依赖于绝对的时间戳。这主要是因为各个节点的本地时钟可能不一致,存在所谓的“时钟漂移”现象,即不同节点的系统时间可能有所差异。如果使用绝对时间戳来同步租约状态,这种时钟漂移可能会导致各节点对租约是否到期的判断出现分歧,进而影响系统的正确性和一致性。
此外,在早期版本的etcd中,为了提高性能,并没有将Lease的剩余TTL信息持久化存储。这意味着在重建过程中,系统会根据配置的TTL重新设置所有租约的有效期,从而无意间为所有租约进行了续期操作。当Leader频繁切换且每次切换的时间间隔短于Lease的TTL时,这会导致租约实际上永远无法到期,造成大量未释放的key堆积,最终可能导致数据库大小超出配额限制等异常情况。
func newLessor(lg *zap.Logger, b backend.Backend, cluster cluster, cfg LessorConfig) *lessor {
...
l := &lessor{
...
}
go l.runLoop() // 对应的异步goroutine
return l
}
func (le *lessor) runLoop() {
defer close(le.doneC)
delayTicker := time.NewTicker(500 * time.Millisecond)
defer delayTicker.Stop()
for {
// 撤销过期的租约
le.revokeExpiredLeases()
// 通过剩余时间
le.checkpointScheduledLeases()
select {
case <-delayTicker.C:
case <-le.stopC:
return
}
}
}
lease 过期判断
func (le *lessor) revokeExpiredLeases() {
var ls []*Lease
// 速率限制,防止拥塞
revokeLimit := le.leaseRevokeRate / 2
le.mu.RLock()
if le.isPrimary() {
// 找到过期的lease,最多revokeLimit个
ls = le.findExpiredLeases(revokeLimit)
}
le.mu.RUnlock()
if len(ls) != 0 {
select {
case <-le.stopC:
return
// 过期的租约会发送到expiredC chan
case le.expiredC <- ls:
default:
// 如果接收端(`expiredC` 的消费者)繁忙,无法立即处理过期租约
// 本次操作被跳过,等待 500 毫秒后重试
}
}
}
func (s *EtcdServer) run() {
...
for {
select {
case ap := <-s.r.apply():
f := schedule.NewJob("server_applyAll", func(context.Context) { s.applyAll(&ep, &ap) })
sched.Schedule(f)
case leases := <-expiredLeaseC:
// 过期租约
s.revokeExpiredLeases(leases)
case err := <-s.errorc:
lg.Warn("server error", zap.Error(err))
lg.Warn("data-dir used by this member must be removed")
return
case <-s.stop:
return
}
}
}
func (s *EtcdServer) revokeExpiredLeases(leases []*lease.Lease) {
...
for _, curLease := range leases {
f := func(lid int64) {
s.GoAttach(func() {
ctx := s.authStore.WithRoot(s.ctx)
// 通过raft 集群同步
_, lerr := s.LeaseRevoke(ctx, &pb.LeaseRevokeRequest{ID: lid})
})
}
f(int64(curLease.ID))
}
}
// 后续会通过apply chan 异步调用LeaseRevoke
// LeaseRevoke 会调用lessor的Revoke 方法
func (a *applierV3backend) LeaseRevoke(lc *pb.LeaseRevokeRequest) (*pb.LeaseRevokeResponse, error) {
err := a.lessor.Revoke(lease.LeaseID(lc.ID))
return &pb.LeaseRevokeResponse{Header: a.newHeader()}, err
}
同步剩余时间
func (le *lessor) checkpointScheduledLeases() {
// rate limit
for i := 0; i < leaseCheckpointRate/2; i++ {
var cps []*pb.LeaseCheckpoint
le.mu.Lock()
if le.isPrimary() {
// 寻找需要执行 checkPoint 的 Lease
cps = le.findDueScheduledCheckpoints(maxLeaseCheckpointBatchSize)
}
le.mu.Unlock()
if len(cps) != 0 {
// cp 是一个接口
if err := le.cp(context.Background(), &pb.LeaseCheckpointRequest{Checkpoints: cps}); err != nil {
return
}
}
if len(cps) < maxLeaseCheckpointBatchSize {
return
}
}
}
func (le *lessor) findDueScheduledCheckpoints(checkpointLimit int) []*pb.LeaseCheckpoint {
if le.cp == nil {
return nil
}
now := time.Now()
var cps []*pb.LeaseCheckpoint
for le.leaseCheckpointHeap.Len() > 0 && len(cps) < checkpointLimit {
lt := le.leaseCheckpointHeap[0]
// 这是一个最小堆,第一个元素的time值就是最小的
// 如果第一个元素的checkpoint time 都没到,则说明该找的都找到了,直接返
if lt.time.After(now) /* lt.time: next checkpoint time */ {
return cps
}
heap.Pop(&le.leaseCheckpointHeap)
var l *Lease
var ok bool
if l, ok = le.leaseMap[lt.id]; !ok {
continue
}
if !now.Before(l.expiry) {
continue
}
remainingTTL := int64(math.Ceil(l.expiry.Sub(now).Seconds()))
if remainingTTL >= l.ttl {
continue
}
if le.lg != nil {
le.lg.Debug("Checkpointing lease",
zap.Int64("leaseID", int64(lt.id)),
zap.Int64("remainingTTL", remainingTTL),
)
}
cps = append(cps, &pb.LeaseCheckpoint{ID: int64(lt.id), Remaining_TTL: remainingTTL})
}
return cps
}
// cp 方法的类型
type Checkpointer func(ctx context.Context, lc *pb.LeaseCheckpointRequest) error
func NewServer(cfg config.ServerConfig) (srv *EtcdServer, err error) {
// 因为该功能对性能有一定影响,目前还是试验性功能,需要通过参数指定开启
if srv.Cfg.EnableLeaseCheckpoint {
srv.lessor.SetCheckpointer(func(ctx context.Context, cp *pb.LeaseCheckpointRequest) {
// cp 方法进一步封装,调用的是raftRequestOnce
srv.raftRequestOnce(ctx, pb.InternalRaftRequest{LeaseCheckpoint: cp})
})
}
}
func (s *EtcdServer) raftRequestOnce(ctx context.Context, r pb.InternalRaftRequest) (proto.Message, error) {
...
// raft 集群同步
result, err := s.processInternalRaftRequestOnce(ctx, r)
if err != nil {
return nil, err
}
return result.resp, nil
}
// server/etcdserver/v3_server.go 642行
// remainingTTL 数据被提交到 Raft 模块,然后同步给 Follower,最后 Apply 到状态机(etcd server),
// 全部执行后,所有的节点都拥有最新 Lease 的 remainingTTL
func (s *EtcdServer) processInternalRaftRequestOnce(ctx context.Context, r pb.InternalRaftRequest) (*applyResult, error) {
// 这里首先获取了本地复制状态机中的 appliedIndex 和 committedIndex
ai := s.getAppliedIndex()
ci := s.getCommittedIndex()
// 如果 committedIndex 远超过 appliedIndex(目前阈值为5000)就会报错,此时不再接受新的提案。
// ErrTooManyRequests = errors.New("etcdserver: too many requests")
if ci > ai+maxGapBetweenApplyAndCommitIndex {
return nil, ErrTooManyRequests
}
r.Header = &pb.RequestHeader{
ID: s.reqIDGen.Next(),
}
// check authinfo if it is not InternalAuthenticateRequest
if r.Authenticate == nil {
authInfo, err := s.AuthInfoFromCtx(ctx)
if err != nil {
return nil, err
}
if authInfo != nil {
r.Header.Username = authInfo.Username
r.Header.AuthRevision = authInfo.Revision
}
}
data, err := r.Marshal()
if err != nil {
return nil, err
}
if len(data) > int(s.Cfg.MaxRequestBytes) {
return nil, ErrRequestTooLarge
}
id := r.ID
if id == 0 {
id = r.Header.ID
}
// 注册提案Id,后续通过chan以异步的方式接受返回值
ch := s.w.Register(id)
cctx, cancel := context.WithTimeout(ctx, s.Cfg.ReqTimeout())
defer cancel()
start := time.Now()
// 到此将这个提案提交到 raft 模块。
err = s.r.Propose(cctx, data)
if err != nil {
proposalsFailed.Inc()
s.w.Trigger(id, nil) // GC wait
return nil, err
}
proposalsPending.Inc()
defer proposalsPending.Dec()
select {
case x := <-ch:
return x.(*applyResult), nil
case <-cctx.Done():
proposalsFailed.Inc()
s.w.Trigger(id, nil) // GC wait
return nil, s.parseProposeCtxErr(cctx.Err(), start)
case <-s.done:
return nil, ErrStopped
}
}
节点的崩溃恢复,对应的租约是使用了RemainingTTL
func newLessor(lg *zap.Logger, b backend.Backend, cluster cluster, cfg LessorConfig) *lessor {
l := &lessor{}
// 从 blotdb 中加载lease数据并在内存中重建
l.initAndRecover()
}
func (le *lessor) initAndRecover() {
tx := le.b.BatchTx()
tx.Lock()
schema.UnsafeCreateLeaseBucket(tx)
lpbs := schema.MustUnsafeGetAllLeases(tx)
tx.Unlock()
for _, lpb := range lpbs {
ID := LeaseID(lpb.ID)
if lpb.TTL < le.minLeaseTTL {
lpb.TTL = le.minLeaseTTL
}
le.leaseMap[ID] = &Lease{
ID: ID,
ttl: lpb.TTL,
itemSet: make(map[LeaseItem]struct{}),
expiry: forever,
revokec: make(chan struct{}),
remainingTTL: lpb.RemainingTTL, // 重建时同样带上了 RemainingTTL
}
}
le.leaseExpiredNotifier.Init()
heap.Init(&le.leaseCheckpointHeap)
le.b.ForceCommit()
}
3.5. grant
Grant 接口会把 Lease 保存到内存的 ItemMap 数据结构中,然后它需要持久化 Lease,将 Lease 数据保存到 boltdb 的 Lease bucket 中,返回一个唯一的 LeaseID 给 client
func (le *lessor) Grant(id LeaseID, ttl int64) (*Lease, error) {
// 条件校验: id 不为 NoLease , ttl 小于 MaxLeaseTTL , lease id 没有被创建过
...
l := NewLease(id, ttl)
le.mu.Lock()
defer le.mu.Unlock()
if _, ok := le.leaseMap[id]; ok {
return nil, ErrLeaseExists
}
if l.ttl < le.minLeaseTTL {
l.ttl = le.minLeaseTTL
}
if le.isPrimary() {
l.refresh(0)
} else {
l.forever()
}
// 将新建的 lease 存入 lessor 模块中,一个 map 结构
le.leaseMap[id] = l
// 持久化到blotdb中
l.persistTo(le.b)
// 这些是监控相关的
leaseTotalTTLs.Observe(float64(l.ttl))
leaseGranted.Inc()
if le.isPrimary() {
// 对应上面的两个定时任务堆
item := &LeaseWithTime{id: l.ID, time: l.expiry}
le.leaseExpiredNotifier.RegisterOrUpdate(item)
le.scheduleCheckpointIfNeeded(l) // 加入到 leaseCheckpointHeap
}
return l, nil
}
3.6. revoke
func (le *lessor) Revoke(id LeaseID) error {
le.mu.Lock()
l := le.leaseMap[id]
if l == nil {
le.mu.Unlock()
return ErrLeaseNotFound
}
defer close(l.revokec)
// unlock before doing external work
le.mu.Unlock()
if le.rd == nil {
return nil
}
txn := le.rd()
// sort keys so deletes are in same order among all members,
// otherwise the backend hashes will be different
keys := l.Keys()
sort.StringSlice(keys).Sort()
for _, key := range keys {
txn.DeleteRange([]byte(key), nil)
}
le.mu.Lock()
defer le.mu.Unlock()
delete(le.leaseMap, l.ID)
// lease deletion needs to be in the same backend transaction with the
// kv deletion. Or we might end up with not executing the revoke or not
// deleting the keys if etcdserver fails in between.
schema.UnsafeDeleteLease(le.b.BatchTx(), &leasepb.Lease{ID: int64(l.ID)})
txn.End()
leaseRevoked.Inc()
return nil
}
3.7. key 与 lease 关联
// server/lease/lessor.go 546行
func (le *lessor) Attach(id LeaseID, items []LeaseItem) error {
le.mu.Lock()
defer le.mu.Unlock()
l := le.leaseMap[id]
if l == nil {
return ErrLeaseNotFound
}
l.mu.Lock()
for _, it := range items {
l.itemSet[it] = struct{}{}
le.itemMap[it] = id
}
l.mu.Unlock()
return nil
}
// 有 Attach 自然就会有 Detach
// server/lease/lessor.go 573行
func (le *lessor) Detach(id LeaseID, items []LeaseItem) error {
le.mu.Lock()
defer le.mu.Unlock()
l := le.leaseMap[id]
if l == nil {
return ErrLeaseNotFound
}
l.mu.Lock()
for _, it := range items {
delete(l.itemSet, it)
delete(le.itemMap, it)
}
l.mu.Unlock()
return nil
}