一、前言
Redis实现的分布式锁被大家广泛用于解决在分布式环境下的并发问题——使用set NX EX,当某一个key存在时,返回失败,当key不存在时,设置新值和过期时间,返回成功。
那么如何通过Redis实现一个可重入的分布式锁呢?
二、解决思路
我们可以参考一下Java中ReentrantLock的实现,在持有锁时记录下线程信息,获取锁时检查线程id是否相同,那么在Redis中也可参考相同实现:
- 1、获取锁信息;
- 2、比较持有线程ID;
- 3、更新锁信息。
此时就出现了并发问题,需要先比较在更新,Redis并未提供CAS原子性命令,需要借助Redis的其它特性解决,下面为大家介绍两种方法。
三、基于Redis事务
1、简单介绍
redis支持了简单的事务,提供了以下几个命令:
- WATCH:监控某些键值对;
- MULTI:用于开启一个事务;
- EXEC:执行事务;
- DISCARD:取消事务;
- UNWATCH:取消监控。
通过watch命令,实现对某些Key的监听,当一个事务提交时,会优先检测监听的key是否发生改变,如果已发生改变,取消事务,若并未改变,执行事务。
2、jedis实现
public boolean tryLock(String key, int timeout, String threadId){
Transaction multi = null;
try {
// 监控key
jedis.watch(key);
// 获取锁信息
String lock = jedis.get(key);
// 已持有锁且不是当前线程,获取锁失败
if (StringUtils.isNotEmpty(lock) && !lock.equals(threadId)) {
return false;
}
// 开启事务
multi = jedis.multi();
// 添加命令
multi.setex(key, timeout, threadId);
// 执行事务
multi.exec();
return true;
} catch (Exception e) {
if (Objects.nonNull(multi)) {
multi.discard();
}
return false;
} finally {
jedis.unwatch();
}
}
注意:
- redis提供的事务,中间一条命令执行失败,并不会导致前面已经执行的指令回滚,也不会造成后续的指令不做
- WATCH监视了一个带过期时间的键,那么即使这个键过期了,事务仍然可以正常执行;
- WATCH机制不存在ABA问题。
3、WATCH机制原理
redis中存在一个字典,用于保存所有被监视的key和其对应监视的客户端列表,字典的键是被监视的key,而值则是监视其的客户端链表。
- 当某一个客户端通过watch添加对key的监视时,会在字典中检索出对应的链表,将其添加到末尾,保存下监视状态;
- 当redis对任何key执行修改命令之后,都会检查当前key是否在watch字典中处于被监视状态,若存在,则将监视其的客户端节点中的状态标记为已修改;
- 当客户端提交事务时,通过查询watch字典中监视的key,其对应的客户端节点状态是否修改,决定是否需要执行事务。
四、基于LUA
1、简单介绍
Lua是一种小巧的脚本语言,redis提供了对lua脚本执行的能力。通过将多个请求通过脚本的形式一次发送,redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。
- EVAL
EVAL script numkeys key [key …] arg [arg …]
向redis发送具体的脚本内容和参数,完成脚本的执行。
- SCRIPTLOCAD 和 EVALSHA
// 预加载脚本,返回其对应的sha1值
SCRIPTLOCAD script
// 提交脚本对应的sha1值和参数,执行脚本
EVALSHA sha1 numkeys key [key …] arg [arg …]
redis具有缓存能力,提前将脚本语句在redis缓存,后续只需发送脚本对应的sha1值,有效的减少带宽的消耗。
2、jedis实现
public class QueueStateOperateClient {
private static final Object LOCK_OBJECT = new Object();
private static volatile String lockSha;
private static volatile String unLockSha;
public static Response lock(Pipeline pipeline, String key) {
// 脚本检查
preCheck();
return pipeline.evalsha(lockSha, 2, new String[]{setSuffix(key), setSuffix(QueueConstant.LOCAL_HOST)});
}
public static Response unlock(Pipeline pipeline, String key) {
preCheck();
return pipeline.evalsha(unLockSha, 2, new String[]{setSuffix(key), setSuffix(QueueConstant.LOCAL_HOST)});
}
public static void preCheck() {
// 脚本是否已经加载
if (StringUtils.isNotBlank(lockSha) && StringUtils.isNotBlank(unLockSha)) {
return;
}
synchronized (LOCK_OBJECT) {
if (StringUtils.isNotBlank(lockSha) && StringUtils.isNotBlank(unLockSha)) {
return;
}
// 加载script
SuishenRedisTemplate queueRedisTemplate = (SuishenRedisTemplate) SourceEventQueueManager
.getApplicationContext().getBean("queueRedisTemplate");
new SuishenRedisExecutor<Boolean>().exe(jedis -> {
lockSha = jedis.scriptLoad(getLockScript());
unLockSha = jedis.scriptLoad(getUnLockScript());
return true;
}, queueRedisTemplate);
}
}
private static String getLockScript() {
return "local ip = redis.call(\"get\",KEYS[1]);" +
"if (not ip) or ip==KEYS[2] " +
"then " +
" return redis.call(\"setex\",KEYS[1],10,KEYS[2]);" +
"else " +
" return \"FAIL\"" +
"end ";
}
private static String getUnLockScript() {
return "local ip = redis.call(\"get\",KEYS[1]);" +
"if ip and ip==KEYS[2] " +
"then " +
" redis.call(\"del\",KEYS[1]);" +
"end " +
"return \"OK\"";
}
/**
* 集群redis设置分片规则
*
* @param key
* @return
*/
private static String setSuffix(String key) {
return key + "{queue}";
}
}
3、扩展知识
a、分布式redis
对于分布式redis,执行lua时,需要保证所有的key均在同一分片下才可正确的执行,当key中存在{}时,分布式redis只会对{}中的字符进行分片规则计算,通过这种方式,可以保证不同的key均在同一分片下。
b、redis.clients.jedis.exceptions.JedisDataException: NOSCRIPT No matching script. Please use EVAL异常
使用EVALSHA时,如果当前sha1对应的脚本在redis中不存在时,会抛出此异常,常见的场景:
- redis因为某种原因重启;
- 分布式redis出现扩容,导致新的节点未缓存脚本;
当发现此异常时,需要重新SCRIPTLOAD脚本,加入redis缓存。