前言:分布式锁一般有三种实现方式:1. 数据库乐观锁;2. 基于Redis的分布式锁;3. 基于ZooKeeper的分布式锁。本篇博客将介绍第二种方式,基于Redis实现分布式锁
组件依赖
首先我们要通过Maven引入Jedis开源组件,在pom.xml文件加入下面的代码:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
代码实现:
先展示代码,再慢慢指出代码的不足处以及优化方式
public class RedislockApplication {
public static void main(String[] args) {
LockService lockService=new LockService();
for (int i=0;i<10;i++){
new ThreadRedis(lockService).start();
}
}
}
public class LockRedis {
private ThreadLocal<Jedis> redisThreadLocal = new ThreadLocal();
//redis线程池
private JedisPool jedisPool;
private static final String REDISLOCKKEY="REDIS_KEY";
public LockRedis(JedisPool jedisPool){
this.jedisPool=jedisPool;
}
/**
* redis实现分布式锁 有两个超时 时间问题
* 两个超时时间含义:<br>
* 1.在获取锁之前的超时时间----在尝试获取锁的时候,如果在规定的时间内还没有获取锁,直接放弃。<br>
* 2.在获取锁之后的超时时间---当获取锁成功之后,对应的key 有对应有效期,对应的key 在规定时间内进行失效
*/
/**
* 获取锁
* @param acquireTimeout 在获取锁之前的超时时间
* @param timeout 在获取锁之后的超时时间
* @return
*/
public String getRedisLockKey(Long acquireTimeout,Long timeout){
Jedis conn=null;
try {
//1.建立连接
conn=jedisPool.getResource();
//redisThreadLocal.set(conn);
//2.定义 redis 对应key 的value值( uuid) 作用 释放锁 随机生成value
String value= UUID.randomUUID().toString();
//3.定义在获取锁之后的超时时间
int expireLock=(int) (timeout/1000);//以秒为单位
if(conn.setnx(REDISLOCKKEY,value)==1){
//设置对应key的有效期
conn.expire(REDISLOCKKEY,expireLock);
return value;
// 为什么获取锁之后,还要设置锁的超时时间 目的是为了防止死锁
}
}catch (Exception e) {
e.printStackTrace();
} finally {
if (conn != null) {
conn.close();
}
}
return null;
}
/**
* 释放redis锁
* 释放锁有两种 key自动有有效期
*整个程序执行完毕情况下,删除对应key
* @param value
*/
public void unRedisLock(String value){
Jedis conn=null;
try {
conn=jedisPool.getResource();
if(conn.get(REDISLOCKKEY).equals(value)){
Long result=conn.del(REDISLOCKKEY);
if(result==1){
System.out.println("释放锁。。。"+Thread.currentThread().getName()+",value:"+value);
}
}
}catch (Exception e){
}finally {
if (conn != null) {
conn.close();
}
}
}
}
public class LockService {
private static JedisPool pool = null;
static{
JedisPoolConfig config = new JedisPoolConfig();
// 设置最大连接数
config.setMaxTotal(200);
// 设置最大空闲数
config.setMaxIdle(8);
// 设置最大等待时间
config.setMaxWaitMillis(1000 * 100);
// 在borrow一个jedis实例时,是否需要验证,若为true,则所有jedis实例均是可用的
config.setTestOnBorrow(true);
pool = new JedisPool(config, "127.0.0.1", 6379, 3000);
}
private LockRedis lockRedis=new LockRedis(pool);
//演示redis实现分布式锁
public void seckill(){
//1.获取锁
System.out.println("尝试获取锁");
String value=lockRedis.getRedisLockKey(5000L);
if(value!=null){
System.out.println(Thread.currentThread().getName() + ",获取锁成功,锁的id:" + value + ",正常执行业务了逻辑");
try {
Thread.sleep(1*1000);
}catch (Exception e){
}
//2.释放锁
lockRedis.unRedisLock(value);
}
}
}
public class ThreadRedis extends Thread {
private LockService lockService;
public ThreadRedis(LockService lockService) {
this.lockService = lockService;
}
@Override
public void run(){
lockService.seckill();
}
}
这里我开了10个线程模拟10个用户,一直在获取分布式锁
输出结果如下:
Thread-1尝试获取锁
Thread-5尝试获取锁
Thread-4尝试获取锁
Thread-3尝试获取锁
Thread-2尝试获取锁
Thread-6尝试获取锁
Thread-7尝试获取锁
Thread-9尝试获取锁
Thread-10尝试获取锁
Thread-8尝试获取锁
Thread-8,获取锁成功,锁的id:da757e48-c082-4c42-a37e-9c9216f6b3e5,正常执行业务了逻辑
释放锁。。。Thread-8,value:da757e48-c082-4c42-a37e-9c9216f6b3e5
以上加锁解锁代码乍一看没什么问题,也很符合大家的要求。不过。。。看了一位大佬的博文(博文地址)以后,我豁然开朗,以上的代码确实存在缺陷,听我细细道来。
1.setnx()方法作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间,由于这是两条redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。
加锁代码的正确姿态应该是:
public class RedisTool {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";//秒用EX,毫秒用PX
/**
* 尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:
第一个为key,我们使用key来当锁,因为key是唯一的。
第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
第五个为time,与第四个参数相呼应,代表key的过期时间。
总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。
2.在于解锁代码,如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了
解锁代码的正确姿态应该是:
public class RedisTool {
private static final Long RELEASE_SUCCESS = 1L;
/**
* 释放分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
可以看到,我们解锁只需要两行代码就搞定了!第一行代码,我们写了一个简单的Lua脚本代码,这段编程语言在《黑客与画家》里,,第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。
那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的
那么为什么执行eval()方法可以确保原子性,源于Redis的特性,下面是官网对eval命令的部分解释:
简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。
如果你的项目中Redis是多机部署的,那么可以尝试使用Redisson实现分布式锁