实现分布式锁,你能想到什么?

前言

所谓分布式锁,即在多个相同服务水平扩展时,对于同一资源,能稳定保证有且只有一个服务获得该资源 — by LinkinStar

其实对于分布式锁,也是属于那种看似简单,实则有很多细节的问题。很多人在被问到这个问题的时候,一上来就会说用redis嘛,setnx嘛,我知道我知道。但仅仅是这样就能搞定了吗?那么当我们在实现一个分布式锁的时候,我们究竟需要考虑些什么呢?

必考点

首先作为一个分布式锁,你一定要保证的是什么呢?

  • 不能有两个服务同时获取到一把锁(资源)
  • 不能出现有一个资源一直被锁住(锁一直被持有)

我认为上面两点是必须要保证的,其他的点,比如锁的获取是否高效,锁获取的非阻塞等等是评价一个锁是否好用的点(当然也不是说不重要)

下面我们一个个实现方案来说,来看看究竟有多少细节是我们需要考虑的。

redis实现

先从最普遍的实现方案开始说起,redis。利用redis的特性,nx,资源不存在时才能够成功执行 set 操作,同时设置过期时间用于防止死锁

加锁

SET resource_key random_value NX PX lock-time

解锁

DEL resource_key

面试者往往能给出这样的方案,那么这样的实现足够了吗?

问题1 解锁方式靠谱吗?

上面的解锁方式是通过删除对应的key实现的。那么会有什么问题呢?
如果程序是我们自己写的,那么我们一定能保证,如果需要主动释放锁的话,必须要先要获取到锁。(我们可以这样强制编码)
那么问题来了,其实这个任何人都可能主动调用解锁,只要知道key就可以了,而key是肯定知道的。
那么,如果我主动捣乱,我可以说直接手动先删除这个key然后我就一定能重新拿到这个锁了,这显然有漏洞了。
其实不只是这样的场景,有一些场景下,获取锁和释放锁的人确实不是一个,那么就会存在问题。

问题1 解决

方式1:强制规定只能使用过期解锁
方式2:验证存放的value是否为存放的时候的值来保证是同一人的行为
方式3:通过lua脚本进一步保证验证和是否为原子操作

if redis.get("resource_key") ==  "random_value"
    return redis.del("resource_key")
else 
    return 0

问题2 redis挂了...

redis万一挂了,那么对外来说,没有人能获取到锁,那么业务肯定会有问题。
这个时候小明马上会说了,那就redis集群,主从,哨兵...
那么相对应的问题就来了,如果在复制的过程中挂了,是否就有可能出现虽然获取到了锁但是锁丢失了的情况呢?

问题2 解决方案

那么redis早就想到了解决方案,Redlock(红锁)
如果你是第一次听到这个名字可能会觉得它有点独特和高级,其实并没有。。。
它利用的就是抽屉原理,或者称为大多数原理,就是当你要获取锁的时候,如果有5个节点,你必须要拿到其中3个才可以。并且获取锁的时间共计时间要小于锁的超时时间。
更加详细的可以参考官网:https://redis.io/topics/distlock
这样能保证在最多挂掉2个节点的情况下,依旧能正常的时候(原来是5个)

问题3 超时时间设定多久

这个问题其实就很难了,无论是一开始的方案还是说对超时时间要求更高的redlock,超时时间的设定一直是一个难题;设定太长,可能在意外情况下会导致锁迟迟得不到释放;设定太短,事情还没做好,锁就被释放了;更有甚者提出,设定时间即使合适,那么由于网络、GC、等等不稳定因素也会导致意外情况发生。

问题3 解决

其实问题在不好解决,因为问题本身存在不确定因素。所以我们不能从问题本身出发,那么就尝试从业务出发解决。(我总不能告诉你说'设定5分钟,这样是最好的'这样类似的话吧)
方案是说:当我们获取锁之后获得一个类似乐观锁的标记token(或者说version)比如当前是33,当我们做完事情之后,需要主动更新数据时,如果发现当前当前的version已经为34(已经出现了别人获取到锁并且更新了数据),那么此次操作将不进行。
虽然这样看来直接用乐观锁不就好了吗?后面我们会提到。

mysql实现

说完了 redis 的实现,那让我们来看看 mysql 的实现吧。mysql的实现方式就五花八门了,我们一个个来看看。

mysql实现的优点

我先来说说 mysql 实现的优点吧,因为马上可能就会有人问,为什么要用 mysql 去实现呢?redis它不香吗?主要原因我想了一下:

  • 如果没有redis(当前项目中未使用)如果多引入一个中间件势必带来维护成本
  • 实现和使用简单(因为只需要操作mysql)
  • 如果出现意外不用慌张(mysql 都挂了,你的业务系统也就凉了,能不能拿到锁已经是次要的了,反正就是要死一起死)

方案1 主键锁

这个是最容易想到的,利用主键的唯一性。

  • 获取锁就是插入一条记录(相同的主键插入不进去)
  • 释放锁就是删除一条记录

方案1 主键锁 问题

问题其实也是显而易见的

  • 没有超时时间,可能一直无法释放,这问题很大
  • 会一直造成 mysql 报错,并发下性能堪忧

方案1 主键锁 解决

  • 可以设定一个入表时间,然后另外建立一个定时任务去清理过期的记录
  • 可以在程序中记录一下当前的id最大值,来减少冲突发生的情况

总之这样的方式实现只能说在并发量不高,只是简单要保证实现的基础做是可以的

方案2 乐观锁

有关乐观锁就简单解释一下好了,就是添加一个 version 的字段,需要更新操作的时候,必须满足当前取出时的版本号。举个例子:我取出时的版本号是3,当我更新时那么就必须写着 update...... where version = 3 因为 mysql 的 mvcc 的控制能保证没有问题

方案2 乐观锁 问题

其实乐观锁的问题就在需要给业务添加 version 字段,这个对于业务是入侵的。
其次在并发情况下会增加大量的数据库无用操作,如果数据量大的话也挺难顶的。(这也是为什么上面在redis实现中加入类似version控制,而不直接使用乐观锁控制的原因)

方案2 乐观锁 解决

乐观锁其实挺乐观的,它就是用于哪些乐观的不会发生很大程度并发的情况,所以它的使用就看你的业务需求即可,有时即使没有 version 字段,也会合理使用。

方案3 悲观锁

网上搜一圈你就会发现如下的分布式锁的实现:https://www.jianshu.com/p/b76f409b2db2

  • 开始事务
  • 使用 for update 进行查询(如果能查询就表示能获取到锁)
  • 做需要做的任务
  • 提交事务(解锁)

方案3 悲观锁 问题

于是你就会发现这个方案虽然可行,但是存在很多问题

  • 通过提交来解锁,那么整个事务持续时间会很长(有可能,根据你做的任务有关)
  • 获取不到锁的会一直在等待,因为前一个问题导致
  • 没有超时时间,存在锁一直不释放的情况,并且有可能导致事务一直被开启
  • 高并发下 mysql 连接会很多
  • ...

所以小明想要改动一下看看能不能做的更好,于是有了下面的改动方案

方案3 悲观锁 改动之旅

小明想到的第一个改动方案是,我要锁的 key 是 xxx

  1. BEGIN;
  2. SELECT * FROM lock_tab WHERE key = 'xxx' FOR UPDATE;
  3. INSERT xxx....;
  4. COMMIT;

当第二个步骤查询到了之后:

  • 如果没有数据,证明服务正在持有锁,那么此时进行新增就可以了,由于悲观锁的存在,别的服务是没有办法同时进行插入操作的;
  • 如果有数据,证明已经有服务在持有锁,那么就直接放弃;
  • 释放锁通过删除这条记录去释放

那么,你想想,这样有问题吗?

有,问题就在释放锁的时候,这个删除操作有可能无法成功,因为有别的服务可能会持有悲观锁,特别是在并发量大,且重试较多的情况下,非常容易出现锁无法释放的情况。


那再改改呗,手动删除这个操作肯定是不行的,这次小明想到超时机制,于是尝试加入字段过期时间,查询之后通过时间去判断是否超时,如果已经超时,也同时证明没有服务正在持有这把锁。
那这样会有问题吗?
有,当前这样查询是直接加的表锁。(当前表设计上没有索引)当我们要锁资源的时候我们肯定想的是最好去锁一行数据,而不要去锁整张表,这样不会影响到其他资源的抢锁,于是小明给表的key(资源名称)字段加了索引。测试了一下。

当前表格中的数据
id key val
1 aa a2
2 bb b3
3 cc c4

T1

  • BEGIN;
  • SELECT * FROM lock_tab WHERE key='aa' FOR UPDATE;

T2

  • BEGIN;
  • SELECT * FROM lock_tab WHERE key='bb' FOR UPDATE;(正常)
  • COMMIT;
  • BEGIN;
  • SELECT * FROM lock_tab WHERE key='aa' FOR UPDATE;(卡主)

发现T2查询bb可以正常执行,也就是说,两个不同的资源不会互相干扰了(如果锁表的情况下,T2查询bb就会卡主)

还有问题吗?显然还有问题。
当前确实是行锁没错了,但是如果这个资源本身在表里面不存在会怎么样?
T1

  • BEGIN;
  • SELECT * FROM lock_tab WHERE key='zz' FOR UPDATE;

T2

  • BEGIN;
  • SELECT * FROM lock_tab WHERE key='zz' FOR UPDATE;(正常???)

没错这就是问题,当资源本身在表格中不存在的时候是能查询到的,也就是说可能造成有两个服务同时获取到锁,这是为什么呢?因为 mysql 当查询主键或索引无记录的并不会触发锁机制,也就是说,没东西锁,这个时候 mysql 是不会将 行锁退化成表锁的。

显然这样的方案不可行,那么如何解决呢?
看起来解决的方式也只有锁表了,不然的话就是必须在表中优先创建资源所占用的数据,这样或许也就只能针对特定的场景锁进行了。

方案3 悲观锁 总结

那总的来说,对于悲观锁的实现,总结一下:

  1. 如果只是单纯对于一个业务的某个场景,并且这个场景下持有锁的时间很短暂,那么选择第一种,直接开启事务,并在事务中获得锁,通过提交事务来释放锁。
  2. 如果对于锁的业务不定,并且锁持有的时间较长,那么使用第二种,每次获取锁通过for update先锁表,然后通过插入数据来完成持有锁的操作,仅利用过期时间这一条件来释放锁,这样能最大程度的更快提交事务,不必占用过多资源也不会造成不必要的等待时间。
  3. 如果面对已知数量的业务场景,可以明确提前给出锁的对象,那么使用第三种,在第二种方式的基础上,在表中加入提前创建锁的对象,并建立索引来完成对于行的锁定,从而不会影响其他资源的锁定。最大限度保证锁的细粒度。

ETCD实现

说了redis、说了mysql、可能很多人认为下面提到的应该是zk了。其实zk也并不失为一种很好的解决方案,但是由于篇幅不想拉的过长,我更想介绍一下ETCD的实现。

ETCD 在 K8S 火了之后也就自然被带火了,多的我就不介绍了,对于很多分布式场景存储的实现总会提到它,现在我们关注一下如何用它来实现分布式锁呢?

实现方案

其实 ETCD 的实现分布式锁思路和 Redis 类似,只不过 ETCD 本身没有一个操作叫做SET NX或类似操作,我们需要使用 ETCD 的事务来帮助实现这个操作,从而实现如果查询到没有就set这样一个原子操作。下面是go实现中的部分代码片段。

    kv := clientv3.NewKV(client)
    txn := kv.Txn(context.TODO())
    txn.If(clientv3.Compare(clientv3.CreateRevision("/lock-key/uuid"), "=", 0)).
        Then(clientv3.OpPut("/lock-key/uuid", "xxx", clientv3.WithLease(leaseId))).
        Else(clientv3.OpGet("/lock-key/uuid"))

    txnResp, err := txn.Commit()
    if err != nil {
        fmt.Println(err)
        return
    }
    if !txnResp.Succeeded {
        fmt.Println("锁被占用:", string(txnResp.Responses[0].GetResponseRange().Kvs[0].Value))
        return
    }

如果仅仅只是这样,那我也不会单独拿出来重点说了,它可并不只是这样。

  • ETCD 有着租约机制,什么意思呢?当你申请获得一个租约之后,它有一定的时间,在这个时间之内 key 都是有效的,但是租约到期了之后,key 就会被自动删除了。有点类似 redis 的过期,但是它有续租的概念,过一段时间可以主动进行续租,这样你又能获得一段时间的租约。

那么利用这个租约机制,我们是可以实现出一种逻辑,就是当任务在进行的过程中,不断的去更新我们的租约,能保证我们在做任务的阶段一定是持有锁的,不会出现任务还在进行中,但是锁已经失效的情况。并且可以使用在任务时长无法控制的情况下,如:当前任务需要跑1分钟,可能下一次同一个任务需要跑1小时,无法确定合理的锁过期时间。

下面是在go中,使用lease.KeepAlive自动续租,而用 context 的 cancelFunc 来取消自动续租。

    lease = clientv3.NewLease(client)
    leaseGrantResp, err := lease.Grant(context.TODO(), 5);
    if err != nil {
        return
    }

    leaseId = leaseGrantResp.ID
    ctx, cancelFunc = context.WithCancel(context.TODO())

    defer cancelFunc()
    defer lease.Revoke(context.TODO(), leaseId)

    keepRespChan, err = lease.KeepAlive(ctx, leaseId)
    if err != nil {
        return
    }

仅仅是这样吗?etcd还有一个巧妙的 watch 机制,能监听一个 key 的变化,也就是说,当我没有获取到锁的时候,但是我又不想一直循环去调用 get 方法进行查询,那么让 watch 通知你可能不失为一种巧妙的解决方式(适用于一些特殊的等待场景,这里就不列举代码了)

实现总结

ETCD 本身就是支持分布式的,所以在分布式锁的实现上没有前两者可能带来的单点问题,而本身基于 raft 实现的它,也同时避免了 redis 主从或集群下复制可能出现的尴尬问题。要说有什么问题,那么就是成本了,ETCD 在实际的业务使用场景中并不是非常常见的,所以如果要单独为它进行部署维护还是需要成本的。

其他实现方式

  • Consul 是 Go 实现的一个轻量级 服务发现 、KV存储 的工具
    对于它,知道的人可能就不多了,它也能实现分布式锁,而且实现起来也很简单,只需要实例一个session,用这个session去获取锁和释放锁就可以了。如果你正好在用 Consul 那用它来实现你需要的分布式锁,也可以作为你的一种选择吧

总结

其实,回过头你会发现,我们实现分布式锁,其实要考虑的地方非常多,需要注意的问题也很多,并不是很多时候我们也在权衡考虑。为了保证一个分布式环境中的原子操作,其实说起来容易,做起来真的有点难。

推荐下面几篇博客供你进一步学习:
https://dbaplus.cn/news-159-2469-1.html(ZK实现分布式锁,以及分布式锁就够了吗?如何能做到高并发下也能好用呢?)
https://zhuanlan.zhihu.com/p/42056183
https://mp.weixin.qq.com/s/1bPLk_VZhZ0QYNZS8LkviA(基于Redis的分布式锁真的安全吗?)
https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html(大佬说说分布式锁)

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,752评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,100评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,244评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,099评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,210评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,307评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,346评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,133评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,546评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,849评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,019评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,702评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,331评论 3 319
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,030评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,260评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,871评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,898评论 2 351