一、什么是分布式锁?
在我们写Java程序的时候,多线程争取同一个资源的时候,经常会使用到诸如syncchronize或Lock来实现锁操作,这种锁通常被称为“本地锁”。但是本地锁只能适用于在同一个进程内(同一个应用内的线程之间锁定资源),如果应用是分布式部署的,彼此之间是独立的进程,进程之间又存在需要争夺的资源,那么该如何对资源进行锁定?这就需要使用到分布式锁。
其实分布式锁和本地锁的基本原理是一样的,举个例子:上厕所
4人去上厕所,厕所只有2个坑位
先到坑位的人先占,占有后锁门(也就是上锁)
后到的人没有占到坑位,只能等待
先使用“坑位”的人,使用完资源,进行锁释放。
锁释放之后,后到的人就可以获得坑位并上锁,如此循环往复。
上面的逻辑可以使用下面的代码来体现。
@Resource
RedisTemplate<String, String> redisTemplate;
public void updateUserWithRedisLock(SysUser sysUser) throws InterruptedException {
// 占分布式锁,去redis占坑
Boolean lock = redisTemplate.opsForValue()
.setIfAbsent("SysUserLock" + sysUser.getId(),
"value");
if(lock) {
//加锁成功... 执行业务
redisTemplate.delete("SysUserLock" + sysUser.getId()); //删除key,释放锁
} else {
Thread.sleep(100); // 加锁失败,重试
updateUserWithRedisLock(sysUser);
}
}
setIfAbsent方法的作用是在某一个lock key不存在的时候,才能返回true;如果这个key已经存在了就返回false,返回false就是获取锁失败。setIfAbsent函数功能类似于redis命令行setnx。
二、分布式锁实现过程中的问题
问题一:异常导致锁没有释放
这个问题形成的原因就是程序在获取到锁之后,执行业务的过程中出现了异常,导致锁没有被释放。通俗的话说:上厕所的人死在了厕所里面,导致“坑位”资源死锁无法被释放。(当然这种情况出现的概率很小,但概率小不等于不存在。)
解决方案: 为redis的key设置过期时间,程序异常导致的死锁,在到达过期时间之后锁自动释放。也就说厕所门是电子锁,锁定的最长时间是有限制的,超过时长锁就会自动打开释放"坑位"资源。
// 设置过期时间
redisTemplate.expire("SysUserLock" + sysUser.getId(), timeout: 30, TimeUnit.SECONDS) ;
问题二:获取锁与设置过期时间操作不是原子性的
上文中我们虽然获取到锁,也设置了过期时间,看似完美。但是在高并发的场景下仍然会出问题,因为“获取锁”与“设置过期时间”是两个redis操作,两个redis操作不是原子性的。
可能出现这种情况:就在获取锁之后,设置过期时间之前程序宕机了。锁被获取到了但没有设置过期时间,最后又成为死锁。
解决方案: 获取锁的同时设置过期时间
// 1. 分布式锁占坑
Boolean lock = redisTemplate.opsForValue().setIfAbsent("SysUserLock" + sysUser.getId(), "value", 30, TimeUnit.SECONDS);
问题三:锁过期之后被别的线程重新获取与释放
这个问题出现的场景是:假如某个应用集群化部署存在多个进程实例,实例A、实例B。实例A获取到锁,但是执行过程超时了(数据库层面或其他层面导致操作执行超时)。超时之后锁被自动释放了,实例B获取到锁,并执行业务程序,执行完成之后把锁删除了。
实际上这里还涉及到一个锁的续期的问题,我们后续再说,我们先来看下锁的释放的问题。
解决方案: 在释放锁之前判断一下,这把锁是不是自己的那一把,如果是别人的锁你就不要动。怎么判断这把锁是不是自己的?加锁时为value赋随机值,加锁的随机值等于解锁时的获取到的值,才能证明这把锁是你的。代码如下:
问题四:锁的释放不是原子性的
大家仔细看代码,锁的释放时三个操作,这三个操作不是原子性的。也就是说在高并发的场景下,你刚get到的redis key有可能也被别的线程get了,你刚要删除别的线程可能已经把这个key删除了。
为了解决这个问题,我们可以使用redis lua脚本(lua脚本是在一个事务里面执行的,可以保证原子性)。在Java代码中可以以字符串的形式存在。
String script =
"if redis.call('get', KEYS[1]) == ARGV[1]
then return redis.call('del', KEYS[1])
else
return 0
end";
问题五:其他的问题?
上面我们分析了很多使用redis实现分布式锁可能出现的问题及解决方案,其实在实际的开发应用中还会有更多的问题。比如:
目前我们的程序获取不到锁,就无限的重试,是不是应该在重试一定的次数之后就抛出异常?在有限的时间内通过异常给用户一个友好的响应。比如:程序太忙,请您稍后再试!
程序A没有执行完成,锁定的key就过期了。虽然过期之后会自动释放锁,但是我的程序A的确没有执行完成啊,也没有异常抛出,就是执行的时间比较长,这个时候是不是应该对锁定的key进行续期?
笔者对于分布式锁自动续期的这个功能也不是特别感冒,我觉得程序超过了我们设置的过期时间(比如说60s)一定是出现了问题,如果不是离线大数据批处理,一个程序执行60秒还没完成那一定是出问题了,你给我抛出异常就可以了。对于一个出问题的程序一直续期和死锁没什么区别。
所以实现一个分布式锁,不是我们想的那么简单,在高并发的环境下需要考虑的问题会复杂得多。怎么办?实际上分布式锁的细节时间有很多的现成的解决方案,不用我们去自己实现。比较完整优秀的分布式锁实现包括:
RedisLockRegistry是spring-integration-redis中提供redis分布式锁实现类
基于Redisson实现分布式锁原理(Redission是一个独立的redis客户端,是与Jedis、Lettuce同级别的存在)
对比
RedisLockRegistry通过本地锁(ReentrantLock)和redis锁,双重锁实现;Redission通过Netty Future机制、Semaphore (jdk信号量)、redis锁实现。
RedisLockRegistry和Redssion都是实现的可重入锁。(可重入锁是什么?下节再说)
RedisLockRegistry对锁的刷新没有处理(续期),Redisson通过Netty的TimerTask、Timeout 工具完成锁的定期刷新任务。
下面的章节,笔者为大家介绍RedisLockRegistry实现分布式锁,RedisLockRegistry作为spring-integration-redis与Spring Boot、Spring Data Redis无缝集成,不用自己做封装,简单易用。