用索引堆检查租约到期,并重试尚未完成的撤销
etcd 用按检查时间排序的小顶堆寻找待检查租约,用租约 ID 索引直接定位堆元素;到期检测只发起撤销,实际删除还要经过一致性处理。
工程问题#
大量租约不断续期,逐轮扫描全部租约代价高;为每个租约独立创建定时器也会增加管理开销。同时,发现到期不等于撤销已完成,通知拥塞、领导者变更或请求失败都可能延迟删除。
实际实现#
LeaseExpiredNotifier 同时维护最小堆和 ID 到堆元素的 map,元素保存堆下标,Swap 时同步下标。续租刷新 expiry 后,以 map 找到旧元素并 heap.Fix。lessor 每轮查看堆顶:未到检查时间就停止;已经移除的租约清出堆;到检查时间的租约先把下次检查时间推迟,再核验租约自身是否过期并加入通知批次。服务器收到批次后调用 LeaseRevoke,经 Raft 请求处理撤销,因此检查时间与最终删除时间是两个不同阶段。
处理流程#
- 授予或续租后,注册或更新租约的堆内检查时间
- 主 lessor 周期性查看最早的检查项,不扫描所有未到期租约
- 清除已不存在的租约项;对到时的项登记下一次重试时间
- 检查实际租约 expiry,把真正过期的租约加入受批次限制的通知
- 服务器确认领导权并发起 LeaseRevoke,撤销完成后再清理后续检查项
源码片段#
固定版本 · 原文摘录35func (pq LeaseQueue) Less(i, j int) bool {36 return pq[i].time.Before(pq[j].time)37}3839func (pq LeaseQueue) Swap(i, j int) {40 pq[i], pq[j] = pq[j], pq[i]41 pq[i].index = i42 pq[j].index = j43}4445func (pq *LeaseQueue) Push(x interface{}) {46 n := len(*pq)47 item := x.(*LeaseWithTime)48 item.index = n49 *pq = append(*pq, item)Less 以时间排序;Swap 在交换节点后更新 index,供后续 heap.Fix 直接定位。Push 是 Go heap.Interface 的底层操作,完整堆维护由 container/heap 完成。
83func (mq *LeaseExpiredNotifier) RegisterOrUpdate(item *LeaseWithTime) {84 if old, ok := mq.m[item.id]; ok {85 old.time = item.time86 heap.Fix(&mq.queue, old.index)87 } else {88 heap.Push(&mq.queue, item)89 mq.m[item.id] = item90 }91}9293func (mq *LeaseExpiredNotifier) Unregister() *LeaseWithTime {94 item := heap.Pop(&mq.queue).(*LeaseWithTime)95 delete(mq.m, item.id)96 return item97}9899func (mq *LeaseExpiredNotifier) Poll() *LeaseWithTime {100 if mq.Len() == 0 {101 return nil102 }103 return mq.queue[0]104}map 找到旧项后只修改时间并修复堆,不为同一租约积累多个检查记录。Poll 只查看最早项,Unregister 才弹出并删除索引。
700 item := le.leaseExpiredNotifier.Poll()701 l = le.leaseMap[item.id]702 if l == nil {703 // lease has expired or been revoked704 // no need to revoke (nothing is expiry)705 le.leaseExpiredNotifier.Unregister() // O(log N)706 return nil, false, true707 }708 now := time.Now()709 if now.Before(item.time) /* item.time: expiration time */ {710 // Candidate expirations are caught up, reinsert this item711 // and no need to revoke (nothing is expiry)712 return l, false, false713 }714715 // recheck if revoke is complete after retry interval716 item.time = now.Add(le.expiredLeaseRetryInterval)717 le.leaseExpiredNotifier.RegisterOrUpdate(item)718 return l, true, false不存在的租约从堆中清除;未来项使本轮停止;到时项先推迟下次检查,防止尚未完成的撤销在同轮立即反复触发。
726 for {727 l, ok, next := le.expireExists()728 if !ok && !next {729 break730 }731 if !ok {732 continue733 }734 if next {735 continue736 }737738 if l.expired() {739 leases = append(leases, l)740741 // reach expired limit742 if len(leases) == limit {743 break744 }745 }堆中到时表示需要检查,最终是否进入撤销批次还要调用 l.expired。批次限制统计真正过期的租约数,而非所有访问的堆项。
1182 // Increases throughput of expired leases deletion process through parallelization1183 c := make(chan struct{}, maxPendingRevokes)1184 for _, curLease := range leases {1185 select {1186 case c <- struct{}{}:1187 case <-s.stopping:1188 return1189 }11901191 f := func(lid int64) {1192 s.GoAttach(func() {1193 ctx := s.authStore.WithRoot(s.ctx)1194 _, lerr := s.LeaseRevoke(ctx, &pb.LeaseRevokeRequest{ID: lid})1195 if lerr == nil {1196 leaseExpired.Inc()1197 } else {1198 lg.Warn(1199 "failed to revoke lease",1200 zap.String("lease-id", fmt.Sprintf("%016x", lid)),1201 zap.Error(lerr),1202 )1203 }服务器使用有界并发处理批次,并调用 LeaseRevoke。错误被记录,检查堆保留的后续重试仍有机会重新通知。
290func (s *EtcdServer) LeaseRevoke(ctx context.Context, r *pb.LeaseRevokeRequest) (*pb.LeaseRevokeResponse, error) {291 resp, err := s.raftRequestOnce(ctx, pb.InternalRaftRequest{LeaseRevoke: r})292 if err != nil {293 return nil, err294 }295 return resp.(*pb.LeaseRevokeResponse), nil296}堆顶检测没有直接删除键。真正的撤销请求被提交到 Raft 处理路径,完成时刻受共识和应用进度影响。
六段原文覆盖索引堆、检查项更新、候选判断、实际 expiry 核验以及撤销请求入口;周期调度、续租和领导权检查见补充来源。
收益与取舍#
工程分析带来的收益
- 查看最早检查项为 O(1),借助 ID 索引更新堆项为 O(log N),避免续租时线性寻找租约
- 只保存每个租约的一条检查项,重复续租不会不断积累过期定时任务
- 撤销未完成时保留重试机会,通知通道暂时忙也不会直接丢弃该租约
付出的代价
- 索引 map 和堆下标必须同步维护,空间为 O(N);比只追加定时记录的实现更复杂
- 每轮实际工作取决于到期候选和陈旧项数量;撤销批次上限不等于严格限制本轮检查的堆项总数
- 500 ms 的循环等待、批次限制、重试间隔及 Raft 提交都会带来延迟;TTL 到点不保证关联键在同一时刻立刻删除
可比较的选择
定期扫描全部租约实现直接,但即使几乎没有到期租约,每轮仍需检查 O(N) 个对象。
时间轮大量粗粒度定时任务可减少堆维护,但需要选择槽宽、处理跨轮长 TTL 与续租迁移;精度和批处理行为不同。
版本与适用范围#
固定 etcd v3.5.17 的 lease lessor,分析到期检查堆,不混同另一个 lease checkpoint 堆。最初堆时间对应 expiry;触发检查后也会被改为重试时刻,所以不是所有时候都等于租约的真实到期时间。撤销通知仅由 primary 发出,最终删除由服务器的 Raft 请求与应用路径完成。
参考来源#
源码、官方文档与原始论文server/lease/lease_queue.go · LeaseExpiredNotifier · 定位、修复和查看堆顶
server/lease/lessor.go · findExpiredLeases · 核验实际 expiry
server/etcdserver/server.go · revokeExpiredLeases · 发起撤销
server/etcdserver/v3_server.go · LeaseRevoke · 进入 Raft 请求路径
server/lease/lessor.go · runLoop / revokeExpiredLeases · 周期、primary 与通道背压
server/etcdserver/server.go · revokeExpiredLeases · 确认领导权
相关实现与比较#
4 个相关案例QEMU TCG 用 QHT 保存 TranslationBlock 索引,帮助复用与当前客体执行状态一致的翻译结果。
libvirt 为 Domain 对象维护 UUID 与名称两个哈希索引,两个入口指向同一个对象。
SwarmKit 的调度决策树在叶子中使用 nodeMaxHeap,把候选节点整理为从优到劣的顺序。
把上下文身份编码进块哈希,让重复请求共享已经计算的 KV cache。