所谓分布式锁,即在多个相同服务水平扩展时,对于同一资源,能稳定保证有且只有一个服务获得该资源 — by LinkinStar
其实对于分布式锁,也是属于那种看似简单,实则有很多细节的问题。很多人在被问到这个问题的时候,一上来就会说用redis嘛,setnx嘛,我知道我知道。但仅仅是这样就能搞定了吗?那么当我们在实现一个分布式锁的时候,我们究竟需要考虑些什么呢?
首先作为一个分布式锁,你一定要保证的是什么呢?
我认为上面两点是必须要保证的,其他的点,比如锁的获取是否高效,锁获取的非阻塞等等是评价一个锁是否好用的点(当然也不是说不重要)
下面我们一个个实现方案来说,来看看究竟有多少细节是我们需要考虑的。
先从最普遍的实现方案开始说起,redis。利用redis的特性,nx,资源不存在时才能够成功执行 set 操作,同时设置过期时间用于防止死锁
SET resource_key random_value NX PX lock-time
DEL resource_key
面试者往往能给出这样的方案,那么这样的实现足够了吗?
上面的解锁方式是通过删除对应的key实现的。那么会有什么问题呢? 如果程序是我们自己写的,那么我们一定能保证,如果需要主动释放锁的话,必须要先要获取到锁。(我们可以这样强制编码) 那么问题来了,其实这个任何人都可能主动调用解锁,只要知道key就可以了,而key是肯定知道的。 那么,如果我主动捣乱,我可以说直接手动先删除这个key然后我就一定能重新拿到这个锁了,这显然有漏洞了。 其实不只是这样的场景,有一些场景下,获取锁和释放锁的人确实不是一个,那么就会存在问题。
方式1:强制规定只能使用过期解锁 方式2:验证存放的value是否为存放的时候的值来保证是同一人的行为 方式3:通过lua脚本进一步保证验证和是否为原子操作
if redis.get("resource_key") == "random_value"
return redis.del("resource_key")
else
return 0
redis万一挂了,那么对外来说,没有人能获取到锁,那么业务肯定会有问题。 这个时候小明马上会说了,那就redis集群,主从,哨兵… 那么相对应的问题就来了,如果在复制的过程中挂了,是否就有可能出现虽然获取到了锁但是锁丢失了的情况呢?
那么redis早就想到了解决方案,Redlock(红锁) 如果你是第一次听到这个名字可能会觉得它有点独特和高级,其实并没有。。。 它利用的就是抽屉原理,或者称为大多数原理,就是当你要获取锁的时候,如果有5个节点,你必须要拿到其中3个才可以。并且获取锁的时间共计时间要小于锁的超时时间。 更加详细的可以参考官网:https://redis.io/topics/distlock 这样能保证在最多挂掉2个节点的情况下,依旧能正常的时候(原来是5个)
这个问题其实就很难了,无论是一开始的方案还是说对超时时间要求更高的redlock,超时时间的设定一直是一个难题;设定太长,可能在意外情况下会导致锁迟迟得不到释放;设定太短,事情还没做好,锁就被释放了;更有甚者提出,设定时间即使合适,那么由于网络、GC、等等不稳定因素也会导致意外情况发生。
其实问题在不好解决,因为问题本身存在不确定因素。所以我们不能从问题本身出发,那么就尝试从业务出发解决。(我总不能告诉你说’设定5分钟,这样是最好的’这样类似的话吧) 方案是说:当我们获取锁之后获得一个类似乐观锁的标记token(或者说version)比如当前是33,当我们做完事情之后,需要主动更新数据时,如果发现当前当前的version已经为34(已经出现了别人获取到锁并且更新了数据),那么此次操作将不进行。 虽然这样看来直接用乐观锁不就好了吗?后面我们会提到。
说完了 redis 的实现,那让我们来看看 mysql 的实现吧。mysql的实现方式就五花八门了,我们一个个来看看。
我先来说说 mysql 实现的优点吧,因为马上可能就会有人问,为什么要用 mysql 去实现呢?redis它不香吗?主要原因我想了一下:
这个是最容易想到的,利用主键的唯一性。
问题其实也是显而易见的
总之这样的方式实现只能说在并发量不高,只是简单要保证实现的基础做是可以的
有关乐观锁就简单解释一下好了,就是添加一个 version 的字段,需要更新操作的时候,必须满足当前取出时的版本号。举个例子:我取出时的版本号是3,当我更新时那么就必须写着 update…… where version = 3 因为 mysql 的 mvcc 的控制能保证没有问题
其实乐观锁的问题就在需要给业务添加 version 字段,这个对于业务是入侵的。 其次在并发情况下会增加大量的数据库无用操作,如果数据量大的话也挺难顶的。(这也是为什么上面在redis实现中加入类似version控制,而不直接使用乐观锁控制的原因)
乐观锁其实挺乐观的,它就是用于哪些乐观的不会发生很大程度并发的情况,所以它的使用就看你的业务需求即可,有时即使没有 version 字段,也会合理使用。
网上搜一圈你就会发现如下的分布式锁的实现:https://www.jianshu.com/p/b76f409b2db2
for update
进行查询(如果能查询就表示能获取到锁)于是你就会发现这个方案虽然可行,但是存在很多问题
所以小明想要改动一下看看能不能做的更好,于是有了下面的改动方案
小明想到的第一个改动方案是,我要锁的 key 是 xxx
当第二个步骤查询到了之后:
那么,你想想,这样有问题吗?
有,问题就在释放锁的时候,这个删除操作有可能无法成功,因为有别的服务可能会持有悲观锁,特别是在并发量大,且重试较多的情况下,非常容易出现锁无法释放的情况。
那再改改呗,手动删除这个操作肯定是不行的,这次小明想到超时机制,于是尝试加入字段过期时间,查询之后通过时间去判断是否超时,如果已经超时,也同时证明没有服务正在持有这把锁。 那这样会有问题吗? 有,当前这样查询是直接加的表锁。(当前表设计上没有索引)当我们要锁资源的时候我们肯定想的是最好去锁一行数据,而不要去锁整张表,这样不会影响到其他资源的抢锁,于是小明给表的key(资源名称)字段加了索引。测试了一下。
当前表格中的数据 id key val 1 aa a2 2 bb b3 3 cc c4
T1
key
=’aa’ FOR UPDATE;T2
key
=’bb’ FOR UPDATE;(正常)key
=’aa’ FOR UPDATE;(卡主)发现T2查询bb可以正常执行,也就是说,两个不同的资源不会互相干扰了(如果锁表的情况下,T2查询bb就会卡主)
还有问题吗?显然还有问题。 当前确实是行锁没错了,但是如果这个资源本身在表里面不存在会怎么样? T1
key
=’zz’ FOR UPDATE;T2
key
=’zz’ FOR UPDATE;(正常???)没错这就是问题,当资源本身在表格中不存在的时候是能查询到的,也就是说可能造成有两个服务同时获取到锁,这是为什么呢?因为 mysql 当查询主键或索引无记录的并不会触发锁机制,也就是说,没东西锁,这个时候 mysql 是不会将 行锁退化成表锁的。
显然这样的方案不可行,那么如何解决呢? 看起来解决的方式也只有锁表了,不然的话就是必须在表中优先创建资源所占用的数据,这样或许也就只能针对特定的场景锁进行了。
那总的来说,对于悲观锁的实现,总结一下:
说了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
}
如果仅仅只是这样,那我也不会单独拿出来重点说了,它可并不只是这样。
那么利用这个租约机制,我们是可以实现出一种逻辑,就是当任务在进行的过程中,不断的去更新我们的租约,能保证我们在做任务的阶段一定是持有锁的,不会出现任务还在进行中,但是锁已经失效的情况。并且可以使用在任务时长无法控制的情况下,如:当前任务需要跑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 在实际的业务使用场景中并不是非常常见的,所以如果要单独为它进行部署维护还是需要成本的。
其实,回过头你会发现,我们实现分布式锁,其实要考虑的地方非常多,需要注意的问题也很多,并不是很多时候我们也在权衡考虑。为了保证一个分布式环境中的原子操作,其实说起来容易,做起来真的有点难。
推荐下面几篇博客供你进一步学习: 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(大佬说说分布式锁)