目录
基于redis乐观锁(因为递归调用,可能出现栈内存溢出,所以不建议使用,但是Cas原理要知道)
问题一:如果代码没有执行完,宕机了, finally没有执行,就没有释放锁,就会照成死锁
问题二:因为设置锁与设置锁过期时间不是原子性的,可能出现设置锁完成,就挂了,没有执行到设置过期时间的代码
问题三:如果设置过期时间,但是代码运行时间大于过期时间,代码就会出现问题,导致误删问题
问题四:上面的获取uuid与删除释放也不是原子性,也会存在问题
事务概念
- 原子性(atomicity)。一个事务是一个不可分割的工作单位,事务中包括的操作要么都做,要么都不做。
- 一致性(consistency)。事务必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的。
- 隔离性(isolation)。一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
- 持久性(durability)。持久性也称永久性(permanence),指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。
在Redis事务没有没有隔离级别的概念!
在Redis单条命令式保证原子性的,但是事务不保证原子性!
Redis事务
正常执行事务
127.0.0.1:6379> multi #开启事务
OK
127.0.0.1:6379> set name dingyongjun #添加数据
QUEUED
127.0.0.1:6379> set high 172 #添加数据
QUEUED
127.0.0.1:6379> exec 执行事务
1) OK
2) OK
127.0.0.1:6379> get name #获取数据成功,证明事务执行成功
"dingyongjun"
127.0.0.1:6379> get age
"26"
放弃事务
127.0.0.1:6379> multi #开启事务
OK
127.0.0.1:6379> set name dingyongjun #添加数据
QUEUED
127.0.0.1:6379> set age 26 #添加数据
QUEUED
127.0.0.1:6379> discard #放弃事务
OK
127.0.0.1:6379> get name #不会执行事务里面的添加操作
(nil)
分布式锁
基于redis乐观锁(因为递归调用,可能出现栈内存溢出,所以不建议使用,但是Cas原理要知道)
- watch: 可以监控一个或者多个key的值,如果在事务(exec)执行之前,key的值发生变化则取事务执行
- muti: 开启事务
- exec: 执行事务
public String redisLockDome() {
//必须使用SessionCallback 才可以开启事务,这个是接口,只要外部类定义,实现这个接口也行
this.stringRedisTemplate.execute(new SessionCallback<Object>() {
@SneakyThrows
@Override
public Object execute(RedisOperations redisTemplate) throws DataAccessException {
//watch //监听key
redisTemplate.watch("stock");
//获取
String stock = redisTemplate.opsForValue().get("stock").toString();
//判断
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
//multi 开启事务
redisTemplate.multi();
//扣减
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
//exec 执行
List exec = redisTemplate.exec();
// CAS 如果执行事务的返回结果集为空,则代表减库存失败,重试
if (exec == null || exec.size() == 0) {
//重试的时候睡一会,防止栈内存溢出
Thread.sleep(40);
redisLockDome();
}
return exec;
}
}
return null;
}
});
return "OK";
}
基于setnx
先设置setnx 设置锁,如果设置成功就代表获得锁,执行,如果没有设置成功就重试获取锁
- 加锁:setnx 如果存在,不设置,不存在才设置,设置成功返回成功数
- 解锁:del 删除 锁,这样其他重试的就可以设置锁
- 如果没有获得,就重试
public String redisLockDome() {
//setIfAbsent == setnx, 如果不存在,就设置
while (!this.stringRedisTemplate.opsForValue().setIfAbsent("lock", "1111")) {
//如果没有设置成功,40毫秒后重试,这个睡眠可以使竞争压力小,本质也是使用CAS调用
try { Thread.sleep(40); } catch (InterruptedException e) { e.printStackTrace(); }
}
//获取成功
try {
//获取
String stock = this.stringRedisTemplate.opsForValue().get("stock").toString();
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
//扣减
this.stringRedisTemplate.opsForValue().set("stock", String.valueOf(--st));\
}
}
} finally {
//解锁
this.stringRedisTemplate.delete("lock");
}
return "OK";
}
问题一:如果代码没有执行完,宕机了, finally没有执行,就没有释放锁,就会照成死锁
解决:添加锁过期时间,防止死锁,expire设置, 获取成功,添加下面代码
//获取成功
while (!this.stringRedisTemplate.opsForValue().setIfAbsent("lock", "1111")) {
//如果没有设置成功,40毫秒后重试,这个睡眠可以使竞争压力小,本质也是使用CAS调用
try { Thread.sleep(40); } catch (InterruptedException e) { e.printStackTrace(); }
}
//设置过期时间,防止死锁
this.stringRedisTemplate.expire("lock", 3, TimeUnit.SECONDS);
问题二:因为设置锁与设置锁过期时间不是原子性的,可能出现设置锁完成,就挂了,没有执行到设置过期时间的代码
解决:
- set lock 111 ex 20 xx :lock 键值, 111 设置的值, ex 20 过期时间,毫秒 xx表示存在才设置,不存在,不设置 (nx) 相反 不存在才设置 ,存在不设置
- ttl key 获取过期时间
//java代码实现
this.stringRedisTemplate.opsForValue().setIfAbsent("lock", "1111", 3, TimeUnit.SECONDS)
问题三:如果设置过期时间,但是代码运行时间大于过期时间,代码就会出现问题,导致误删问题
解决:防误删:设置锁的UUID等唯一表示
String uuid = UUID.randomUUID().toString();
//setIfAbsent == setnx, 如果不存在,就设置
while (!this.stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS)) {
//如果没有设置成功,40毫秒后重试
try { Thread.sleep(40); } catch (InterruptedException e) { e.printStackTrace(); }
}
try{
//业务代码.......
}finally {
//解锁
//先判断是不是自己的锁, 是自己的才删除,防止误删
if (this.stringRedisTemplate.opsForValue().get("lock").equals(uuid)){
this.stringRedisTemplate.delete("lock");
}
}
问题四:上面的获取uuid与删除释放也不是原子性,也会存在问题
解决:使用Lua脚本解决,redis默认支持, 一次性发送多个指令给redis,redis单线程执行指令遭守one-by-one规则
String uuid = UUID.randomUUID().toString();
//setIfAbsent == setnx, 如果不存在,就设置
while (!this.stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS)) {
//如果没有设置成功,40毫秒后重试
try { Thread.sleep(40); } catch (InterruptedException e) { e.printStackTrace(); }
}
try{
//业务代码.......
}finally {
//解锁
// 先判断是不是自己的锁, 是自己的才删除,防止误删
// if (this.stringRedisTemplate.opsForValue().get("lock").equals(uuid)){
// this.stringRedisTemplate.delete("lock");
// }
//等价于
String script = "if redis.call('get', KEYS[1]) == ARGV[1] " + (等价于 //this.stringRedisTemplate.opsForValue().get("lock").equals(uuid))
"then " +
" return redis.call('del', KEYS[1]) " + (等价于// this.stringRedisTemplate.delete("lock");)
"else " +
" return 0 " +
"end";
// new DefaultRedisScript<>(script, Boolean.class), Arrays.asList("lock"), uuid
// script:脚本
// Boolean.class 返回类型
// Arrays.asList("lock") KEYS值
// uuid ARGV值
this.stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList("lock"), uuid);
}
问题五:可能出现不可重入问题(或者死锁)
- 线程有A方法和B方法,现在A方法获取到锁之后调用B方法,但是B方法没有锁,那么A就需要等待B,这样A明明已经获取到锁,就不能等待了
- 可重入性就可以解决这个尴尬的问题,当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法逻辑。退出加锁方法之后,加锁次数再减
总结
解决上诉所有问题,我们采用 hash+lua脚本解决,看下一章
基于 hash+lua脚本
lua脚本基本使用
redis命令:
EVAL script numkeys key [key ...arg [arg...] 输出的不是print,而是return
变量:
全局变量:a=5
局部变量:local a=5
lua使用:
EVAL script numkeys key [key ...] arg [arg ...]
script:lua脚本字符串,这段Lua脚本不需要(也不应该)定义函数。
numkeys:lua脚本中KEYS数组的大小
key [key ...]:KEYS数组中的元素
arg [arg ...]:ARGV数组中的元素
例子:
EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 5 10 20 30 40 50 60 70 80 90
# 输出:10 20 60 70, [ 5(是key的长度)10 20 30 40 50(keys值)60 70 80 90(剩下都是ARGA值)]
KEYS[1] 10
KEYS[2] 20
KEYS[3] 30
KEYS[4] 40
KEYS[5] 50
ARGV[1] 60
ARGV[2] 70
ARGV[3] 80
ARGV[4] 90
EVAL "if KEYS[1] > ARGV[1] then return 1 else return 0 end" 1 10 20
# 输出:0
EVAL "if KEYS[1] > ARGV[1] then return 1 else return 0 end" 1 20 10
# 输出:1
可重入锁概念
ReentrantLock就是可重入锁,底层原理如下:
- 可重入锁加锁流程:ReentrantLock.lock() --> NonfairSync.lock() --> AQS.acquire(1) --> NonfairSync.tryAcquire(1) --> Sync.nonfairTryAcquire(1)
1.CAS获取锁,如果没有线程占用锁(state==0),加锁成功并记录当前线程是有锁线程(两次)
2.如果state的值不为0,说明锁已经被占用。则判断当前线程是否是有锁线程,如果是则重入(state + 1)
3.否则加锁失败,入队等待
- 可重入锁解锁流程:ReentrantLock.unlock() --> AQS.release(1) --> Sync.tryRelease(1)
1.判断当前线程是否是有锁线程,不是则抛出异常
2.对state的值减1之后,判断state的值是否为0,为0则解锁成功,返回true
3.如果减1后的值不为0,则返回false
当加锁次数为 0 时,锁才被真正的释放。可以看到可重入锁最大特性就是计数,计算加锁的次数。所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数
分布式锁思路
- redis hash结构(双层Map结构): Map<'lock', Map<uuid, state(可重入次数)>>
例子:hset lock 1213-3453-6234-232 1
- 加锁
- 判断锁是否存在(exists lock == null),不存在则直接获取锁 hset lock uuid 1
- 如果锁存在(exists lock != null)则判断是否自己的锁(hexists lock uuid != null,是自己的锁),如果是自己的锁则重入:hincrby lock uuid 1(这是递增+1)
- 如果锁存在(exists lock != null)则判断是否自己的锁(hexists lock uuid == null,不是自己的锁) 否则重试:递归 循环
- 解锁
- exists lock 判断锁是否存在
- 判断自己的锁是否存在(hexists),不存在则返回nil
- 如果自己的锁存在,则减1(hincrby -1),判断减1后的值是否为0,为0则释放锁(del)并返回1,不为0,返回0
lua脚本加锁
--------------------------------------版本1-------------------------------------------------------------------
if redis.call('exists','lock')==0 //判断锁是否存在(exists lock == 0)
then
redis.call('hset', 'lock', uuid, 1) //不存在则直接获取锁 hset lock uuid 1
redis.call('expire','lock',30) //设置lock的过期时间
return 1
elseif redis.call('hexists', 'lock', uuid)==1 //判断是否自己的锁(hexists lock uuid != 0,是自己的锁)
then
redis.call('hincrby', 'lock', uuid, 1) //如果是自己的锁则重入:hincrby lock uuid 1(这是递增+1)
redis.call('expire', 'lock', 30) //重置一下过期时间
return 1
else
return 0
end
--------------------------------------改进版本2-----------------------------------------------------------------------
lock = KEYS[1]
uuid = ARGV[1]
过期时间 = ARGV[2]
改成
if redis.call('exists',KEYS[1])==0 //判断锁是否存在(exists lock == 0)
then
redis.call('hset', KEYS[1], ARGV[1], 1) //不存在则直接获取锁 hset lock uuid 1
redis.call('expire', KEYS[1], ARGV[2]) //设置lock的过期时间
return 1
elseif redis.call('hexists', KEYS[1], ARGV[1])==1 //判断是否自己的锁(hexists lock uuid != 0,是自己的锁)
then
redis.call('hincrby', KEYS[1], ARGV[1], 1) //如果是自己的锁则重入:hincrby lock uuid 1(这是递增+1)
redis.call('expire', KEYS[1], ARGV[2]) //重置一下过期时间
return 1
else
return 0
end
------------------------------------------改进版本3(最终版本)--------------------------------------------------------------------------
因为if与if else里面的内容体redis.call('hset', KEYS[1], ARGV[1], 1) 与 redis.call('hincrby', KEYS[1], ARGV[1], 1) 不一样,但是都可以使用hincrby替代
所以代码可以改成
if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1
then
redis.call('hincrby', KEYS[1], ARGV[1], 1)
redis.call('expire', KEYS[1], ARGV[2])
return 1
else
return 0
end
--------------------------------------------------------------------------------------------------------------------
lua脚本解锁
if redis.call('hexists', KEYS[1], ARGV[1]) == 0 //判断锁是否存在 并且是自己的锁
then
return nil //没有,直接不需要释放,返回nil
elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 //如果自己的锁存在,则减1(hincrby -1),并判断减1后的值是否为0
then
return redis.call('del', KEYS[1]) //为0则释放锁(del)并返回1
else
return 0 //不为0,返回0
end
-------------------------------------------
KEYS[1]: lock
ARGV[1]: uuid
分布式Redis锁完整代码实现
- DistributedLockClient工厂类完整代码具体实现:
package com.cg.service.utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class DistributedLockClient {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 这个是单例的,一台服务器启动spring boot 的时候,初始化就已经定义好了,所以分布式下的话解决不了
*/
private String uuid;
public DistributedLockClient() {
this.uuid = UUID.randomUUID().toString();
}
/**
* 工厂lock
* @param lockName 锁名称
* @return
*/
public DistributedRedisLock getRedisLock(String lockName){
return new DistributedRedisLock(redisTemplate, lockName, uuid);
}
}
- DistributedRedisLock实现完整代码如下:
package com.cg.service.utils;
import lombok.Data;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.lang.reflect.Array;
import java.util.Arrays;
import java.util.Timer;
import java.util.TimerTask;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
/**
* 基于redis锁
*/
public class DistributedRedisLock implements Lock {
private StringRedisTemplate redisTemplate;
private String lockName;
private String uuid;
private long expire = 30;
public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName, String uuid) {
this.redisTemplate = redisTemplate;
this.lockName = lockName;
this.uuid = uuid + ":" + Thread.currentThread().getId();
}
/**
* 加锁方法
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
if (time != -1){
this.expire = unit.toSeconds(time);
}
String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
"then " +
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
" redis.call('expire', KEYS[1], ARGV[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
while (!this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuid, String.valueOf(expire))){
Thread.sleep(50);
}
//加锁成功,返回之前,开启定时器,自动续期
renewExpire();
return true;
}
/**
* 解锁方法
*/
@Override
public void unlock() {
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " +
"then " +
" return nil " +
"elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " +
"then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), uuid);
if (flag == null){
throw new IllegalMonitorStateException("this lock doesn't belong to you!");
}
}
@Override
public void lock() {
this.tryLock();
}
@Override
public boolean tryLock() {
try {
return this.tryLock(-1L, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
//下面不需要实现
@Override
public void lockInterruptibly() throws InterruptedException {}
@Override
public Condition newCondition() {return null;}
/**
* 自动续期
*/
private void renewExpire() {
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 1" +
"then" +
" return redis.call('expire', KEYS[1], ARGV[2])" +
"else" +
" return 0" +
"end";
new Timer().schedule(new TimerTask() {
@Override
public void run() {
if(redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuid, String.valueOf(expire))){
//重置
renewExpire();//又开启定时任务
}
}
//延迟三分之一过期时间执行lua脚本, 一次性脚本
}, this.expire * 1000 / 3);
}
}
- 使用
public String redisLockDome() {
DistributedRedisLock lock = distributedLockClient.getRedisLock("lock");
lock.lock();
try {
String stock = this.stringRedisTemplate.opsForValue().get("stock").toString();
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
//扣减
this.stringRedisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
test();// 测试可重入锁
} finally {
lock.unlock();
}
return "OK";
}
public void test() {
DistributedRedisLock lock = distributedLockClient.getRedisLock("lock");
lock.lock();
try {
System.out.println("测试可重入锁, 业务处理");
} finally {
lock.unlock();
}
}
注意事项
- 因为spring boot 容器@Component后,默认是单例模式,uuid在服务初始化就已经确定,所以多服务,多服务器,每个服务器uuid相同, 为了实现分布式锁,且满足多线程访问,所以要在 uuid后面加上线程id
String getId(){
return uuid + ":" + Thread.currentThread().getId();
}
- 使用的时候用
Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), getId());
- 代码思路理解
A服务 容器uuid = uuid_service_A,初始化就确定
线程1:锁uuid = uuid_service_A:thread1(容器uuid+线程id)
线程2:锁uuid = uuid_service_A:thread2(容器uuid+线程id)
B服务 容器uuid = uuid_service_B 初始化就确定
线程1:锁uuid = uuid_service_B:thread1(容器uuid+线程id)
线程2:锁uuid = uuid_service_B:thread2(容器uuid+线程id)
锁结构:Map<lock, Map<uuid, state>>
A服务线程1 调用方法1 new DistributedLockClient().lock() 加锁
-> 锁不存在(redis.call('exists','lock')==0)
-> 加锁 Map<lock, Map<uuid_service_A:thread1, 1>> state+1
-> 继续执行业务逻辑...
->调用方法2 new DistributedLockClient().lock() 加锁
->锁存在(redis.call('exists','lock')!=0)
->判断是自己的锁 /uuid = uuid_service_A:thread1/ 设置可重入锁 Map<lock, Map<uuid_service_A:thread1, 2>> state再+1=2
->继续执行业务逻辑...
这时A服务线程2 进来处理代码
-> 锁存在(redis.call('exists','lock')==0) 因为A服务在处理,加过了
-> 判断不是自己的锁(hexists lock uuid == 0,不是自己的锁)自己的锁uuid = uuid_service_B:thread2/目前的锁uuid=uuid_service_A:thread1
-> while重试(等待服务A释放删除,或者过期)
关于自动续期
解决:可以使用定时任务 + lua脚本
- 定时器
//定时器,延迟10S执行,每隔5S执行
new Timer().schedule(new TimerTask() {
@Override
public void run() {
}
}, 10000, 5000);
- lua脚本
if redis.call('hexists', KEYS[1], ARGV[1]) == 1
then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
KEYS[1]: lock
ARGV[1]: uuid
ARGV[2]: 过期时间
- 自动续期最终代码
/**
* 自动续期
*/
private void renewExpire() {
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 1" +
"then" +
" return redis.call('expire', KEYS[1], ARGV[2])" +
"else" +
" return 0" +
"end";
new Timer().schedule(new TimerTask() {
@Override
public void run() {
if(redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), getId(), String.valueOf(expire))){
//重置
renewExpire();//又开启定时任务,递归
}
}
//延迟三分之一过期时间执行lua脚本, 一次性脚本
}, this.expire * 1000 / 3);
}
总结
- 锁实现了的功能
- 独占排他:setnx
- 防死锁:redis客户端程序获取到锁之后,立马宕机。给锁添加过期时间
- 不可重入:可重入
- 防误删:先判断是否自己的锁才能别除
- 原子性:
加锁和过期时间之间:set k v ex3nx
判断和释放锁之间:lua脚本
- 可重入性:hash(key field value)+lua脚本
- 自动续期:Timer定时器+lua脚本
- 加锁:
- setnx:独占排他,原子性,死锁、不可重入
- set k v ex30nx: 独占排他、死锁,不可重入
- hash+lua脚本:可重入锁
判断锁是否被占用(exists),如果没有被占用则直接获取锁(hset/hincrby)并设置过期时间(expire)
如果锁被占用,则判断是否当前线程占用的(hexists),如果是则重入(hincrby)并重置过期时间(expire)
否则获取锁失败,将来代码中重试
- Timer定时器+lua脚本:实现锁的自动续期
- 判断锁是否自己的锁(hexists==1),如果是自己的锁则执行expire重置过期时间
- 解锁
- del:导致误删除
- 先判断再删除同时保证原子性:ua脚本
- hash+lua脚本:可重入
判断当前线程的锁是否存在,不存在则返回il,将来抛出异常
存在则直接减1(hincrby-1),判断减1后的值是否为0,为0则释放锁(del),并返回1
不为0,则返回0