基于redis实现的分布式锁
leayun 人气:2基于redis实现的分布式锁实现
什么是锁
这里需要引入一个话题,什么是锁?其实说到锁在我们的现实生活中非常的常见,比如密码锁,指纹锁,他是为了保证家中物资的安全性的一道保障。而在我们的计算机领域中,其实也有锁的概念,他的目的与上相似,都是为了保证数据的最终一致性,当然在单个线程锁是没有太大作用,但是若出现多个线程之间对某个资源进行竞争的时候,那么锁的存在就很有意义。那么刚才所提到的是针对与单体服务的锁(这个有时间可以讲讲基于java中锁的概念),然而随网络用户量的升级,单体服务难以支撑起庞大的访问量,为此我们的服务之间存在了数据以及模块的拆分,之前关于单体服务的锁对我们就不太适用了,因此就会引入到我们今天所需要讲的分布式锁。
为什么要用分布式锁
就如之前所说,我们需要保证数据的一致性,防止分布式系统中多个线程之间相互进行干扰,我们需要一种分布式协调技术来对这些进程进行调度。而这个分布式协调技术的核心就是来实现这个分布式锁,以用户购买商品下单为例子。
-
未使用锁
我们在没有使用任何锁的时候,当用户进行下单的时候我们需要对redis 中的库存进行查询,如果是可以售卖,那么数量就需要进行更新,同时使用mq进行落库。但是,这个地方需要注意的是查询和更新是两部操作(其实可以 使用lua进行原子操作,但是今天主要讲的是分布式锁) 这样可能会存在bug,如果两个线程同时进行操作的时候,如果库存只有1,两个线程在没有执行第三步的间隙同时进入了第二阶段,都认为可以购买,并进入了第四阶段。 那么就会存在我们所谓的超卖问题(没错的话,估计会被请去喝茶了)
-
使用分布式锁
这里略过了多个单体服务器多线程操作的过程,其实也和上面的类似,都是相同时间,多个线程对同一数据进行操作。其实有个思路,就是能够保证全局唯一线程去获取到锁并对数据进行操作,那么就可以保证全局的安全性问题,能够实现分布式锁的框架很多,如 redis,以及zookeeper甚至是数据库, 我们这里使用redis作为我们的分布式锁,关于redis为什么能实现分布式锁主要是用到了他的单线程模式,采用队列模式将并发访问转换成串行模式。
在图中其实可以看到,如果多个服务进行购买商品的时候,在最后会进入到分布式锁的阶段,得到了锁的服务器的那个线程才能执行对扣减操作。其实对应锁的和java中的多线程非常类似,只是从java中的synchronized的锁更改未了redis 的单线程操作,然后操作对象由原来的单体服务器中的多个线程更改为了多个服务器的多个线程进行执行操作。
分布式锁原理
既然redis那么好用,那是用的内部哪个命令呢,setNx,没错。就是一个命令就可以做到我们想要的。他其实内部包含有两部分操作
1、判断数据是否存在,
2、如果存在那么不插入数据,如果不存在那么就插入对应的数据。
具体的执行操作如下。
图中的Setex 命令为指定的 key 设置值及其过期时间。如果 key 已经存在, SETEX 命令将会替换旧的值。
同时这里需要注意
* 【千万记住】解锁流程不能遗漏,否则导致任务执行一次就永不过期
* 将加锁代码和任务逻辑放在try,catch代码块,将解锁流程放在finally
分布式锁可能出现的问题
虽然这个命令能够完成我们在高并发的数据一致性,但是还是可能会存在一些问题
-
服务宕机导致redis锁永不失效
-
线程误删除redis锁
-
执行线程操作时redis锁过期
-
集群模式下哨兵重选导致的redis丢失
所以,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
-
互斥性。在任意时刻,只有一个客户端能持有锁。
-
不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
-
具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
-
解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
我们通过代码的方式来对分布式锁出现问题进行节点
1、服务宕机导致redis锁永不失效
-
错误代码范例
public static void thisIsWrongLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
jedis.expire(lockKey, expireTime);
}
}由于设置中 加锁以及设置过期时间为两段操作,在未执行过期时间时服务器宕机,那么这个锁就永远没有办法失效了
-
正确操作
其实正确操作就是将刚才的两步操作,更改为一步操作,那么操作的方式是什么呢,这就要涉及到另外个语言LUA脚本语言,他执行是原子性质操作。
//上锁脚本
private static final String LOCK_LUA = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then redis.call('expire', KEYS[1], ARGV[2]) return 'true' else return 'false' end";
/**
*
* @param lockKey 上锁
* @param time 上锁时间
* @return
*/
public boolean lock(String lockKey, int time) {
RedisScript lockRedisScript = RedisScript.of(LOCK_LUA, String.class);
List<String> keys = Collections.singletonList(lockKey);
/**
* 这里上锁的value是当前线程
**/
String flag = redisTemplate.execute(lockRedisScript, argsSerializer, resultSerializer, keys, Thread.currentThread().getName(), String.valueOf(time));
return Boolean.valueOf(flag);
}
2、线程误删除redis锁
-
错误示范
public void wrongReleaseLock1( String lockKey) {
redisTemplate.delete(lockKey);
}
没错 看了这个就是直接强制删除对应的redis key值,管你是谁,直接强删,这样也会出现很多问题。
-
正确示范
我们可以在进行操作的时候来对key值数据进行判断,判断数据是否是我们之前存放的结果值,一般来结果值也需要一个特定的数据,那想像下,在同一时间执行如何他设置一个唯一性id的value值呢?其实方式也很多,redis的incr或者雪花算法生成的唯一性id,然后使用lua脚本进行执行操作就可以了
//解锁脚本
private static final String UNLOCK_LUA = "if redis.call('get', KEYS[1]) == ARGV[1] then redis.call('del', KEYS[1]) end return 'true' ";
public void unlock(String lockKey, String val) {
RedisScript unLockRedisScript = RedisScript.of(UNLOCK_LUA, String.class);
List<String> keys = Collections.singletonList(LOCK_PREFIX + lockKey);
redisTemplate.execute(unLockRedisScript, argsSerializer, resultSerializer, keys, val);
}
3、 redis锁提前过期
在生产过程中其实可能会出现这样业务代码执行缓慢的情况,而我们在添加的redis的过期太短,导致程序还没有执行完,redis就直接过期从而其他线程会提前拿到对应的锁。针对与解决思路其实设置时间长一点也行,但这里其实可以考虑对锁进行自动续期,需要引入redission客户端进行操作。它内部提供了对应的看门狗,作用是在redisson实例被关闭之前,不断的对锁进行延长时间。
redisson的底层原理
4、集群模式下哨兵重选导致的redis丢失
如果redis 部署的是集群版本可能会脑裂或者是主master宕机问题。那么就很有可能会出现锁的丢失:
-
客户端1在Redis的master节点上拿到了锁
-
Master宕机了,存储锁的key还没有来得及同步到Slave上
-
master故障,发生故障转移,slave节点升级为master节点
-
客户端2从新的Master获取到了对应同一个资源的锁
于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破了。针对这个问题。Redis作者antirez提出了RedLock算法来解决这个问题
redLock思路
大致思路如下
1、获取当前时间毫秒值 CT
2、按照顺序向N个节点中执行获取锁的操作,为了保证在某个在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还需要一个超时时间。它应该远小于锁的过期时间(expireTime))。客户端向某个Redis节点获取锁失败后,应立即尝试下一个Redis节点。这里失败包括Redis节点不可用或者该Redis节点上的锁已经被其他客户端持有。
3、计算总耗时间,即ET=now()-CT,然后与过期时间进行比对,如果是小于锁过期则对应上锁生效,否则认定失败,同时对所有节点进行锁的删除(无论是否得到都得执行该操作)
当然,这里有个小问题:一定是要全部节点获取到才认为上锁成功么?
其实当超过半数redis请求到锁的时候,才算是真正获取到了锁。如果没有获取到锁,则把部分已锁的redis释放掉。
今天的分享就到这里了,下期有兴趣可以给大家分享下分布式事务的小知识。
附录:
红锁
加载全部内容