etcd分布式锁lease keepalive导致的goroutine泄漏问题排查

分布式锁实现

demo参考:https://github.com/etcd-io/etcd/blob/master/clientv3/concurrency/example_mutex_test.go
基本原理:在etcd事务中查询key的revision是否为0,等于0则创建key和value,表示抢锁成功;不等于0则返回最早创建该key的revision信息,表示抢锁失败。更详细的实现留个TODO。

问题现象

  • pprof显示goroutine数量异常,查看详细调用栈看到有两处goroutine泄漏,数量都在14000多。


    1562121215406.jpg
  • 查看etcd锁的kv数量,和泄漏的goroutine数量一致。

问题分析

  • 分析goroutine阻塞位置的代码(如下),从现象特征、注释和代码上下文初步推测加锁的key没有释放成功,goroutine一直对key的lease做keepalive操作。
// keep the lease alive until client error or cancelled context <- 核心注释
go func() {
    defer close(donec)
    for range keepAlive {
        // eat messages until keep alive channel closes
    }
}()
func (l *lessor) keepAliveCtxCloser(ctx context.Context, id LeaseID, donec <-chan struct{}) {
    select {
    case <-donec:
        return
    case <-l.donec:
        return
    case <-ctx.Done():
    }
    //略
}
  • 和etcd mutex example demo代码比对,缺少unlock操作。
  • 确认unlock能否解决问题。源码如下:
func (m *Mutex) Unlock(ctx context.Context) error {
    client := m.s.Client()
    if _, err := client.Delete(ctx, m.myKey); err != nil {
        return err
    }
    m.myKey = "\x00"
    m.myRev = -1
    return nil
}

unlock直接将key删除,可能会出现其他服务抢到锁,临界代码再次被执行。这样能解决当前问题,但会引入其他问题。

解决方案

unlock

  • unlock操作是将锁删除。如果unlock失败怎么办?锁一直存在,goroutine还会泄漏!
  • 各个应用实例执行状态无法确定,某个实例unlock后会有其他应用实例lock成功的可能,会演变成临界代码的串行化执行。

lock expire

  • 根据lock后的程序执行情况,冗余判断代码执行时间,进而设置锁存活时间,并停止对锁的续租行为,到期后key和value自动消失。
  • 如果lock后执行失败了?执行时间不一定靠谱?抢锁后的操作失败或者执行超时锁消失造成二次上锁,这些问题引发的后果是可承担的,出现概率极低,当前的业务场景下是ok的。
  • 抢锁设置超时时间,避免多服务实例goroutine执行阻塞。

如何停止keepalive?分析源码

  • 源码中有说明注释
  • 从调用栈分析是NewSession的调用,入口示例代码:
    cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
    if err != nil {
        log.Fatal(err)
    }
    defer cli.Close()

    s1, err := concurrency.NewSession(cli)  //<- 问题入口函数
    if err != nil {
        log.Fatal(err)
    }
    defer s1.Close()
    m1 := concurrency.NewMutex(s1, "/my-lock/")

    // acquire lock for s1
    if err := m1.Lock(context.TODO()); err != nil {
        log.Fatal(err)
    }
    fmt.Println("acquired lock for s1")
  • NewSession实现如下:
// NewSession gets the leased session for a client.
func NewSession(client *v3.Client, opts ...SessionOption) (*Session, error) {
    ops := &sessionOptions{ttl: defaultSessionTTL, ctx: client.Ctx()}
    for _, opt := range opts {
        opt(ops)
    }
        
    //如果没有租约lease,则申请一个新的,其TTL是可以通过参数WithTTL设置,不设置默认defaultSessionTTL 60s。
    id := ops.leaseID
    if id == v3.NoLease {
        resp, err := client.Grant(ops.ctx, int64(ops.ttl))
        if err != nil {
            return nil, err
        }
        id = v3.LeaseID(resp.ID)
    }

    ctx, cancel := context.WithCancel(ops.ctx) 
    //KeepAlive参数ctx的parent来源于ops.ctx,通过此context可以cancel keepalive,停止keepalive channel。
    keepAlive, err := client.KeepAlive(ctx, id)
    if err != nil || keepAlive == nil {
        cancel()
        return nil, err
    }

    donec := make(chan struct{})
    s := &Session{client: client, opts: ops, id: id, cancel: cancel, donec: donec}

    // keep the lease alive until client error or cancelled context
    go func() {
        defer close(donec)
        for range keepAlive { //<- 阻塞点之一,因为keepalive goroutine不停续租,没有close该channel
            // eat messages until keep alive channel closes
        }
    }()

    return s, nil
}

sessionOption对session做参数赋值操作,类似gRPC的client grpc.DialContext的代码。将结构字段赋值做成可变参数,做到针对性的定制化,并提供了WithTTL WithLease WithContext三种参数选项,分别设置新申请租约的TTL、设置已有租约、设置context。如果不熟悉context请先熟悉context的parent canel机制。

太累了,不写了。看一下goroutine执行图,有留言我再写。

lease keepalive执行图

etcd v3 lease keepalve.jpg
  • goroutine E是专门负责在keepalive失败或者取消后,清理keepalive所持有数据对象资源的。D是专门负责在keepalive失败或者取消后,通知上层goroutine。因此goroutine D和E阻塞。
  • goroutine A在stream通道中周期性发送lease保活心跳,etcd接收到心跳request后对lease renew,然后向goroutine B发送response,返回TTL。如果TTL>0,则更新下一次发送保活心跳的时间和lease过期时间,并发送lease alive的message给到goroutine D,形成keepalive forever的模型,goroutine D和E保持阻塞。如果TTL<=0,则调用keepalive关闭模块,通知goroutine D和E进行结束。
  • goroutine C在周期性计算keepalive是否到期,到期删除释放相关资源。主要职责是防止服务端宕掉、网络问题或其他异常问题导致lease已经消亡而客户端还认为lease存活的假象,采取过期机制。
  • 在goroutine E中增加timeout的context,到时间自动关闭context done channel,进而结束D和E goroutine。
  • 实际代码更加复杂,更多channel之间的通信,暂时不画全貌。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容