一、前言
基于redis实现分布式锁,其实有很多,基于 redisson,基于 jedis,等都可以实现,springBoot 默认提供 redis 操作工具 redisTemplate ,我们可以基于它配合lua 进行实现。
简单业务场景不需要使用 redisson
redisson本身其实是基于lua脚本来保证原子性的,使用redisson需要额外引用依赖,还要单独去配置,还要增加学习成本去了解redisson相关接口,但是如果我们的需求不是那么复杂,没有必要哦,
完美分布式锁的几个条件
1、 互斥性:在任意时刻,只有一个客户端能持有锁,这是最根本的。
2、 原子性:加锁时涉及到,两个操作(setnx 、expire或者是 setex)要不都执行
3、 不会发生死锁:即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
4 、解铃还须系铃人:加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
5、 具有容错性,只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。(可选)
6、公平锁/非公平锁(可选): 公平锁的意思是按照请求的顺序获得锁,非公平锁就是无效的。
7、支持阻塞和非阻塞(可选):非阻塞:获取不到锁,返回错误结构,阻塞:获取不到锁,自旋等待锁
8、高可用(可选):加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。
9、可重入性(可选):同一节点的同一个线程如果获取了锁,可以再次获取锁。
注:
自己写的分布式锁,再优秀,其实都有缺点的,如果追求完美请直接参考redisson,redisson可以解决所有你遇到锁的问题。
二、redis锁 必备知识点
redis指令必备
很多博文上来就贴出代码,会让新手有点懵圈,其实要想深入了解,必先了解redis实现锁的几个指令。
更多指令可参考: http://redisdoc.com/string/set.html
加锁:setnx 、expire(或者是 setex)
释放锁:get、del
返回值的问题:返回值成功有 返回 “OK”,有返回 1 注意区分。
2.1 setnx:
setnx 命令是当key不存在时设置key,但setnx不能同时完成expire设置失效时长,不能保证setnx和expire的原子性。我们可以使用set命令完成setnx和expire的操作,并且这种操作是原子操作。
说明:
SET key value NX 的效果等同于执行 SETNX key value
为什么使用 setnx 比较好,set key value nx 返回值是 nil 不友好
含义:
将 key 的值设为 value ,当且仅当 key 不存在。 若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。
返回值:
设置成功,返回 1 。 设置失败,返回 0 。
2.2 expire:
含义:
指定key 设置失效时间默认单位是秒。如果想要获取剩余过期时间可以用过 TTL指令
返回值
设置成功,返回 1 。 设置失败,返回 0 。
2.3 setex:
含义:
Setex 命令为指定的 key 设置值及其过期时间。如果 key 已经存在, SETEX 命令将会替换旧的值。
说明:
设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。
我认为:
网上很多帖子用 setnx 和 setex 实现,其实都还好,使用效果一样的。
与 expire 相比 setex 会 重新设置key的值。
如果我们的锁后面考虑续约的话,就用expire 比较好。
我认为:
网上很多帖子用 setnx 和 setex 实现,其实都还好,使用效果一样的。
与 expire 相比 setex 会 重新设置key的值。
如果我们的锁后面考虑续约的话,就用expire 比较好。
2.4 setpx:
含义:
设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。
lua 脚本需要简单了解
-
KEYS[1]
-
ARGV[1]
-
KEYS[1] 用来表示在redis 中用作键值的参数占位,主要用來传递在redis 中用作keyz值的参数。
-
ARGV[1]用来表示在redis 中用作参数的占位,主要用来传递在redis中用做 value值的参数。
三、干货来袭
依赖就一个,redis 配置跟随系统,无效额外配置。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
这里实现的并不是完美锁,但是一般场景可用
。
3.1 通用接口
定义 加锁/加锁接口。
public interface RedisLuaLock {
/**
* 加锁
* @param key key
* @param value 值用于解锁时判断
* @return Boolean
*/
Boolean tryLock(String key, String value);
/**
* 自定义加锁过期时间
* @param key key
* @param value 值用于解锁时判断
* @param time 默认单位是 秒
* @return Boolean
*/
Boolean tryLock(String key, String value, Integer time);
/**
* 释放锁操作
* @param key
* @param value
* @return
*/
Boolean releaseLock(String key, String value);
}
3.2 加锁解锁实现细节
import com.aaa.mybatisplus.config.redis.DelayTask;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* redisTemplate 基于 lua 实现分布式锁 版本一
* @author liuzhen.tian
*/
@Slf4j
@Component
public class RedisLuaLockImpl implements RedisLuaLock{
@Autowired
private RedisTemplate redisTemplate;
private DefaultRedisScript<Boolean> tryLockScript;
private DefaultRedisScript<Boolean> releaseLockScript;
/**
*默认加锁时间 10s
*/
public static final Integer DEFAULT_TIME =10;
@PostConstruct
public void initLUA() {
tryLockScript = new DefaultRedisScript();
tryLockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/lock.lua")));
tryLockScript.setResultType(Boolean.class);
releaseLockScript = new DefaultRedisScript();
releaseLockScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/unlock.lua")));
releaseLockScript.setResultType(Boolean.class);
}
@Override
public Boolean tryLock(String key, String value) {
return tryLock( key, value, DEFAULT_TIME);
}
/**
* 获取lua结果
* @param key key
* @param value 值用于解锁时判断
* @param time 默认单位是 秒
* @return Boolean
*/
@Override
public Boolean tryLock(String key,String value,Integer time) {
// 封装参数
List<String> keyList = Arrays.asList(key,String.valueOf(time),value);
Boolean result= (Boolean)redisTemplate.execute(tryLockScript, keyList);
// 使用下面的也可以,下面这个是基于 事务也能保证原子性,出于效率问题,还是使用lua 进行加锁。
// Boolean result = redisTemplate.opsForValue().setIfAbsent(key, value, Long.parseLong(time), TimeUnit.SECONDS);
log.info("redis set result:"+result);
return result;
}
/**
* 释放锁操作
* @param key
* @param value
* @return
*/
@Override
public Boolean releaseLock(String key, String value) {
// 封装参数
List<Object> keyList = new ArrayList();
keyList.add(key);
keyList.add(value);
Boolean result = (Boolean) redisTemplate.execute(releaseLockScript, keyList);
return result;
}
}
3.3 lua 脚本
在resources 下面新建一个 luascript文件夹,新建 lock.lua,unlock.lua 即可。
为什么使用lua脚本呢,
为啥不用 redistemplate 的 方法去实现呢,为了保证加锁,原子性,怎么说,加锁实际上是俩个操作,设置值和设置失效时间。如果多个线程并发过来,会执行错乱。
加锁脚本 lock.lua
-- 加锁脚本
local lockKey = KEYS[1]
local lockTime = KEYS[2]
local lockValue = KEYS[3]
local result_1 = redis.call('SETNX', lockKey, lockValue)
if result_1 == 1
then
local result_2 = redis.call('expire', lockKey,lockTime)
return result_2
else
return 0
end;
解锁脚本 unlock.lua
-- 解锁脚本
local lockKey = KEYS[1]
local lockValue = KEYS[2]
local result_1 = redis.call('get', lockKey)
if result_1 == lockValue
then
local result_2 = redis.call('del', lockKey)
return result_2
else
return 0
end;
3.4 测试
测试的话,大家就比较随意了,你可以在controller里面写成接口,通过压测工具譬如 jmeter 去压测接口,也可以使用java 实现 多线程测试。
基于jdk 的 CountDownLatch 和 Semaphore,我们可以直接去使用代码去压测我们的锁。
@Slf4j
@SpringBootTest
public class RedisLuaTest {
@Autowired
// @Qualifier("redisLuaLockImplV2")
@Qualifier("redisLuaLockImpl")
RedisLuaLock redisLuaLock;
// 总的请求个数
public static final int requestTotal = 20;
// 同一时刻最大的并发线程的个数
public static final int concurrentThreadNum = 20;
@Test
public void mainTest() throws InterruptedException {
ExecutorService executorService = Executors.newCachedThreadPool();
CountDownLatch countDownLatch = new CountDownLatch(requestTotal);
Semaphore semaphore = new Semaphore(concurrentThreadNum);
for (int i = 0; i< requestTotal; i++) {
executorService.execute(()->{
try {
semaphore.acquire();
//执行的方法
lockLuaTest();
semaphore.release();
} catch (InterruptedException e) {
log.error("exception", e);
}
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
log.info("请求完成");
}
/**
* 执行测试的方法
*/
private void lockLuaTest() {
String value = UUID.randomUUID().toString();
try {
Boolean aaa = redisLuaLock.tryLock("aaa", value, 10);
if (!aaa) {
System.out.println("已经加锁,请等待!");
}else {
Thread.sleep(6*1000);
System.err.println("首先执行的线程");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
redisLuaLock.releaseLock("aaa", value);
}
}
}
3.5 测试结果
同时释放20个线程去获取锁,最终只有一个线程,拿到锁。
四、总结
抛开实现完美锁的其他条件,上面代码实现了,原子性,一致性,不会发生死锁,理论上已经满足很多场景使用。
小缺陷:
其实这个还有小缺陷的,但是没有异步续约,也就是锁超时问题,虽然我们加锁方法可以设置带超时时间的方法。但是如果我们加锁的方法,其业务逻辑执行的时间超过了,我们设置的超时时间,怎么办呢。
解决方法:
参考redisson中看门狗的机制,在加锁的时候,开启一个守护线程进行异步续约,本次代码没有列出来,具体可以参考我的github。