深入理解etcd(一) --- lease 是如何实现?

181 阅读11分钟

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毫秒)执行两个关键的定时任务:

  1. RevokeExpiredLease:负责检查并处理已过期的租约。它会从最小堆中提取那些已经到达其TTL期限的租约,并执行相应的清理操作,包括删除这些租约及其关联的key列表数据。
  2. 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
}