分布式锁的关键是多进程共享的内存标记,因此只要我们在Redis中放置一个这样的标记就可以了 .
多进程可见:多进程可见,否则就无法实现分布式效果
避免死锁:死锁的情况有很多,我们要思考各种异常导致死锁的情况,保证锁可以被释放
排它:同一时刻,只能有一个进程获得锁
高可用:避免锁服务宕机或处理好宕机的补救措施
多进程可见:多进程可见,否则就无法实现分布式效果
redis本身就是多服务共享的,因此自然满足
排它:同一时刻,只能有一个进程获得锁
我们需要利用Redis的setnx命令来实现,setnx是set when not exits的意思。当多次执行setnx命令时,只有第一次执行的才会成功并返回1,其它情况返回0:
我们定义一个固定的key,多个进程都执行setnx,设置这个key的值,返回1的服务获取锁,0则没有获取
避免死锁:死锁的情况有很多,我们要思考各种异常导致死锁的情况
比如服务宕机后的锁释放问题,我们设置锁时最好设置锁的有效期,如果服务宕机,有效期到时自动删除锁。
高可用:避免锁服务宕机或处理好宕机的补救措施
利用Redis的主从、哨兵、集群,保证高可用
Redis分布式锁的发展:
(一)
流程:
1、通过set命令设置锁
2、判断返回结果是否是OK
1)Nil,获取失败,结束或重试(自旋锁)
2)OK,获取锁成功
执行业务
释放锁,DEL 删除key即可
3、异常情况,服务宕机。超时时间EX结束,会自动释放锁。
代码{
//定义锁接口
public interface RedisLock {
boolean lock(long releaseTime);
void unlock();
}
//定义锁工具
public class SimpleRedisLock implements RedisLock{
private StringRedisTemplate redisTemplate;
/**
* 设定好锁对应的 key
*/
private String key;
/**
* 锁对应的值,无意义,写为1
*/
private static final String value = "1";
public SimpleRedisLock(StringRedisTemplate redisTemplate, String key) {
this.redisTemplate = redisTemplate;
this.key = key;
}
public boolean lock(long releaseTime) {
// 尝试获取锁
Boolean boo = redisTemplate.opsForValue().setIfAbsent(key, value, releaseTime, TimeUnit.SECONDS);
// 判断结果
return boo != null && boo;
}
public void unlock(){
// 删除key即可释放锁
redisTemplate.delete(key);
}
}
//在定时任务中使用锁
@Slf4j
@Component
public class HelloJob {
@Autowired
private StringRedisTemplate redisTemplate;
@Scheduled(cron = "0/10 * * * * ?")
public void hello() {
// 创建锁对象
RedisLock lock = new SimpleRedisLock(redisTemplate, "lock");
// 获取锁,设置自动失效时间为50s
boolean isLock = lock.lock(50);
// 判断是否获取锁
if (!isLock) {
// 获取失败
log.info("获取锁失败,停止定时任务");
return;
}
try {
// 执行业务
log.info("获取锁成功,执行定时任务。");
// 模拟任务耗时
Thread.sleep(500);
} catch (InterruptedException e) {
log.error("任务执行异常", e);
} finally {
// 释放锁
lock.unlock();
}
}
}
}
(二)
问题:
释放锁就是用DEL语句把锁对应的key给删除,有没有这么一种可能性:
问题出现了:B和C同时获取了锁,违反了排它性!
如何得知当前获取锁的是不是自己呢?
对了,我们可以在set 锁时,存入自己的信息!删除锁前,判断下里面的值是不是与自己相等,如果不等,就不要删除了。
代码:
public class SimpleRedisLock implements RedisLock{
private StringRedisTemplate redisTemplate;
/**
* 设定好锁对应的 key
*/
private String key;
/**
* 存入的线程信息的前缀,防止与其它JVM中线程信息冲突
*/
private final String ID_PREFIX = UUID.randomUUID().toString();
public SimpleRedisLock(StringRedisTemplate redisTemplate, String key) {
this.redisTemplate = redisTemplate;
this.key = key;
}
public boolean lock(long releaseTime) {
// 获取线程信息作为值,方便判断是否是自己的锁
String value = ID_PREFIX + Thread.currentThread().getId();
// 尝试获取锁
Boolean boo = redisTemplate.opsForValue().setIfAbsent(key, value, releaseTime, TimeUnit.SECONDS);
// 判断结果
return boo != null && boo;
}
public void unlock(){
// 获取线程信息作为值,方便判断是否是自己的锁
String value = ID_PREFIX + Thread.currentThread().getId();
// 获取现在的锁的值
String val = redisTemplate.opsForValue().get(key);
// 判断是否是自己
if(value.equals(val)) {
// 删除key即可释放锁
redisTemplate.delete(key);
}
}
}
(三)
如果我们在获取锁以后,执行代码的过程中,再次尝试获取锁,执行setnx肯定会失败,因为锁已经存在了。这样就是不可重入锁,有可能导致死锁。
如何解决呢?
当然是想办法改造成可重入锁。让自己可以复用自己的锁
可重入锁,也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。换一种说法:同一个线程再次进入同步代码时,可以使用自己已获取到的锁。
其中的关键,就是在锁已经被使用时,判断这个锁是否是自己的,如果是则再次获取。
我们可以在set锁的值是,存入获取锁的线程的信息,这样下次再来时,就能知道当前持有锁的是不是自己,如果是就允许再次获取锁。
要注意,因为锁的获取是可重入的,因此必须记录重入的次数,这样不至于在释放锁时一下就释放掉,而是逐层释放。
因此,不能再使用简单的key-value结构,这里推荐使用hash结构:
key:lock
hashKey:线程信息
hashValue:重入次数,默认1
释放锁时,每次都把重入次数减一,减到0说明多次获取锁的逻辑都执行完毕,才可以删除key,释放锁
获取锁的步骤:
1、判断lock是否存在 EXISTS lock
存在,说明有人获取锁了,下面判断是不是自己的锁
判断当前线程id作为hashKey是否存在:HEXISTS lock threadId
不存在,说明锁已经有了,且不是自己获取的,锁获取失败,end
存在,说明是自己获取的锁,重入次数+1:HINCRBY lock threadId 1
,去到步骤3
2、不存在,说明可以获取锁,HSET key threadId 1
3、设置锁自动释放时间,EXPIRE lock 20
释放锁的步骤:
1、判断当前线程id作为hashKey是否存在:HEXISTS lock threadId
不存在,说明锁已经失效,不用管了
存在,说明锁还在,重入次数减1:HINCRBY lock threadId -1
,获取新的重入次数
2、判断重入次数是否为0:
为0,说明锁全部释放,删除key:DEL lock
大于0,说明锁还在使用,重置有效时间:EXPIRE lock 20
上述流程有一个最大的问题,就是有大量的判断,这样在多线程运行时,会有线程安全问题,除非能保证执行
Redis支持一种特殊的执行方式:lua脚本执行,lua脚本中可以定义多条语句,语句执行具备原子性。
(三-------一) Redis脚本 LUA
实现Redis的原子操作有多种方式,比如Redis事务,但是相比而言,使用Redis的Lua脚本更加优秀,具有不可替代的好处:
原子性:redis会将整个脚本作为一个整体执行,不会被其他命令插入。
复用:客户端发送的脚本会永久存在redis中,以后可以重复使用,而且各个Redis客户端可以共用。
高效:Lua脚本解析后会形成缓存,不用每次执行都解析。
减少网络开销:Lua脚步缓存后,可以形成SHA值,作为缓存的key,以后调用可以直接根据SHA值来调用脚本,不用每次发送完整脚本,较少网络占用和时延
help @scripting:
numkeys:脚本中用到的key的数量,接下来的numkeys个参数会作为key参数,剩下的作为arg参数
key:作为key的参数,会被存入脚本环境中的KEYS数组,角标从1开始
arg:其它参数,会被存入脚本环境中的ARGV数组,角标从1开始
示例:EVAL "return 'hello world!'" 0
,其中:
"return 'hello world!'"
:就是脚本的内容,直接返回字符串,没有别的命令
0
:就是说没有用key参数,直接返回
SCRIPT LOAD命令 :
将一段脚本编译并缓存起来,生成一个SHA1值并返回,作为脚本字典的key,方便下次使用。
EVALSHA 命令:
与EVAL类似,执行一段脚本,区别是通过脚本的sha1值,去脚本缓存中查找,然后执行,参数:
sha1:就是脚本对应的sha1值
Lua脚本遵循Lua的基本语法,这里我们简单介绍几个常用的:
这两个函数是调用redis命令的函数,区别在于call执行过程中出现错误会直接返回错误;pcall则在遇到错误后,会继续向下执行。基本语法类似:
redis.call("命令名称", 参数1, 参数2 ...)
例如这样的脚本:return redis.call('set', KEYS[1], ARGV[1])
'set':就是执行set 命令
KEYS[1]:从脚本环境中KEYS数组里取第一个key参数
ARGV[1]:从脚本环境中ARGV数组里取第一个arg参数
条件判断和变量
条件判断语法:if (条件语句) then ...; else ...; end;
变量接收语法:local num = 123;
示例:
local val = redis.call('get', KEYS[1]); if (val > ARGV[1]) then return 1; else return 0; end;
基本逻辑:获取指定key的值,判断是否大于指定参数,如果大于则返回1,否则返回0
RedisScript<T> script
:封装了Lua脚本的对象
List<K> keys
:脚本中的key的值
Object ... args
:脚本中的参数的值
要执行Lua脚本,我们需要先把脚本封装到RedisScript
对象中,有两种方式来构建RedisScript
对象:
方式1:通过RedisScript中的静态方法:
String script
:Lua脚本
Class<T> resultType
:返回值类型
方式二 :自己去创建
RedisScript
的实现类DefaultRedisScript
的对象可以把脚本文件写到classpath下的某个位置,然后通过加载这个文件来获取脚本内容,并设置给
DefaultRedisScript
实例。
。。。