其他
老大吩咐的可重入分布式锁,终于完美的实现了~
The following article is from 程序通事 Author 楼下小黑哥
1、《往期精选优秀博文都在这里了!》 2、多线程到底该设置多少个线程? 3、摆摊吧,程序员! 4、Lombok 的爱恨情仇 5、造了一个 Redis 分布锁的轮子,没想到还学到这么多东西!!!
重做永远比改造简单
可重入
基于 ThreadLocal 实现方案
基于 Redis Hash 实现方案
可重入
b();
}
public synchronized void b() {
// pass
}
基于 ThreadLocal 实现方案 基于 Redis Hash 实现方案
基于 ThreadLocal 实现方案
实现方式
ThreadLocal
可以使每个线程拥有自己的实例副本,我们可以利用这个特性对线程重入次数进行计数。ThreadLocal
的全局变量 LOCKS
,内存存储 Map
实例变量。ThreadLocal
获取自己的 Map
实例,Map
中 key
存储锁的名称,而 value
存储锁的重入次数。* 可重入锁
*
* @param lockName 锁名字,代表需要争临界资源
* @param request 唯一标识,可以使用 uuid,根据该值判断是否可以重入
* @param leaseTime 锁释放时间
* @param unit 锁释放时间单位
* @return
*/
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
Map<String, Integer> counts = LOCKS.get();
if (counts.containsKey(lockName)) {
counts.put(lockName, counts.get(lockName) + 1);
return true;
} else {
if (redisLock.tryLock(lockName, request, leaseTime, unit)) {
counts.put(lockName, 1);
return true;
}
}
return false;
}
* 解锁需要判断不同线程池
*
* @param lockName
* @param request
*/
public void unlock(String lockName, String request) {
Map<String, Integer> counts = LOCKS.get();
if (counts.getOrDefault(lockName, 0) <= 1) {
counts.remove(lockName);
Boolean result = redisLock.unlock(lockName, request);
if (!result) {
throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName:+" + lockName + " with request: "
+ request);
}
} else {
counts.put(lockName, counts.get(lockName) - 1);
}
}
Map
中锁对应的 key,然后再到 Redis 释放锁。ThreadLocal
使用过程要记得及时清理内部存储实例变量,防止发生内存泄漏,上下文数据串用等问题。ThreadLocal
写的 Bug。相关问题
ThreadLocal
这种本地记录重入次数,虽然真的简单高效,但是也存在一些问题。ThreadLocal
的方案仅仅只能满足同一线程重入,无法解决不同线程/进程之间重入问题。基于 Redis Hash 可重入锁
实现方式
ThreadLocal
的方案中我们使用了 Map
记载锁的可重入次数,而 Redis 也同样提供了 Hash (哈希表)这种可以存储键值对数据结构。所以我们可以使用 Redis Hash 存储的锁的重入次数,然后利用 lua
脚本判断逻辑。---- 0 代表 false
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end ;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end ;
return 0;
exists
命令判断当前 lock 这个锁是否存在。hincrby
创建一个键为 lock
hash 表,并且为 Hash 表中键为 uuid
初始化为 0,然后再次加 1,最后再设置过期时间。hexists
判断当前 lock
对应的 hash 表中是否存在 uuid
这个键,如果存在,再次使用 hincrby
加 1,最后再次设置过期时间。String lockLuaScript = IOUtils.toString(ResourceUtils.getURL("classpath:lock.lua").openStream(), Charsets.UTF_8);
lockScript = new DefaultRedisScript<>(lockLuaScript, Boolean.class);
/**
* 可重入锁
*
* @param lockName 锁名字,代表需要争临界资源
* @param request 唯一标识,可以使用 uuid,根据该值判断是否可以重入
* @param leaseTime 锁释放时间
* @param unit 锁释放时间单位
* @return
*/
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
long internalLockLeaseTime = unit.toMillis(leaseTime);
return stringRedisTemplate.execute(lockScript, Lists.newArrayList(lockName), String.valueOf(internalLockLeaseTime), request);
}
StringRedisTemplate
即可。-- 如果为 0 代表 该可重入 key 不存在
if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
return nil;
end ;
-- 计算当前可重入次数
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1);
-- 小于等于 0 代表可以解锁
if (counter > 0) then
return 0;
else
redis.call('del', KEYS[1]);
return 1;
end ;
return nil;
hexists
判断 Redis Hash 表是否存给定的域。nil
。hincrby
使可重入次数减 1 ,然后判断计算之后可重入次数,若小于等于 0,则使用 del
删除这把锁。String unlockLuaScript = IOUtils.toString(ResourceUtils.getURL("classpath:unlock.lua").openStream(), Charsets.UTF_8);
unlockScript = new DefaultRedisScript<>(unlockLuaScript, Long.class);
/**
* 解锁
* 若可重入 key 次数大于 1,将可重入 key 次数减 1 <br>
* 解锁 lua 脚本返回含义:<br>
* 1:代表解锁成功 <br>
* 0:代表锁未释放,可重入次数减 1 <br>
* nil:代表其他线程尝试解锁 <br>
* <p>
* 如果使用 DefaultRedisScript<Boolean>,由于 Spring-data-redis eval 类型转化,<br>
* 当 Redis 返回 Nil bulk, 默认将会转化为 false,将会影响解锁语义,所以下述使用:<br>
* DefaultRedisScript<Long>
* <p>
* 具体转化代码请查看:<br>
* JedisScriptReturnConverter<br>
*
* @param lockName 锁名称
* @param request 唯一标识,可以使用 uuid
* @throws IllegalMonitorStateException 解锁之前,请先加锁。若为加锁,解锁将会抛出该错误
*/
public void unlock(String lockName, String request) {
Long result = stringRedisTemplate.execute(unlockScript, Lists.newArrayList(lockName), request);
// 如果未返回值,代表其他线程尝试解锁
if (result == null) {
throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName:+" + lockName + " with request: "
+ request);
}
}
Long
。这里之所以没有跟加锁一样使用 Boolean
,这是因为解锁 lua 脚本中,三个返回值含义如下:1 代表解锁成功,锁被释放 0 代表可重入次数被减 1 null
代表其他线程尝试解锁,解锁失败
Boolean
,Spring-data-redis 进行类型转换时将会把 null
转为 false,这就会影响我们逻辑判断,所以返回类型只好使用 Long
。JedisScriptReturnConverter
:相关问题
spring-data-redis
也没关系,可以使用如下方式,直接使用原生 Jedis 连接执行 lua 脚本。long internalLockLeaseTime = unit.toMillis(leaseTime);
Boolean result = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> {
Object innerResult = eval(connection.getNativeConnection(), lockScript, Lists.newArrayList(lockName), Lists.newArrayList(String.valueOf(internalLockLeaseTime), reentrantKey));
return convert(innerResult);
});
return result;
}
private Object eval(Object nativeConnection, RedisScript redisScript, final List<String> keys, final List<String> args) {
Object innerResult = null;
// 集群模式和单点模式虽然执行脚本的方法一样,但是没有共同的接口,所以只能分开执行
// 集群
if (nativeConnection instanceof JedisCluster) {
innerResult = evalByCluster((JedisCluster) nativeConnection, redisScript, keys, args);
}
// 单点
else if (nativeConnection instanceof Jedis) {
innerResult = evalBySingle((Jedis) nativeConnection, redisScript, keys, args);
}
return innerResult;
}
Jedis#eval
返回 Object
,我们需要具体根据 Lua 脚本的返回值的,再进行相关转化。这其中就涉及到 Lua 数据类型转化为 Redis 数据类型。true
将会转为 Redis 整数 1。而 Lua 中 false
并不是转化整数,而是转化 null 返回给客户端。总结
ThreadLocal
实现方案,这种方案实现简单,运行也比较高效。但是若要处理锁过期的问题,代码实现就比较复杂。ThreadLocal
的缺陷,但是代码实现难度稍大,需要熟悉 Lua 脚本,以及Redis 一些命令。另外使用 spring-data-redis 等操作 Redis 时不经意间就会遇到各种问题。帮助
往期热门文章:
3、他来了!IDEA 2020.1 新版介绍!不过升级前请注意避坑!