目录
集群下一人一单的线程并发安全问题:
集群模式下用synchronized并不能解决并发安全问题,因为集群模式下是多个JVM,对应的是多个锁监视器。
分布式锁
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
基于redis实现分布式锁
实现分布式锁需要实现两个基本的方法:
- 获取锁
- 释放锁
改进Redis的分布式锁
上述做法会出现的问题:当线程一业务阻塞锁提前释放,线程二开始工作,线程一恢复后完成业务释放锁,释放的是线程二的锁,锁被线程三拿到。
解决办法:释放锁的时候去判断标识,是不是自己所拥有的锁。
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
public void unlock() {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标示是否一致
if(threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
上述的改进依然存在问题:
这一次的线程一的阻塞发生在判断完标识之后线程一执行释放锁之前,接着超时释放锁,线程二获取锁,此时线程一恢复,因为前面已经判断了标识,所以可以释放锁,导致线程三可以趁虚而入
解决办法:判断完标识和释放锁成为原子性的操作
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
上述改进后仍然存在的问题:
- 不可重入:在方法A中获取了锁,同时调用了方法B,在方法B又要获取锁,导致死锁问题
- 不可重试:获取锁只尝试一次就返回false,没有重试机制
- 超时释放:锁超时释放虽然可以避免死锁(误伤操作),但如果业务执行耗时较长,也会导致锁释放,存在安全隐患。
- 主从一致:如果redis提供了主从集群,主从同步存在延迟,线程在主节点执行写操作并获取了锁,当主宕机时,如果从节点还未与主节点同步,此时没有锁标识,就会出现死锁
Reidsson
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.226.128:6379").setPassword("123456");
// 创建RedissonClient对象
return Redisson.create(config);
}
Redisson可重入锁原理
问题出现情况如上述一般:在方法A中获取了锁,同时调用了方法B,在方法B又要获取锁,导致死锁问题
参考Java中的renentrelock:
- 获取锁的时候,发现锁已经有人获取了,就去看看是不是自己(即是不是同一个线程),如果是就计数++(重入次数)。利用redis的hash结构,value中存储线程名称和重入次数。
local key = KEYS[1]; -- 锁的key local threadId = ARGV[1]; -- 线程唯一标识 local releaseTime = ARGV[2]; -- 锁的自动释放时间 -- 判断是否存在 if(redis.call('exists', key) == 0) then -- 不存在, 获取锁 redis.call('hset', key, threadId, '1'); -- 设置有效期 redis.call('expire', key, releaseTime); return 1; -- 返回结果end; -- 锁已经存在,判断threadId是否是自己 if(redis.call('hexists', key, threadId) == 1) then -- 不存在, 获取锁,重入次数+1 redis.call('hincrby', key, threadId, '1'); -- 设置有效期 redis.call('expire', key, releaseTime); return 1; -- 返回结果end; return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
- 释放锁的时候不能像之前那样直接删除锁,而是把重入次数减一,当重入次数为0时,此时释放锁就删除锁
local key = KEYS[1]; -- 锁的key local threadId = ARGV[1]; -- 线程唯一标识 local releaseTime = ARGV[2]; -- 锁的自动释放时间 -- 判断当前锁是否还是被自己持有 if (redis.call('HEXISTS', key, threadId) == 0) then return nil; -- 如果已经不是自己,则直接返回end; -- 是自己的锁,则重入次数-1 local count = redis.call('HINCRBY', key, threadId, -1); -- 判断是否重入次数是否已经为0 if (count > 0) then -- 大于0说明不能释放锁,重置有效期然后返回 redis.call('EXPIRE', key, releaseTime); return nil; else -- 等于0说明可以释放锁,直接删除 redis.call('DEL', key); return nil; end;
Redisson的锁重试和WatchDog机制
解决问题:
- 不可重试:获取锁只尝试一次就返回false,没有重试机制
- 超时释放:锁超时释放虽然可以避免死锁(误伤操作),但如果业务执行耗时较长,也会导致锁释放,存在安全隐患。
- 主从一致:如果redis提供了主从集群,主从同步存在延迟,线程在主节点执行写操作并获取了锁,当主宕机时,如果从节点还未与主节点同步,此时没有锁标识,就会出现死锁
分布式锁解决上述问题的方式总结
Redisson分布式锁主从一致性问题
总结:
1)不可重入Redis分布式锁:
原理:利用
setnx
的互斥性;利用
ex
避免死锁;释放锁时判断线程标示
缺陷:不可重入、无法重试、锁超时失效
2)可重入的Redis分布式锁:
原理:利用
hash
结构,记录线程标示和重入次数;利用
watchDog
延续锁时间;利用信号量控制锁重试等待
缺陷:
redis
宕机引起锁失效问题
3)Redisson的multiLock:
原理:多个独立的
Redis
节点,必须在所有节点都获取重入锁,才算获取锁成功
缺陷:运维成本高、实现复杂