租约是什么

我们都知道Redis可以通过expire命令对key设置过期时间,来实现缓存的ttl,etcd同样有一种特性可以对key设置过期时间,也就是租约(Lease)。不过相较来说,两者的适用场景并不相同,etcd的Lease广泛的用在服务注册与保活上,redis则主要用于淘汰缓存。下面介绍一下etcd的Lease机制,会从使用方式,以及实现原理来逐步探究。

使用方式

首先通过一个案例简单介绍它的使用方式。

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"
​
    clientv3 "go.etcd.io/etcd/client/v3"
)
​
func main() {
​
    key := "linugo-lease"
​
    cli, err := clientv3.New(clientv3.Config{
        Endpoints:   []string{"127.0.0.1:23790"},
        DialTimeout: time.Second,
    })
    if err != nil {
        log.Fatal("new client err: ", err)
    }
​
    //首先创建一个Lease并通过Grant方法申请一个租约,设置ttl为20秒,没有续约的话,该租约会在20s后消失
    ls := clientv3.NewLease(cli)
    grantResp, err := ls.Grant(context.TODO(), 20)
    if err != nil {
        log.Fatal("grant err: ", err)
    }
    log.Printf("grant id: %x\n", grantResp.ID)
​
    //接下来插入一个键值对绑定该租约,该键值对会随着租约的到期而相应被删除
    putResp, err := cli.Put(context.TODO(), key, "value", clientv3.WithLease(grantResp.ID))
    if err != nil {
        log.Fatal("put err: ", err)
    }
​
    log.Printf("create version: %v\n", putResp.Header.Revision)
    //通过KeepAliveOnce方法对该租约进行续期,每隔5s会将该租约续期到初始的20s
    go func() {
        for {
            time.Sleep(time.Second * 5)
            resp, err := ls.KeepAliveOnce(context.TODO(), grantResp.ID)
            if err != nil {
                log.Println("keep alive once err: ", err)
                break
            }
            log.Println("keep alive: ", resp.TTL)
        }
    }()
​
    sigC := make(chan os.Signal, 1)
    signal.Notify(sigC, os.Interrupt, syscall.SIGTERM)
    s := <-sigC
    log.Println("exit with: ", s.String())
}

我们可以通过上述方式实现某个服务模块的保活,可以将节点的地址注册到etcd中,并绑定适当时长的租约,定时进行续约操作,若节点宕机,超过了租约时长,etcd中该节点的信息就会被移除掉,实现服务的自动摘除,通常配合etcd的watch特性来做到实时的感知。
v3版的客户端接口除了上述的Grant,KeepAliveOnce方法,还包括了一些其他重要的方法如Revoke删除某个租约,TimeToLive查看某个租约剩余时长等。
etcd服务端面向租约对客户端服务的有5个接口,分别对client端的方法给予了实现。本次主要对服务端的实现方法进行分析。

type LeaseServer interface {
    //对应客户端的Grant方法,创建租约
    LeaseGrant(context.Context, *LeaseGrantRequest) (*LeaseGrantResponse, error)
    //删除某个租约
    LeaseRevoke(context.Context, *LeaseRevokeRequest) (*LeaseRevokeResponse, error)
    //租约某个续期
    LeaseKeepAlive(Lease_LeaseKeepAliveServer) error
    //租约剩余时长查询
    LeaseTimeToLive(context.Context, *LeaseTimeToLiveRequest) (*LeaseTimeToLiveResponse, error)
    //查看所有租约
    LeaseLeases(context.Context, *LeaseLeasesRequest) (*LeaseLeasesResponse, error)
}

初始化

在etcd启动时候,会初始化一个lessor,lessor内部存储了所有有关租约的信息,包括租约ID,到期时间,租约绑定的键值对等;lessor实现了一系列接口,是租约功能的具体实现逻辑,包括Grant(创建),Revoke(撤销),Renew(续租)等。

type lessor struct {
    mu sync.RWMutex
    demotec chan struct{}
    //存放所有有效的lease信息,key为leaseID,value包括该租约的ID,ttl,lease绑定的key等信息
    leaseMap             map[LeaseID]*Lease
    //便于查找lease的一个数据结构,基于最小堆实现,可以将快到期的租约放到队头,检查是否过期时候,只需要检查队头即可
    leaseExpiredNotifier *LeaseExpiredNotifier
    //用于实时更新lease的剩余时间
    leaseCheckpointHeap  LeaseQueue
    //用户存放的key与lease的绑定关系,通过key可以找到租约
    itemMap              map[LeaseItem]LeaseID
    ......
    //过期的lease会被放到该chan中,被消费者清理
    expiredC chan []*Lease
    ......
}

在lessor被初始化后,同时会启动一个goroutine,用于频繁的检查是否有过期的lease以及更新lease剩余时间。lease的这些检查是集群的leader节点做的,包括更新剩余的时间,维护lease的最小堆,到期时候撤销lease。而follower节点只用于响应leader节点的存储、更新或撤销lease请求。

func (le *lessor) runLoop() {
    defer close(le.doneC)
    for {
        //检查是否有过期的lease
        le.revokeExpiredLeases()
        //checkpoint机制检查并更新lease的剩余时间
        le.checkpointScheduledLeases()
        //每500毫秒检查一次
        select {
        case <-time.After(500 * time.Millisecond):
        case <-le.stopC:
            return
        }
    }
}

为了涵盖大部分场景,我们假设一个三节点的etcd集群的场景,通过上面的案例代码对其中的一个follower节点发起请求。

创建

当v3客户端调用Grant方法时候,会对应到server端LeaseServer的LeaseGrant方法,该方法会经过一系列的中间步骤(鉴权等)到达etcdServer包装实现的LeaseGrant方法,该方法会调用raft模块并封装一个Lease的提案并进行数据同步流程。由于此时节点是follower,会将请求转交给leader进行处理,leader接到请求后会将该提案封装成一个日志,并广播到follower节点,follower节点执行提案消息,并回复给leader节点。

image.png

在follower节点执行提案内容时候,会解析出该请求是一个创建lease的请求,该流程是在apply模块执行的。apply模块会调用自己包装好的LeaseGrant方法。

func (a *applierV3backend) Apply(r *pb.InternalRaftRequest, shouldApplyV3 membership.ShouldApplyV3) *applyResult {
    op := "unknown"
    ar := &applyResult{}
    ......
​
    // call into a.s.applyV3.F instead of a.F so upper appliers can check individual calls
    switch {
    ......
    case r.LeaseGrant != nil:
        op = "LeaseGrant"
        ar.resp, ar.err = a.s.applyV3.LeaseGrant(r.LeaseGrant)
    ......
    default:
        a.s.lg.Panic("not implemented apply", zap.Stringer("raft-request", r))
    }
    return ar
}

LeaseGrant方法是对lessor实现的Grant方法的进一步封装。

func (a *applierV3backend) LeaseGrant(lc *pb.LeaseGrantRequest) (*pb.LeaseGrantResponse, error) {
    l, err := a.s.lessor.Grant(lease.LeaseID(lc.ID), lc.TTL)
    resp := &pb.LeaseGrantResponse{}
    if err == nil {
        resp.ID = int64(l.ID)
        resp.TTL = l.TTL()
        resp.Header = newHeader(a.s)
    }
    return resp, err
}
lessor通过Grant方法将lease封装并存入到自己的leaseMap,并经lease持久化到boltdb。
func (le *lessor) Grant(id LeaseID, ttl int64) (*Lease, error) {
    ......
    //封装lease
    l := &Lease{
        ID:      id,
        ttl:     ttl,
         //用于存放该lease绑定的key,用于在lease过期时删除key
        itemSet: make(map[LeaseItem]struct{}),
        revokec: make(chan struct{}),
    }
    ......
    //如果是leader节点,则刷新lease的到期时间
    if le.isPrimary() {
        l.refresh(0)
    } else {
        //follower节点中没有存储lease的到期时间
        l.forever()
    }
​
    le.leaseMap[id] = l
    //lease信息持久化
    l.persistTo(le.b)
    //如果是leader节点,就将lease信息放到最小堆中
    if le.isPrimary() {
        item := &LeaseWithTime{id: l.ID, time: l.expiry}
        le.leaseExpiredNotifier.RegisterOrUpdate(item)
        le.scheduleCheckpointIfNeeded(l)
    }
​
    return l, nil
}

绑定

lease创建好之后,就可以通过Put指令创建一个数据并与lease进行绑定。在Put时候,put的value字段中会有一个leaseID,并存到了boltDB。这样可以在etcd挂掉之后,可以根据持久化存储来恢复lease与数据的对应关系。

func (tw *storeTxnWrite) put(key, value []byte, leaseID lease.LeaseID) {
    ......
    kv := mvccpb.KeyValue{
        Key:            key,
        Value:          value,
        CreateRevision: c,
        ModRevision:    rev,
        Version:        ver,
        //lease字段
        Lease:          int64(leaseID),
    }
    //....持久化等操作
    //attach操作
    if leaseID != lease.NoLease {
        if tw.s.le == nil {
            panic("no lessor to attach lease")
        }
        err = tw.s.le.Attach(leaseID, []lease.LeaseItem{{Key: string(key)}})
        if err != nil {
            panic("unexpected error from lease Attach")
        }
    }
    tw.trace.Step("attach lease to kv pair")
}

lessor的Attach操作会将lease与key两者进行绑定并存到自身的itemMap以及lease的itemSet中。

func (le *lessor) Attach(id LeaseID, items []LeaseItem) error {
    ......
    l := le.leaseMap[id]
    l.mu.Lock()
    for _, it := range items {
        //存到lease的itemSet
        l.itemSet[it] = struct{}{}
        //存到lessor的itemMap中
        le.itemMap[it] = id
    }
    l.mu.Unlock()
    return nil
}

保活

客户端提供的keepAlive方法用于lease进行续租,每次调用都会使得lease的剩余时间回到初始化时候设定的剩余时间。由于lease的一些检查以及维护都是由leader节点维持,所以当我们发送请求到follower时,会直接将请求重定向到leader节点。

func (s *EtcdServer) LeaseRenew(ctx context.Context, id lease.LeaseID) (int64, error) {
    //发送到follower会返回ErrNotPrimary的错误
    ttl, err := s.lessor.Renew(id)
    if err == nil { // already requested to primary lessor(leader)
        return ttl, nil
    }
    ......
    for cctx.Err() == nil && err != nil {
        //获取leader节点
        leader, lerr := s.waitLeader(cctx)
        if lerr != nil {
            return -1, lerr
        }
        for _, url := range leader.PeerURLs {
            lurl := url + leasehttp.LeasePrefix
             //通过http接口请求到leader的keeplaive接口
            ttl, err = leasehttp.RenewHTTP(cctx, id, lurl, s.peerRt)
            if err == nil || err == lease.ErrLeaseNotFound {
                return ttl, err
            }
        }
        time.Sleep(50 * time.Millisecond)
    }
    ......
    return -1, ErrCanceled
}

到达Leader节点之后会通过Renew更新该lease的剩余时间,过期时间以及最小堆中的lease。

func (le *lessor) Renew(id LeaseID) (int64, error) {
    le.mu.RLock()
    if !le.isPrimary() {
        le.mu.RUnlock()
        return -1, ErrNotPrimary
    }
​
    demotec := le.demotec
​
    l := le.leaseMap[id]
    if l == nil {
        le.mu.RUnlock()
        return -1, ErrLeaseNotFound
    }
    //当cp(checkpoint方法,需要通过raft做数据同步的方法)不为空而且剩余时间大于0时为true
    clearRemainingTTL := le.cp != nil && l.remainingTTL > 0
​
    le.mu.RUnlock()
    //如果lease过期
    if l.expired() {
        select {
        case <-l.revokec: //revoke时候会直接返回
            return -1, ErrLeaseNotFound
        // The expired lease might fail to be revoked if the primary changes.
        // The caller will retry on ErrNotPrimary.
        case <-demotec:
            return -1, ErrNotPrimary
        case <-le.stopC:
            return -1, ErrNotPrimary
        }
    }
​
    if clearRemainingTTL {
        //通过checkpoint方法同步到各个节点lease的剩余时间
        le.cp(context.Background(), &pb.LeaseCheckpointRequest{Checkpoints: []*pb.LeaseCheckpoint{{ID: int64(l.ID), Remaining_TTL: 0}}})
    }
​
    le.mu.Lock()
    l.refresh(0)
    item := &LeaseWithTime{id: l.ID, time: l.expiry}
    //更新最小堆中的lease
    le.leaseExpiredNotifier.RegisterOrUpdate(item)
    le.mu.Unlock()
    return l.ttl, nil
}

撤销

撤销操作可以由两种方式触发,一种是通过客户端直接调用Revoke方法被动触发,一种是leader节点检测到lease过期时候的主动触发。被动触发相对简单,follower节点收到请求后直接调用raft模块同步该请求,各个节点收到请求后通过lessor主动删除该lease(删除并没有直接删除leaseMap中的lease,而是关闭对应revokec),以及删除绑定在上面的key。

func (le *lessor) Revoke(id LeaseID) error {
    le.mu.Lock()
    l := le.leaseMap[id]
    //关闭通知的管道
    defer close(l.revokec)
    le.mu.Unlock()
​
    if le.rd == nil {
        return nil
    }
​
    txn := le.rd()
    //Keys方法会将lease中itemSet的key取出
    keys := l.Keys()
    sort.StringSlice(keys).Sort()
    //删除lease绑定的key
    for _, key := range keys {
        txn.DeleteRange([]byte(key), nil)
    }
​
    le.mu.Lock()
    defer le.mu.Unlock()
    delete(le.leaseMap, l.ID)
    //删除boltdb持久化的lease
    le.b.BatchTx().UnsafeDelete(buckets.Lease, int64ToBytes(int64(l.ID)))
​
    txn.End()
    return nil
}

主动触发则通过创建lessor时候启动的异步协程runLoop(),每500ms轮询调用revokeExpiredLeases来检查是否过期。

func (le *lessor) revokeExpiredLeases() {
    var ls []*Lease
    // rate limit
    revokeLimit := leaseRevokeRate / 2
    le.mu.RLock()
    //如果是leader节点
    if le.isPrimary() {
        //在leaseExpiredNotifier最小堆中找到过期的lease
        ls = le.findExpiredLeases(revokeLimit)
    }
    le.mu.RUnlock()
    if len(ls) != 0 {
        select {
        case <-le.stopC:
            return
        case le.expiredC <- ls://将过期的lease发送到expireC中
        default:
        }
    }
}

在etcd启动时候,会另外启动一个异步run协程,会订阅该expireC,收到消息后发起一个Revoke提案并进行同步操作。

//leassor通过ExpiredLeasesC方法把expiredC暴露出来
func (le *lessor) ExpiredLeasesC() <-chan []*Lease {
    return le.expiredC
}
​
//etcd启动的异步run协程
func (s *EtcdServer) run() {
    ......
    var expiredLeaseC <-chan []*lease.Lease
    if s.lessor != nil {
        expiredLeaseC = s.lessor.ExpiredLeasesC()
    }
    for{
        select{
           case leases := <-expiredLeaseC://接到过期消息
            s.GoAttach(func() {
                for _, lease := range leases {
                    ......
                    lid := lease.ID
                    s.GoAttach(func() {
                        ctx := s.authStore.WithRoot(s.ctx)
                         //调用revoke方法
                        _, lerr := s.LeaseRevoke(ctx, &pb.LeaseRevokeRequest{ID: int64(lid)})
                        ......
                        <-c
                    })
                }
            })
           ......
            //其他case操作
        }
    }
}
​

小结

为了保持数据的一致性,lease的创建,删除,checkpoint等都需要经过raft模块进行同步,而在续约阶段则直接通过http请求发送到leader节点,所有的维护与检查工作都在leader节点,大体可以用下图来表示。由于作者对raft模块理解不够深入,所以一笔带过。

Reference


郭朝
24 声望7 粉丝