分布式锁概念
在多线程的程序里,为了避免同时操作一个共享变量产生数据问题,会加一个互斥锁,以确保共享变量的正确性,使用范围是同一个进程。
那如果是多个进程,需要同时操作一个共享资源,如何互斥呢?
比如,现在的业务基本上都是微服务架构,一个应用会部署多个进程,这多个进程需要修改MySQL的同一行记录时,就需要引入分布式锁来解决这个问题了。
要实现分布式锁,需要借助一个外部系统,所有的进程都去这个系统上申请加锁,而这个外部系统必须要实现互斥的能力,换言之,如果有两个请求同时进来,也只会给一个进程返回成功,另一个返回失败或等待。
这个外部系统可以是MySQL、Redis或Zookeeper,一般使用Redis或ZK
如何实现
可以使用SETNX
命令,表示SET IF NOT EXISTS
,也就是当Key不存在的时候才会设置他的值。
比如,客户端1申请加锁,加锁成功;
127.0.0.1:6379> SETNX lock 1
(integer) 1
客户端2申请加锁,因为到达的比客户端1晚,加锁失败。
127.0.0.2:6379> SETNX lock 1
(integer) 0
此时,加锁成功的客户端,就可以去操作共享资源,例如,修改MySQL的某一行数据,或是调用一个API请求。
操作完成后,还要释放锁,这样后续的客户端才能继续操作共享资源。
释放锁可以通过DEL
命令删除这个key
以上是分布式锁最简单的实现,存在一个很大的问题,如果客户端1拿到锁以后,没有释放锁,就会造成死锁。没有释放锁的原因有以下几个:
程序处理业务逻辑异常,没有及时释放锁
整个进程挂了/宕机,没有办法去释放锁
如何避免死锁
那么如何解决上述的问题呢?比较容易想到的方案是:申请锁的时候,给锁设置一个租期
对于刚刚的Redis实现,就是给这个Key设置一个过期时间
比如
127.0.0.1:6379> SETNX lock 1 // 加锁
(integer) 1
127.0.0.1:6379> EXPIRE lock 10 // 10s后自动过期
(integer) 1
这样如果客户端异常的话,这个锁在10秒后会被自动释放,其他客户端依旧可以拿到锁
这样还是有问题,刚刚的操作里,加锁、设置过期时间这是2条命令,有可能执行完第一条,第二条来不及执行,比如:
执行第二条语句时因为网络问题,执行失败
Redis异常宕机,第二条没时间执行
客户端异常崩溃/退出,第二条命令没机会执行
如果这两条命令不能保证原子操作,就有潜在的风险导致过期时间设置失败,依旧会发生死锁。
Redis 2.6.12以后,只需要使用 SET lock 1 EX 10 NX
就可以了
接下来分析下还有没有别的问题?
假设这样一种场景:
客户端1加锁成功,开始操作共享资源
客户端1操作共享资源的时间,超过了锁的过期时间,锁被自动释放
客户端2加锁成功,开始操作共享资源
客户端1操作共享资源完成,释放锁(注意这里的锁是客户端2刚刚加的锁)
这里有两个很严重的问题:
锁过期:客户端1操作共享资源耗时太久,导致锁被自动释放,之后客户端2加锁
释放别人的锁:客户端1操作共享资源后,释放了客户端2的锁
第一个问题,可能是我们评估共享资源的时间不准确导致的。
简单的解决方案就是增大冗余时间,比如10秒过期,但是操作共享资源的时间最慢是15秒,那我就设置过期时间为20秒。但是这样没法根本解决问题,预估的时间只是大致计算,并不能预估到所有增加耗时的场景,比如程序内部发生异常、网络请求超时、异常耗时增加等。
第二个问题,一个客户端释放了其他客户端持有的锁
导致这个问题的原因是 每个客户端在释放锁的时候,没有检查这个锁是不是自己加上的
如何解决锁被别人释放的问题
客户端在加锁的时候,设置一个只有自己知道的唯一标识进去,可以是自己的主机名字、线程ID等,也可以是一个UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK
之后释放锁的时候,需要检查以下
if redis.get("lock") == $uuid:
redis.del("lock")
这里又是一个原子操作,可以写成lua
脚本,让Redis来执行
if redis.call("GET",KEYS[1]) == ARGV[1]
then
return redis.call("DEL",KEYS[1])
else
return 0
end
这样,整个加锁和解锁的过程就比较严谨了,大概流程如下:
加锁:
SET lock $uuid EX 10 NX
操作共享资源
释放锁:
lua
脚本
不好评估锁过期时间怎么办
前面还有一个遗留问题就是,锁会有提前过期的风险
简单的方案就是尽量冗余过期时间,降低锁提前过期的概率。但是这个方案并不能完美解决问题。
比较主流的方案是,加锁的时候,先设置一个过期时间,同时开启一个守护线程,定时去检测这个锁的失效时间,如果锁快过期了,但是操作共享资源还未完成,自动对锁进行续期,重新设置过期时间。
在Java
里Redisson
库已经把这部分封装好了,看门狗线程
分布式场景
之前分析的场景都是,锁在单个Redis实例中可能会产生的问题,并没有考虑Redis的部署架构细节
但实际上在使用Redis的时候,都是采用主从集群+哨兵的模式部署,当主库异常宕机的时候,哨兵可以进行故障自动切换,把从库提升为主库,继续提供服务,来保证可用性。
假设这样一个场景:
客户端1在主库上加锁成功
主库宕机,SET命令还未同步到从库上(这里是因为主从同步是异步的)
哨兵把从库提升为主库,这个锁在新的主库上就丢失了
当引入Redis副本后,分布式锁还是可能受影响,为此,Redis的作者提出一种解决方案,就是Redlock 红锁