分布式锁
1、分布式锁概述
分布式锁(Redis)
分布锁是分布式系统中重要的一环,在多线程的场景下,就会存在并发问题,这时加锁来保证线程安全,在之前我们也使用过锁,之前我们使用的锁是JVM层面的锁,它只能在一个JVM内存中才能起作用,而在分布式系统环境,我们使用的往往是多个JVM,这时普通的锁即JVM层面的锁就会失效,这时我们就要采用分布式锁,可保证集群模式下多进程可见并且互斥的锁,是进程级别的锁。分布式锁的实现有很多种方法,本文介绍的是通过Redis来实现分布式锁。
普通锁的示意图:只能在单个JVM有效。
分布锁示意图:在多个JVM间也有效。
分布式锁需要满足的条件
实现分布式锁几种方式的对比
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用MySQL本身的互斥锁机制 | 利用setnx互斥命令 | 利用节点唯一性和有序性实现互斥 |
高性能 | 好 | 好 | 好 |
高可用 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用超时时间,到期释放 | 临时节点,断开连接自动释放 |
2、Redis实现互斥锁
获取锁
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回true,失败返回false
#不存在就可以设置成功
setnx lock thread
#添加锁的超时时间,当出现故障时可以超时释放
expire lock 10
#上面两个命令合成一条命令,保证两个命令的原子性
set lock thread nx ex 10
释放锁
- 手动释放
#删除key
del key
- 超时释放
2.1、自定义分布式锁
自定义定义获取锁和释放锁方法
- pom
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.6.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>2.6.5</version>
</dependency>
<!--redis依赖 RedisTemplate1-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.5.2</version>
</dependency>
<!--jedis依赖-->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.2.3</version>
</dependency>
<!--uuid-->
<dependency>
<groupId>org.code-house.eaio-uuid</groupId>
<artifactId>uuid</artifactId>
<version>3.4.1</version>
</dependency>
<!--hutool工具包-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.1.1</version>
</dependency>
</dependencies>
- application.yaml
server:
port: 8888
spring:
redis:
host: 192.168.247.128
port: 6379
- ILock接口
package com.qiumin.lock;
/**
* @author qiumin
* @classname ILock
* @Description love code
* @date 2022-11-14 17:21
*/
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 超时时间
* @return 返回true获取锁成功,返回false获取失败
* */
boolean tryLock(long timeoutSec);
/**
* 释放锁
* */
void unlock();
}
- 实现自定义分布式锁的接口【V1.0版本存在误删问题】
package com.qiumin.utils;
import com.qiumin.lock.ILock;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* @author qiumin
* @classname SimpleRedisLock
* @Description love code
* @date 2022-11-14 17:25
*/
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
//锁前缀
private static final String KEY_PREFIX="lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取锁
long threadId = Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//释放锁
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
- controller
package com.qiumin.controller;
import com.qiumin.utils.SimpleRedisLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author qiumin
* @classname RedisController
* @Description love code
* @date 2022-11-14 16:58
*/
@RestController
public class RedisController {
@Autowired
RedisTemplate redisTemplate;
@Autowired
StringRedisTemplate stringRedisTemplate;
@RequestMapping("/to")
public void testRedis(){
redisTemplate.opsForValue().set("blue","666");
System.out.println(redisTemplate.opsForValue().get("blue"));
}
@RequestMapping("/testLock")
public String testRedisDIY(){
SimpleRedisLock lock = new SimpleRedisLock("order", stringRedisTemplate);
boolean isLock = lock.tryLock(20);
if(!isLock){ //获取锁
System.out.println("获取锁失败!!!");
}
try{
System.out.println("业务处理中...");
}finally {
//释放锁
lock.unlock();
}
return "ok";
}
}
- 测试Jedis
package com.qiumin;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import redis.clients.jedis.Jedis;
/**
* @author qiumin
* @classname TestRedis
* @Description love code
* @date 2022-11-14 15:40
*/
@SpringBootTest
public class TestRedis {
@Autowired
RedisTemplate redisTemplate;
@Test
public void testRedis(){
Jedis jedis = new Jedis("192.168.247.128", 6379);
String ping = jedis.ping();
jedis.set("dex","连接成功");
System.out.println(jedis.get("dex"));
}
}
说明: 当第一个线程设置了key时,后面的线程就不能设置值了即获取锁失败,key的时间超时key自动失效即释放锁(删除key),其他线程才能获取锁。
2.2、Redis分布式锁误删问题
上面的代码可能存在误删的问题
解决方法:在释放锁时取出key中的value判断value中的标识(可用uuid+线程id标识)是否为当前线程的,是则释放。
- 修改锁的实现类
v2.0版本,由于判断和释放是两个操作不是原子性,所有也存在误删,,但几率小,后面利用lua脚本保证两个操作的原子性
package com.qiumin.utils;
import cn.hutool.core.lang.UUID;
import com.qiumin.lock.ILock;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* @author qiumin
* @classname SimpleRedisLock
* @Description love code
* @date 2022-11-14 17:25
*/
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
//锁前缀
private static final String KEY_PREFIX="lock:";
private static final String ID_PREFIX= UUID.randomUUID().toString(true)+"-";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取锁,uuid+线程id 作为标识
String threadId = ID_PREFIX+Thread.currentThread().getId();
//key不存在是才可以设置key值
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//获取当前线程标识
String threadId = ID_PREFIX+Thread.currentThread().getId();
//获取当前锁的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if(threadId.equals(id)){
//释放锁
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
}
2.3、Lua脚本
lua脚本是由lua编程语言编写的,lua脚本可以保证Redis两个操作的院子性
- 参考网站: http://www.runoob.com/lua/lua-tutoria.html
redis提供了调用函数:
#格式
redis.call('命令名称','key','其他参数',...)
#例子:执行 set name qiumin
redis.call('set','name','qiumin')
#获取用local声明的变量name存储
local name = redis.call('get','name')
#返回
return name
#执行脚本
EVAL "return redis.call('set','name','qiumin')" 0
- 0代表的是key类型参数的个数,如:1代表接在后面的第一个为key类型参数,为2的话代表接在后面的两个个为key类型参数,之外的为其他类型的参数。
- key类型的参数会放入keys数组,其他参数会放在argv数组中,脚本中可以从这两个数组获取参数。
#脚本中可以从这两个数组获取参数,在lua脚本中数组下标从1开始
EVAL "return redis.call('set',keys[1],argv[1])" 1 name qiumin
2.4、利用lua脚本保证原子性
利用lua脚本保证判断锁标识和释放锁 两个操作的原子性
- lua脚本
--获取锁中的线程标识 get key
local id = redis.call('get',KEYS[1])
--比较线程标识与锁中标识是否一致
if(id == ARGV[1]) then
--释放锁
return redis.call('del',KEYS[1])
end
return 0
利用RedisTemplate调用lua脚本,execute方法
@Nullable
public <T> T execute(RedisCallback<T> action) {
return this.execute(action, this.isExposeConnection());
}
- 编写lua脚本文件
单独编写lua脚本文件可以减少lua语言与java代码的耦合,idea下载 Emmylua插件
- unlock.lua
--获取锁中的线程标识 get key
local id = redis.call('get',KEYS[1])
--比较线程标识与锁中标识是否一致
if(id == ARGV[1]) then
--释放锁
return redis.call('del',KEYS[1])
end
return 0
- 调用lua脚本释放锁
package com.qiumin.utils;
import cn.hutool.core.lang.UUID;
import com.qiumin.lock.ILock;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
/**
* @author qiumin
* @classname SimpleRedisLock
* @Description love code
* @date 2022-11-14 17:25
*/
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
//锁前缀
private static final String KEY_PREFIX="lock:";
private static final String ID_PREFIX= UUID.randomUUID().toString(true)+"-";
//lua脚本
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
//加载该类时就将lua脚本文件读入
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取锁,uuid+线程id 作为标识
String threadId = ID_PREFIX+Thread.currentThread().getId();
//key不存在是才可以设置key值
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//调用Lua脚本
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX+Thread.currentThread().getId()
);
}
2.5、总结
基于redia实现分布式锁的思路
- 利用set nx ex 获取锁并设置过期时间,保存线程标识。
- 释放锁时先判断标识是否与自己一致,一致则删除,防止误删。
- 利用lua脚本保证两个操作的原子性。
特性
- set nx保证互斥。
- set ex保证故障时能够超时释放,避免死锁,保证安全。
- 利用Redis集群保证高可用和高并发特性。
3、Redisson
基于setnx实现分布式锁存在下列问题
- 不可重入:同一个线程无法多次获取同一把锁。
- 不可重试:获取锁只尝试一次,返回false就退出,没有重试机制。
- 超时释放时间不好把握,且当业务处理时间长而锁已经超时释放了,这时就有安全隐患。
- 主从一致性问题,锁没有及时同步。
- 为了方便可以使用Redisson框架。
Redisson概述
Redisson是一个在Redis的基础上实现的Java驻内存数据网格,提供了一系列的分布式的java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。如:可重入锁
、公平锁
、联锁
、红锁
、读写锁
、闭锁
。
官方网址: https://redisson.org
GitHub地址: https://github.com/redisson/redisson
3.1、基于Redisson分布式锁
使用redisson实现分布式锁
- pom依赖
<!--redisson依赖-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
- 配置Redisson,RedissonConfig.java
package com.qiumin.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author qiumin
* @classname RedissonConfig
* @Description love code
* @date 2022-11-14 22:37
*/
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
//连接redis
config.useSingleServer().setAddress("redis:192.168.247.128:6379");
//返回创建的对象
return Redisson.create(config);
}
}
- 修改controller代码
package com.qiumin.controller;
import com.qiumin.utils.SimpleRedisLock;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author qiumin
* @classname RedisController
* @Description love code
* @date 2022-11-14 16:58
*/
@RestController
public class RedisController {
@Autowired
RedisTemplate redisTemplate;
@Autowired
StringRedisTemplate stringRedisTemplate;
@Autowired
//注入RedissonClient
RedissonClient redissonClient;
@RequestMapping("/to")
public void testRedis(){
redisTemplate.opsForValue().set("blue","888");
System.out.println(redisTemplate.opsForValue().get("blue"));
}
@RequestMapping("/testLock")
public String testRedisDIY(){
//使用Redisson获取锁
RLock lock = redissonClient.getLock("order");
boolean isLock = lock.tryLock();
if(!isLock){ //获取锁
System.out.println("获取锁失败!!!");
}
try{
System.out.println("业务处理中...");
}finally {
//释放锁
lock.unlock();
}
return "ok";
}
}
tryLock()方法的参数解析: (参数可以都为空)
- 第一个参数:尝试等待时间,不指定时值为-1。
- 第二个参数:超时释放时间,默认为30s。
- 第三个参数:时间单位。
3.2、Redisson可重入锁原理
基于redis的setnx的分布式锁不支持锁的重入,因为只有在key不存在时才能设置,存在就不能设置,这时需要用到Redisson
- Redisson使用hash结构来实现锁的重入,即用hash结构来充当value,这时可以用线程标识充当hash的key,重入的次数充当hash的value,在整个hash充当redis的key的value。
- 每次获取锁时,如果是同一标识且是同一线程加锁就向hash中的value加一。
- 解锁时一个一个释放,即将重入次数减一,当次数减为0时就可以删除锁key。
- 获取锁释放锁重入流程
获取锁的lua脚本
local key = KEYS[1] ; --锁的key
local threadId = ARGV[1]; --线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间判断是否存在
if(redis.call('exists',key) == 0) then
--不存在,获取锁
redis.call( 'hset',key,threadId, '1);
--设置有效期
redis.call('expire',key,releaseTime) ;
return 1; --返回结果
end;
--锁已经存在,判断threadId是否是自己
if(redis.call('hexists',key,threadId) == 1) then
--存在,获取锁,重入次数+1
redis.call('hincrby',key,threadId, '1');
redis.call('expire',key,releaseTime); --设置有效期
return 1; --返回结果
end ;
return 0; --代码走到这里,说明获取锁的不是自己,获取锁失败
释放锁的lua脚本
local key = KEYS[1]; --锁的key
local threadId = ARGV[1]; --线程唯一标识
local releaseTimeARGV[2] ;--锁的自动释放时间
--判断当前锁是否还是被自己持有
if (redis.call( 'HEXISTS', key, threadId) == 0) then
return nil; --如果已经不是自己, 则直接返回
end;
--是自己的锁,则重入次数- 1
local count = redis. call( 'HINCRBY',key, threadId, -1);
--判断是否重入次数是否已经为0
if (count > 0) then
--大于0说明不能释放锁,重置有效期然后返回
redis.call('EXPIRE', key, releaseTime) ;
return nil ;
eLse --等于0 说明可以释放锁,直接删除
redis.call('DEL',key);
return nil;
end;
3.3、Redisson的重试锁原理
当调用lock.tryLock()方法时传入了重试等待时间时就会在等待时间段内进行获取锁的重试
重试的基本原理: 发布订阅
、信号量机制
- 传入了等待时间当我们获取锁失败时,获取锁的lua脚本会返回一个获取锁的消耗时间,用设置的等待时间减去消耗时间就是剩余的等待时间。
- 在该段时间内不会立刻去尝试获取锁,而是订阅指定锁的标识,只有当释放锁的脚本中删除了key发布锁已释放的消息后才去尝试获得锁,但如果超过了等待的最大时间就会取消订阅,尝试获取锁失败。
- 在等待时间内收到了释放的消息,再次计算是否还有时间,有时间就进行第一次重试获得锁。
3.4、Redisson的看门狗机制
看门狗机制是redisson保证锁超时释放和持续续约的机制,当调用lock.tryLock()方法时没有指定超时释放时间时,就会默认采用看门狗机制
- 当没有指定超时释放时间,就会走看门狗机制,默认为30秒。
- 看门狗机制就是构造一个entry,entry是由锁标识 和一个定时任务 构成,通过锁key找到entry,当第一次是就new出一个entry,当发生锁重入时不会在创建。
- 当是新entry时,会创建一个定时任务,默认为每10秒执行一次直到释放锁时取消定时任务,重置有效期即续约,一般情况下锁不释放key就不会过期。
- 释放锁时,根据锁key找到对应的entry,把entry中的标识去掉,定时任务取消。
示意图:
3.5、总结
Redisson分布式锁原理
- 可重入: 利用hash结构记录线程id和重入次数。
- 可重试: 利用信号量和发布订阅功能实现等待、唤醒,获取锁失败的重试机制。
- 超时续约: 利用watchDog 看门狗机制,每隔一段时间执行定时任务(releaseTime/3),续约重置超时时间。
qiumin