所谓分布式锁,即在多个相同服务水平扩展时,对于同一资源,能稳定保证有且只有一个服务获得该资源 — 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脚本进一步保证验证和是否为原子操作

1
2
3
4
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实现中的部分代码片段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
   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 来取消自动续租。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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(大佬说说分布式锁)