1 什么是分布式锁
在单体应用中,线程锁是可以让多个线程串行执行一段代码逻辑的。不过在集群环境或者是分布式的环境下,线程锁无法保证线程串行运行,从而出现线程安全的问题。
根本的原因在于,在 集群分布式环境下 \textcolor{red}{集群分布式环境下} 集群分布式环境下,用于确保线程串行运行的线程监视器有多个。因为服务如果是分布式的部署,那么一定是在多个JVM中运行的。每个JVM中都将维护自己的堆栈空间。线程监视器同样如此。每个线程监视器都有可能被线程键入, 所以集群、分布式环境下线程锁无线确保线程安全 \textcolor{blue}{所以集群、分布式环境下线程锁无线确保线程安全} 所以集群、分布式环境下线程锁无线确保线程安全。
所以在这种情况下,需要使用分布式锁。
分布式锁是在集群或者分布式环境下,多进程【线程】可见且互斥的锁。具有以下几个特点。
- 高可用的获取锁与释放锁;
- 高性能的获取锁与释放锁;
- 具备锁失效机制,防止死锁;
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
2 Redis互斥指令实现分布式锁
实现分布式锁有多种方案,如关系型数据库。可在数据库表中维护一张业务锁信息。但是其性能受到数据库性能的影响。而redis数据库是基于内存的数据库,且可集群部署,所以满足高性能和高可用。在互斥与可见方面,可利用setnx这种互斥指令来实现。
另外为了避免服务宕机导致死锁的问题,可以利用redis 的key ttl 时间,超时时可自动删除,防止死锁。总的来说。选用Redis数据库来实现分布式锁是一个有效的方案。
从该流程可看出,redis实现分布式锁分为两步:获得锁和释放锁。初步实现如下
public class DistributeLock {
private StringRedisTemplate stringRedisTemplate;
//具体业务实现锁时,构造函数传入StringRedisTemplate 操作redis数据库
public DistributeLock(StringRedisTemplate stringRedisTemplate){
this.stringRedisTemplate=stringRedisTemplate;
}
/**
* 获得分布式锁 [基于不同的业务]
* @param serviceName 业务名称
* @param time 安全机制 设置过期时间,避免服务宕机时出现死锁问题
* @param unit 过期时间单位
* @return
*/
public boolean getLock(String serviceName, long time, TimeUnit unit){
String key=RedisConstants.DISTRIBUTE_LOCK+serviceName;
//set nx ex指令。set key成功表示获得锁成功
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, key, time, unit);
//避免拆箱时 空指针问题
return Boolean.TRUE.equals(aBoolean);
}
/**
* 释放分布式锁 [基于不同的业务]
* @param serviceName 业务名称
* @return
*/
public void delLock(String serviceName ){
String key=RedisConstants.DISTRIBUTE_LOCK+serviceName;
//释放锁
stringRedisTemplate.delete(key);
}
2.1 Redis分布式锁超时被误删除问题
上述分布式锁是否完美了呢。显然是可以待优化的。因为上面的锁实现会出现业务超时,线程误删除锁的问题。线程时序图如下
由于在获得锁的时候,设置key 的过期时间。如果在执行业务时阻塞超时,甚至超过了设置的key过期时间,在业务还没执行完成时,由于redis的过期机制,锁自动就释放了。那么其他的线程就可以获得这个锁。同样的当前一个线程业务执行完成后,在释放锁的时候可能会释放其他线程持有的锁,这样会出现在集群环境下分布式锁带来的线程安全问题。
这里主要问题就是线程会将其他线程持有的锁给删除掉。这里可以在获得锁的时候存入线程标识,删除前判定线程标识是否为当前持有锁的线程。减少锁误删的问题。优化锁的逻辑如下:
public class DistributeLock {
private StringRedisTemplate stringRedisTemplate;
public DistributeLock(StringRedisTemplate stringRedisTemplate){
this.stringRedisTemplate=stringRedisTemplate;
}
private static final String ID_PREFIX= UUID.randomUUID().toString(true)+":";
/**
* 获得分布式锁 [基于不同的业务]
* @param serviceName 业务名称
* @param time 安全机制 设置过期时间,避免服务宕机时出现死锁问题
* @param unit 过期时间单位
* @return
*/
public boolean getLock(String serviceName, long time, TimeUnit unit){
String key=RedisConstants.DISTRIBUTE_LOCK+serviceName;
long threadId=Thread.currentThread().getId();
//线程标识 线程标识为UUID+线程ID 的组合
String threadMark=ID_PREFIX+threadId;
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, threadMark, time, unit);
//避免拆箱时 空指针问题
return Boolean.TRUE.equals(aBoolean);
}
/**
* 释放分布式锁 [基于不同的业务]
* @param serviceName 业务名称
* @return
*/
public void delLock(String serviceName ){
String key=RedisConstants.DISTRIBUTE_LOCK+serviceName;
//获得上锁时存入的线程标识信息
String value=stringRedisTemplate.opsForValue().get(key);
//判断是否与存入的线程标识一致
String myThreadMark=ID_PREFIX+Thread.currentThread().getName();
if(value.equals(myThreadMark)){
//标识一致时,释放线程的锁,避免误释放其他的锁
stringRedisTemplate.delete(key);
}
}
}
2.2 Redis分布式锁因为非原子性被误删除
优化后,还会出现线程不安全的问题吗。在某些极端情况下,还是会出现线程锁误删,线程不安全的情形。分析时序图如下:
因为线程一在业务完成后删除锁的阶段中,在判断属于自己的锁后准备删除锁时发生了阻塞,导致原本持有的锁释放。线程二从而抢到锁执行业务逻辑,不过在此阶段中线程一抢到了时间片,继续执行删除锁的逻辑,从而删除了本该线程二持有的锁。此后线程三抢到锁,出现了线程二和线程三并行执行的情况,线程不安全。
经过分析可知,出现锁误删的源头在于,极端情形下,判断锁与删除锁的步骤不是原子操作的,那么就有可能出现线程并行的问题。这里采用的解决方案是在删除锁时将多个命令写在Lua脚本中,然后通过redis 执行Lua脚本。因为lua脚本能保证命令执行的原子性。
删除锁的 L u a 脚本通常可放在 x x x . l u a 文件中,如下所示为 u n l o c k . l u a ,该文件可放在 c l a s s p a t h 路径下: \textcolor{red}{删除锁的Lua脚本通常可放在xxx.lua文件中,如下所示为unlock.lua,该文件可放在classpath 路径下:} 删除锁的Lua脚本通常可放在xxx.lua文件中,如下所示为unlock.lua,该文件可放在classpath路径下:
-- 释放锁的lua脚本
-- 成功释放返回1,没有释放返回0
-- 判断线程锁标识是否一致
if(redis.call('get','KEYS[1]')==ARGV[1])
--一致则删除锁数据
then redis.call('del',KEYS[1])
end
return 0
优化代码如下:
public class DistributeLock {
private StringRedisTemplate stringRedisTemplate;
public DistributeLock(StringRedisTemplate stringRedisTemplate){
this.stringRedisTemplate=stringRedisTemplate;
}
private static final String ID_PREFIX= UUID.randomUUID().toString(true)+":";
//释放锁的一个脚本
private static DefaultRedisScript<Integer> redisScript;
//初始化删除锁脚本,避免每次调用Lua脚本都要去读取IO产生的延迟问题,
// static静态代码块只加载一次
static {
redisScript = new DefaultRedisScript<>();
redisScript.setResultType(Integer.class);//返回类型是Int
//lua文件存放在resources目录下的redis文件夹内
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("/unlock.lua")));
}
/**
* 获得分布式锁 [基于不同的业务]
* @param serviceName 业务名称
* @param time 安全机制 设置过期时间,避免服务宕机时出现死锁问题
* @param unit 过期时间单位
* @return
*/
public boolean getLock(String serviceName, long time, TimeUnit unit){
String key=RedisConstants.DISTRIBUTE_LOCK+serviceName;
long threadId=Thread.currentThread().getId();
//线程标识
String threadMark=ID_PREFIX+threadId;
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, threadMark, time, unit);
//避免拆箱时 空指针问题
return Boolean.TRUE.equals(aBoolean);
}
/**
* 释放分布式锁 [基于不同的业务]
* @param serviceName 业务名称
* @return
*/
public void delLock(String serviceName ){
//执行LUA脚本
//泛型T为脚本返回的类型
//<T> T execute(RedisScript<T> var1, List<K> var2, Object... var3);
//var1lua 脚本
//var2 keys
//vars value 信息
String key=RedisConstants.DISTRIBUTE_LOCK+serviceName;
String myThreadMark=ID_PREFIX+Thread.currentThread().getName();
Integer result = stringRedisTemplate.execute(redisScript, Arrays.asList(key), myThreadMark);
}
}