1. 分布式锁
1.1 特点
- 互斥性: 同一时刻只能有一个线程持有锁
- 可重入性: 同一节点上的同一个线程如果获取了锁之后能够再次获取锁
- 锁超时:和J.U.C中的锁一样支持锁超时,防止死锁
- 高性能和高可用: 加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效
- 具备阻塞和非阻塞性:能够及时从阻塞状态中被唤醒
1.2 实现方式
我们一般实现分布式锁有以下几种方式:
- 基于数据库
- 基于Redis
- 基于zookeeper
2. Redis实现方案
2.1 实现原理
想要实现分布式锁,必须要求 Redis 有「互斥」的能力,我们可以使用 SETNX 命令,这个命令表示SET If Not Exists,即如果 key 不存在,才会设置它的值,否则什么也不做。
加锁命令:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。
解锁命令:DEL key,通过删除键值对释放锁,以便其他线程可以通过 SETNX 命令来获取锁。
锁超时:EXPIRE key timeout, 设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。
加锁解锁伪代码如下:
1 | if (setnx(key, 1) == 1){ |
2.2 加锁
SETNX 和 EXPIRE (错误)
如果 SETNX 成功,在设置锁超时时间后,服务器挂掉、重启或网络问题等,导致 EXPIRE 命令没有执行,锁没有设置超时时间变成死锁。
SET 原子命令(正确)
从Redis 2.6.12 版本开始,SET命令可以通过参数来实现和SETNX、SETEX、PSETEX 三个命令相同的效果。
1 | SET key value NX EX seconds |
加上NX、EX参数后,效果就相当于SETEX,这也是Redis获取锁写法里面最常见的。
2.3 释放锁
VALUE 唯一性
value必须要具有唯一性,我们可以用UUID来做,设置随机字符串保证唯一性,至于为什么要保证唯一性?假如value不是随机字符串,而是一个固定值,那么就可能存在下面的问题:
- 1.客户端1获取锁成功
- 2.客户端1在某个操作上阻塞了太长时间
- 3.设置的key过期了,锁自动释放了
- 4.客户端2获取到了对应同一个资源的锁
- 5.客户端1从阻塞中恢复过来,因为value值一样,所以执行释放锁操作时就会释放掉客户端2持有的锁,这样就会造成问题
所以通常来说,在释放锁时,我们需要对value进行验证
GET 后对比自己并 DEL
释放锁时不能直接用del key这种粗暴的方式,因为直接del key任何客户端都可以进行解锁了,所以解锁时,我们需要判断锁是否是自己的,基于value值来判断。
- lua脚本实现
所以我们必须先确保当前释放锁的线程是持有者,没问题了再删除,这样一来,就变成两个步骤了,似乎又违背了原子性了,怎么办呢?我们可以用lua脚本(保证原子性)把两步操作做拼装。
1 | public boolean releaseLock_with_lua(String key,String value) { |
KEYS[1]是当前key的名称,ARGV[1]可以是当前线程的ID(或者其他不固定的值,能识别所属线程即可),这样就可以防止持有过期锁的线程,或者其他线程误删现有锁的情况出现。
- watch 和事务实现乐观锁实现
这个锁实现在绝大部分情况下都能够正常运行,但它的release()方法包含了一个非常隐蔽的错误:在程序使用GET命令获取锁键的值以后,直到程序调用DEL命令删除锁键的这段时间里面,锁键的值有可能已经发生了变化,因此程序执行的DEL命令有可能会导致当前持有者的锁被错误地释放。


1 | from redis import WatchError |
2.3 实现问题总结
死锁
设置过期时间即可
锁被别人释放
锁写入唯一标识,释放锁先检查标识,再释放。
锁过期时间到了还没干完活
锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险。
加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。
如果你是 Java 技术栈,幸运的是,已经有一个库把这些工作都封装好了:Redisson。
单点问题
- 客户端 1 在主库上执行 SET 命令,加锁成功
- 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
- 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!
为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)。
3. Redlock 算法
3.1 前提
Redlock 的方案基于 2 个前提:
- 不再需要部署从库和哨兵实例,只部署主库
- 但主库要部署多个,官方推荐至少 5 个实例
也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。
注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。
3.2 流程
整体的流程是这样的,一共分为 5 步:
- 客户端先获取「当前时间戳T1」
- 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
- 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
- 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
- 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)
3.3 争论
参考 Martin 和 redis 作者的争论
红锁的问题在于:加锁和解锁的延迟较大。难以在集群版或者标准版(主从架构)的Redis实例中实现。尽量不用它,而且它的性能不如单机版 Redis,部署成本也高。
4. 头脑风暴
SET EX PX NX + 校验唯一随机值, 对比后再释放锁。
锁过期了活没干完,就要涉及到续期。
JAVA开源框架:Redisson
只要线程一加锁成功,就会启动一个
watch dog
看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了锁过期释放,业务没执行完问题。