一、redis实现分布式锁的主要原理:
1.加锁
最简单的方法是使用setnx命令。key是锁的唯一标识,按业务来决定命名。比如想要给一种商品的秒杀活动加锁,可以给key命名为 “lock_sale_商品ID” 。而value设置成什么呢?我们可以姑且设置成1。加锁的伪代码如下:
setnx(key,1)
当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;当一个线程执行setnx返回0,说明key已经存在,该线程抢锁失败。
2.解锁
有加锁就得有解锁。当得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式是执行del指令,伪代码如下:
del(key)
释放锁之后,其他线程就可以继续执行setnx命令来获得锁。
3.锁超时
锁超时是什么意思呢?如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住,别的线程再也别想进来。
所以,setnx的key必须设置一个超时时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放。setnx不支持超时参数,所以需要额外的指令,伪代码如下:
expire(key, 30)
二、加锁的代码
/** * 尝试获取分布式锁 * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @param expireTime 超期时间 * @return 是否获取成功 */ public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) { Long result = jedis.setnx(lockKey, requestId); if (result == 1) { // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁 jedis.expire(lockKey, expireTime); } }
上面的代码有一个致命的问题,就是加锁和设置过期时间不是原子操作。
那么会有两种极端情况:
一种是在并发情况下,两个线程同时执行setnx,那么得到的结果都是1,这样两个线程同时拿到了锁。
别一种是如代码注释所示,即执行完setnx,程序崩溃没有执行过期时间,那这把锁就永远不会被释放,造成了死锁。
之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。正确的代码如下:
/** * 尝试获取分布式锁 * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @param expireTime 超期时间 * @return 是否获取成功 */ public static boolean tryGetDistributedLock(Jedis jedis,String lockKey, String requestId, int expireTime) { String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { return true; } return false; }
这个set()方法一共有五个形参:
第一个为key,我们使用key来当锁,因为key是唯一的。
第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
第五个为time,与第四个参数相呼应,代表key的过期时间。
总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。
二、解锁的代码
public static void wrongReleaseLock1(Jedis jedis, String lockKey) { jedis.del(lockKey); }
这段代码的问题是容易导致误删,假如某线程成功得到了锁,并且设置的超时时间是30秒。如果某些原因导致线程A执行的很慢很慢,过了30秒都没执行完,这时候锁过期自动释放,线程B得到了锁。
随后,线程A执行完了任务,线程A接着执行del指令来释放锁。但这时候线程B还没执行完,线程A实际上删除的是线程B加的锁。
怎么避免这种情况呢?可以在del释放锁之前做一个判断,验证当前的锁是不是自己加的锁。
至于具体的实现,可以在加锁的时候把当前的线程ID当做value,并在删除之前验证key对应的value是不是自己线程的ID。
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) { // 判断加锁与解锁是不是同一个客户端 if (requestId.equals(jedis.get(lockKey))) { // 若在此时,这把锁突然不是这个客户端的,则会误解锁 jedis.del(lockKey); } }
但是,这样做又隐含了一个新的问题,判断和释放锁是两个独立操作,不是原子性。
解决方案就是使用lua脚本,把它变成原子操作,代码如下:
public class RedisTool { private static final Long RELEASE_SUCCESS = 1L; /** * 释放分布式锁 * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @return 是否释放成功 */ public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false; } }
三、续约问题
上面加锁最后的代码就完美了吗?假想这样一个场景,如果过期时间为30S,A线程超过30S还没执行完,但是自动过期了。这时候B线程就会再拿到锁,造成了同时有两个线程持有锁。这个问题可以归结为”续约“问题,即A没执行完时应该过期时间续约,执行完成才能释放锁。怎么办呢?我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁“续约”。
其实,后面解锁出现的删除非自己锁,也属于“续约”问题。
四、集群同步延迟问题
用于redis的服务肯定不能是单机,因为单机就不是高可用了,一量挂掉整个分布式锁就没用了。
在集群场景下,如果A在master拿到了锁,在没有把数据同步到slave时,master挂掉了。B再拿锁就会从slave拿锁,而且会拿到。又出现了两个线程同时拿到锁。
基于以上的考虑,Redis 的作者也考虑到这个问题,他提出了一个 RedLock 的算法。
这个算法的意思大概是这样的:假设 Redis 的部署模式是 Redis Cluster,总共有 5 个 Master 节点。
通过以下步骤获取一把锁:
- 获取当前时间戳,单位是毫秒。
- 轮流尝试在每个 Master 节点上创建锁,过期时间设置较短,一般就几十毫秒。
- 尝试在大多数节点上建立一个锁,比如 5 个节点就要求是 3 个节点(n / 2 +1)。
- 客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了。
- 要是锁建立失败了,那么就依次删除这个锁。
- 只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁。
但是这样的这种算法还是颇具争议的,可能还会存在不少的问题,无法保证加锁的过程一定正确。
这个问题的根本原因就是redis的集群属于AP,分布式锁属于CP,用AP去实现CP是不可能的。
五、Redisson
Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。充分的利用了Redis键值数据库提供的一系列优势,基于Java实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。
Redisson通过lua脚本解决了上面的原子性问题,通过“看门狗”解决了续约问题,但是它应该解决不了集群中的同步延迟问题。
总结
redis分布式锁的方案,无论用何种方式实现都会有续约问题与集群同步延迟问题。总的来说,是一个不太靠谱的方案。如果追求高正确率,不能采用这种方案。
但是它也有优点,就是比较简单,在某些非严格要求的场景是可以使用的,比如社交系统一类,交易系统一类不能出现重复交易则不建议用。