Redis分布式锁
简述
利用Redis的Setnx命令,来实现一个分布式的加锁方案。利用注解,在拥有该注解的方法上,进行切面处理,在方法执行前,进行加锁,执行结束后,根据是否自动释放锁,进行解锁。
将该注解用在定时任务的方法上,即可实现分布式定时任务,即获取到锁的方法,才会执行。
1 redis命令
1.1 setnx命令
- Redis setnx(SET if Not eXists) 命令在指定的 key 不存在时,为 key 设置指定的值。(该命令无法设置过期时间)
Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对Redis的连接并不存在竞争关系。redis的SETNX命令可以方便的实现分布式锁。
当某一个客户端将key的值设置成功后,其他的客户端再进行设置,将返回失败,保证同一时间,只有一个客户端能够设置成功。 - Redis事务
watch key1 key2 ... : 监视一或多个key,如果在事务执行之前,被监视的key被其他命令改动,则事务被打断 ( 类似乐观锁 )
multi : 标记一个事务块的开始( queued )
exec : 执行所有事务块的命令 ( 一旦执行exec后,之前加的监控锁都会被取消掉 )
discard : 取消事务,放弃事务块中的所有命令
unwatch : 取消watch对所有key的监控
# 事务正常使用
127.0.0.1:6379> multi
127.0.0.1:6379> set name jack
127.0.0.1:6379> exec
# 取消事务
127.0.0.1:6379> multi
127.0.0.1:6379> set name jack
127.0.0.1:6379> discard
# watch使用
# number初始为10
127.0.0.1:6379> watch number
127.0.0.1:6379> multi
127.0.0.1:6379> set number 11
127.0.0.1:6379> exec
# 如果在执行exec时,number没有被其他客户端修改,还是10,则事务执行成功;
# 如果被其他客户端修改了,number不是10了,则事务执行失败,这时候就需求程序自行处理,进行再次提交或者其他操作
- 在spring boot 中,我们用StringRedisTemplate来操作Redis,它的方法:stringRedisTemplate.opsForValue().setIfAbsent()方法即对应setnx命令,这个方法有两个重载的方法:
1、Boolean setIfAbsent(K key, V value); 设置key value,返回成功/失败
2、Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit); 设置key value,返回成功/失败,同时设置过期时间,redisTemplate 会调用 EXPIRE进行过期时间的设定,同时在设置值和过期时间时,会开启事务,保存全部成功。
// org.springframework.data.redis.core 中实现的方法
@Override
public Boolean setIfAbsent(K key, V value) {
byte[] rawKey = rawKey(key);
byte[] rawValue = rawValue(value);
return execute(connection -> connection.setNX(rawKey, rawValue), true);
}
@Override
public Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit) {
byte[] rawKey = rawKey(key);
byte[] rawValue = rawValue(value);
Expiration expiration = Expiration.from(timeout, unit);
return execute(connection -> connection.set(rawKey, rawValue, expiration, SetOption.ifAbsent()), true);
}
1.2 DEL命令、lua脚本
在加锁之后,解锁时,需要判断锁,是否是当前线程所拥有的,如果是当前线程拥有的,则删除该key,删除key,用del命令。
- del key_name
我们会先取出key对应的值,然后判断是否和当前线程的定义的值一致。如果一致,则说明是该线程拥有的key。如果我们在代码中取出key的值,然后判断通过后,调用redis del 删除key,这就不是一个原子操作了。如果在我们取出key的值后,然后在删除前,其他线程获取了锁,当前线程删除的动作,就会导致删除其他线程拥有的锁。所以释放锁,需要利用lua脚本进行,将判断和删除,这两个动作,合为一个原子性的操作。
所以我们会利用代码去执行下面的lua脚本,保证判断和删除的原子性。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
一般教程中,利用RedisTemplate来执行lua脚本时,会将lua脚本放到静态资源目录中。而在下面的代码中,利用ByteArrayResource直接从String字符串中读取了lua脚本内容:
/*
* 保存lua脚本
*/
private DefaultRedisScript<List> getRedisScript;
@PostConstruct
public void init(){
// 定义lua脚本资源
// 也可以放到文件中,加载进来: new ResourceScriptSource(new ClassPathResource("redis/demo.lua"))
String luaStr = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
ByteArrayResource resource = new ByteArrayResource(luaStr.getBytes());
getRedisScript = new DefaultRedisScript<>();
getRedisScript.setResultType(List.class);
getRedisScript.setScriptSource(new ResourceScriptSource(resource));
}
2 分布式锁实现
下面是实现的核心类:
- RedisLock: reids分布式锁工具类
- EmLock: 分布式锁注解
- LockRangeEnum: 分布式锁的范围枚举
- EmLockAspect: 分布式锁切面
2.1 RedisLock,reids分布式锁工具类
代码如下:
package com.emdata.lowvis.common.redislock;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.concurrent.TimeUnit;
/**
* reids分布式锁工具类
*
* @version 1.0
* @date 2020/12/8 14:37
*/
@Slf4j
@Component
public class RedisLock {
private static final String SPLIT = "_";
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 加锁解锁工具类
* @param lockKey 加锁的key
* @param uuid 线程的标志
* @param timeout 超时时间
* @param timeUnit 超时时间粒度
* @return true:获取成功
*/
public boolean lock(String lockKey, String uuid, long timeout, TimeUnit timeUnit) {
// 根据key获取值
String currentLock = stringRedisTemplate.opsForValue().get(lockKey);
// 值为:uuid_时间
String value = uuid + SPLIT + (timeUnit.toMillis(timeout) + System.currentTimeMillis());
// 如果为空,则设置值
if (StringUtils.isEmpty(currentLock)) {
if (stringRedisTemplate.opsForValue().setIfAbsent(lockKey, value, timeout, timeUnit)) {
// 对应setnx命令,可以成功设置,也就是key不存在,获得锁成功
return true;
} else {
return false;
}
} else {
// 可重入锁,如果是这个uuid持有的锁,则更新时间
if (currentLock.startsWith(uuid)) {
stringRedisTemplate.opsForValue().set(lockKey, value, timeout, timeUnit);
return true;
} else {
return false;
}
}
}
/*
* 保存lua脚本
*/
private DefaultRedisScript<List> getRedisScript;
@PostConstruct
public void init(){
// 定义lua脚本资源
// 也可以放到文件中,加载进来: new ResourceScriptSource(new ClassPathResource("redis/demo.lua"))
String luaStr = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
ByteArrayResource resource = new ByteArrayResource(luaStr.getBytes());
getRedisScript = new DefaultRedisScript<>();
getRedisScript.setResultType(List.class);
getRedisScript.setScriptSource(new ResourceScriptSource(resource)