在使用Redis实现分布式锁的时候,通常向Redis插入一条key-value数据,key为需要上锁的资源,value可以存放使用该资源的用户等信息。
1.方案一
首先传入需要被锁的资源id和当前操作的用户userId,先判断当前Redis中是否有key为id的数据,如果存在,直接返回false代表该资源已经被人使用;如果不存在再插入键为id,值为userId,有效时间30s的数据,并且返回true。
/**
* 用户进入获取某个key的锁
* 返回false说明获取失败,返回true说明成功
* @return
*/
@RequestMapping("getLock1")
@ResponseBody
public boolean getLock1(Integer id, String userId) {
//如果不存在key为id的数据,则返回false
if (stringRedisTemplate.opsForValue().get(id.toString()) != null) {
return false;
}
//在redis中插入一条键为id,值为userId,有效时间30s的数据
stringRedisTemplate.opsForValue().set(id.toString(), userId, 30, TimeUnit.SECONDS);
return true;
}
该方法其实存在漏洞,比如当两个用户同时进入抢占同一资源,在同一时间查询到Redis中不存在键为id的数据,即认为没有人在使用当前资源,所以都去set数据,而set是可以覆盖的,导致两个用户都看起来上锁成功了,都会返回true。
模拟一下这种情况:
public class Test implements Runnable{
public static ConcurrentLinkedQueue<String> stateQuene= new ConcurrentLinkedQueue<>();
public static void main(String[] args) {
stateQuene.add("user1");
stateQuene.add("user2");
Test test = new Test();
Thread thread1 = new Thread(test);
Thread thread2 = new Thread(test);
thread1.start();
thread2.start();
}
@Override
public void run() {
String userId = stateQuene.poll();
String result = HttpClientUtil.doGet("http://localhost:8080/getLock1?userId="+ userId +"&id=1");
System.out.println(result);
}
}
在以上代码中为了模拟两个用户,创建了user1和user2,并且放入到ConcurrentLinkedQueue中,在线程中使用poll()方法逐个取出。
执行结果:
输出两个true,说明都拿到了锁,解决方法见方案二
2.方案二
Redis本身在set数据时有一个方法是如果不存在才能插入成功,否则会插入失败,查阅stringRedisTemplate的API发现有一个setIfAbsent()方法,修改后的代码:
@RequestMapping("getLock2")
@ResponseBody
public boolean getLock2(Integer id, String userId) {
//如果不存在key为id的数据,则返回false
if (stringRedisTemplate.opsForValue().get(id.toString()) != null) {
return false;
}
//在redis中插入一条键为id,值为userId,有效时间30s的数据,使用setIfAbsent可以在不存在该key的情况下完成插入
boolean res = stringRedisTemplate.opsForValue().setIfAbsent(id.toString(), userId, 30, TimeUnit.SECONDS);
return res;
}
测试结果:
在拿到锁真正执行逻辑代码时,一般会在执行结束后将Redis中的数据进行删除,达到释放锁的目的,详见方案三。
3.方案三
我们在操作之前先调用方案二中的getLock方法。成功后,再执行逻辑代码,我使用sleep()模拟逻辑代码操作所需要的时间,在finally代码块中将锁释放。
@RequestMapping("option1")
@ResponseBody
public void option1(Integer id, String userId) {
//获取锁
boolean flag = getLock2(id, userId);
if (!flag){
System.out.println("用户"+ userId +"获取"+ id +"锁失败");
}else {
System.out.println("用户"+ userId +"获取"+ id +"锁成功");
new Thread(new Runnable() {
@Override
public void run() {
try {
//模拟操作过程需要10秒
for (int i = 10; i > 0; i--){
Thread.sleep(1000);
System.out.println("锁剩余时间"+ i +"秒");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (stringRedisTemplate.delete(id.toString())){
System.out.println("用户"+ userId +"释放"+ id +"锁成功");
}else {
System.out.println("用户"+ userId +"释放"+ id +"锁成功");
}
}
}
}).start();
}
}
使用上面的多线程测试工具,将访问路径替换为option1,访问后控制台打印如下:
其实这样做还有问题,因为真正逻辑进行操作时不一定是10s,有可能是20s,30s,甚至是超过开始设置的redis有效时间(上面设置了30s),所以就存在逻辑代码运行的时间超过了有效时间,自动释放了锁,其他人在此时就可以拿到锁,而在逻辑代码运行完之后又执行了finally代码块,把别人的锁释放掉了,解决方案见方案四。
4.方案四
在加锁的时候设置随机值,并存放到redis中,释放锁的时候匹配到该随机值才可以释放锁。要加入随机值的话,redis的value使用hash格式进行存储较为合理,所以修改后的getLock()方法如下:
public boolean getLock3(Integer id, String userId, String radom) {
//如果不存在key为id的数据,则返回false
if (stringRedisTemplate.opsForValue().get(id.toString()) != null) {
return false;
}
//在redis中插入一条键为id,值为userId和随机值组成的hash,有效时间30s的数据,使用putIfAbsent可以在不存在该key的情况下完成插入
if (stringRedisTemplate.opsForHash().putIfAbsent(id.toString(), "userId", userId)){
stringRedisTemplate.opsForHash().putIfAbsent(id.toString(), "radom", radom);
stringRedisTemplate.expire(id.toString(), 30, TimeUnit.SECONDS);
return true;
}
return false;
}
Redis可视化工具查看效果:
获得锁的时候将随机值保存成变量,最后在finally中判断变量和Redis中存储的随机值是否相同,不相同的话不允许释放锁,将线程中的sleep时间设置成40s,锁自动失效时间是30s,就会发生上述的情况,修改后的代码如下:
@RequestMapping("option2")
@ResponseBody
public void option2(Integer id, String userId) {
//获取锁
String radom = UUID.randomUUID().toString();
boolean flag = getLock3(id, userId, radom);
if (!flag){
System.out.println("用户"+ userId +"获取"+ id +"锁失败");
}else {
System.out.println("用户"+ userId +"获取"+ id +"锁成功");
new Thread(new Runnable() {
@Override
public void run() {
try {
//模拟操作过程需要40秒
for (int i = 40; i > 0; i--){
Thread.sleep(1000);
System.out.println("锁剩余时间"+ i +"秒");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("用户"+ userId +"尝试释放"+ id +"锁...");
if (radom.equals(stringRedisTemplate.opsForHash().get(id.toString(), "radom"))){
if (stringRedisTemplate.delete(id.toString())){
System.out.println("用户"+ userId +"释放"+ id +"锁成功");
}else {
System.out.println("用户"+ userId +"释放"+ id +"锁成功");
}
}else {
System.out.println("锁已不属于用户"+userId);
}
}
}
}).start();
}
}
测试之后控制台打印如下:
锁已经不属于当前用户,但是逻辑代码还没执行完,所以需要加一个判断来延续锁,见方案五。
5.方案五
在逻辑代码中加入每隔10s检测是否超时时间小于等于20s,是的话重新设置超时时间30s,防止锁失效,在这里借用上面倒计时代码,判断i是否是10的整数倍来实现10s判断一次
try {
//模拟操作过程需要40秒
for (int i = 40; i > 0; i--){
Thread.sleep(1000);
System.out.println("锁剩余时间"+ i +"秒");
//每隔十秒查询有效时间是否小于20秒
if (i % 10 == 0 && i != 40){
if (stringRedisTemplate.getExpire(id.toString(), TimeUnit.SECONDS) <=20 ){
stringRedisTemplate.expire(id.toString(), 30, TimeUnit.SECONDS);
}
}
}
}
锁延续成功,并且最后锁也是属于自己的,释放成功。
至此,Redis分布式锁的实现就较为完善了。