SpringBoot集成Redis:实现分布式锁(redistemplate,lua,redisson)
SpringBoot集成redis
1.实现lettuce客户端:SpringBoot集成Redis:实现lettuce客户端-CSDN博客
2.实现分布式锁:SpringBoot集成Redis:实现分布式锁(redistemplate,lua,redisson)-CSDN博客
概述
在多线程的环境下,为了保证一个代码块在同一时间只能由一个线程访问,Java中我们一般可以使用synchronized语法和ReetrantLock去保证,这实际上是本地锁的方式。但是现在公司都是流行分布式架构,在分布式环境下,如何保证不同节点的线程同步执行呢?
实际上,对于分布式场景,我们可以使用分布式锁,它是控制分布式系统之间互斥访问共享资源的一种方式。
比如说在一个分布式系统中,多台机器上部署了多个服务,当客户端一个用户发起一个数据插入请求时,如果没有分布式锁机制保证,那么那多台机器上的多个服务可能进行并发插入操作,导致数据重复插入,对于某些不允许有多余数据的业务来说,这就会造成问题。而分布式锁机制就是为了解决类似这类问题,保证多个服务之间互斥的访问共享资源,如果一个服务抢占了分布式锁,其他服务没获取到锁,就不进行后续操作。
分布式锁特点
互斥性: 同一时刻只能有一个线程持有锁
可重入性: 同一节点上的同一个线程如果获取了锁之后能够再次获取锁
锁超时:和J.U.C中的锁一样支持锁超时,防止死锁
高性能和高可用: 加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效
具备阻塞和非阻塞性:能够及时从阻塞状态中被唤醒
分布式锁实现方式
基于数据库、基于Redis、基于zookeeper,本文只要讲解redis的方式。
方式一:Redisremplate
通过上一篇帖子,已经可以搭建出来一份redis的框架了,现在只需要原来的基础上进行修改就行了
RedisController
import org.example.util.RedisUtil;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import java.util.concurrent.TimeUnit;/*** @Author majinzhong* @Date 2025/5/12 10:51* @Version 1.0*/
@RestController
@RequestMapping("/redis")
public class RedisController {private final RedisTemplate<String,String> redisTemplate;public RedisController(RedisTemplate<String,String> redisTemplate) {this.redisTemplate = redisTemplate;}/*** 添加锁* @param key* @param value* @return*/@GetMapping("/lock")public boolean lock(String key, String value) {Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, 15, TimeUnit.MILLISECONDS);return success != null && success;}/*** 释放锁* @param key* @param value*/@GetMapping("/unlock")public void unlock(String key, String value) {String currentValue = redisTemplate.opsForValue().get(key).toString();if (currentValue != null && currentValue.equals(value)) {redisTemplate.delete(key);}}
}
在上面的示例中,我们注入了一个RedisTemplate实例,并提供了两个方法来获取和释放锁。lock()方法尝试在Redis中设置一个键值对,如果该键不存在,则该方法会返回true并获得锁;如果该键已经存在,则该方法将返回false表示无法获取锁。unlock()方法检查当前Redis中键值对是否与提供的值匹配,如果是,则释放锁。
通过源码可以知道,有两个方法,一个是设置过期时间的,一个是不设置过期时间的
存在风险
当使用不设置过期时间的setIfAbsent()的方法时,如果在其他业务逻辑进行时,如果出现问题,可能导致unlock()方法无法执行,那么此时锁就会变成死锁。
当使用可以设置过期时间的setIfAbsent()的方法时,如果其他业务逻辑执行时间超过设置的过期时间,就会出现第一个线程未执行完毕,第二个线程可能持有锁的情况。情况如下:
1)线程 A 成功获取了锁,并设置了一个过期时间;
2)过了一段时间后,锁的过期时间到了,Redis 自动将锁删除;
3)同时,线程 B 也在尝试获取锁,由于此时锁已经被 Redis 删除了,线程 B 成功获取了锁;
4)线程 A 在这个时候调用了 RedisTemplate 的 delete() 方法来释放锁,由于此时 Redis 中已经不存在该锁了,所以线程 A 的操作实际上是删除了线程 B 获取到的锁,从而导致线程 B 的锁失效。
方式二:Lua
继续在原来代码上进行修改
RedisController
import org.example.util.RedisUtil;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import java.util.Arrays;/*** @Author majinzhong* @Date 2025/5/12 10:51* @Version 1.0*/
@RestController
@RequestMapping("/redis")
public class RedisController {private final RedisTemplate<String,String> redisTemplate;public RedisController(RedisTemplate<String,String> redisTemplate) {this.redisTemplate = redisTemplate;}// 锁的过期时间,单位毫秒private static final long LOCK_EXPIRE_TIME = 30000;// 获取锁的 Lua 脚本private static final String LOCK_SCRIPT ="if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +"redis.call('pexpire', KEYS[1], ARGV[2]); " +"return true; " +"else return false; " +"end";// 释放锁的 Lua 脚本private static final String UNLOCK_SCRIPT ="if redis.call('get', KEYS[1]) == ARGV[1] then " +"redis.call('del', KEYS[1]); " +"return true; " +"else return false; " +"end";/*** 添加锁* @param key* @param value* @return*/@GetMapping("/lock")// 获取分布式锁public boolean lock(String key, String value) {String[] keys = {key};String[] args = {value, String.valueOf(LOCK_EXPIRE_TIME)};RedisScript<Boolean> script = new DefaultRedisScript<>(LOCK_SCRIPT, Boolean.class);Boolean result = (Boolean) redisTemplate.execute(script, Arrays.asList(keys), args);return result != null && result;}/*** 释放锁* @param key* @param value* @return*/@GetMapping("/unlock")// 释放分布式锁public boolean unlock(String key, String value) {String[] keys = {key};String[] args = {value};RedisScript<Boolean> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Boolean.class);Boolean result = (Boolean) redisTemplate.execute(script, Arrays.asList(keys), args);return result != null && result;}
}
在上述代码中,LOCK_SCRIPT 和 UNLOCK_SCRIPT 分别是获取锁和释放锁的 Lua 脚本,其中 KEYS[1] 表示 Redis 键名,ARGV[1] 表示 Redis 键值,ARGV[2] 表示锁的过期时间。lock() 方法使用 RedisTemplate 的 execute() 方法执行获取锁的 Lua 脚本,unlock() 方法使用 execute() 方法执行释放锁的 Lua 脚本。
需要注意的是,在使用 Lua 脚本执行 Redis 操作时,为了避免多次编译 Lua 脚本而降低性能,可以将 Lua 脚本的 SHA1 值缓存起来,然后使用 EVALSHA 命令来执行缓存的 Lua 脚本,这样可以提高 Redis 操作的性能。同时,如果在执行 EVALSHA 命令时,Redis 返回的是 NOSCRIPT 错误,则说明缓存中不存在对应的 Lua 脚本,此时需要使用 EVAL 命令来编译并执行 Lua 脚本。
Lua脚本加锁的基本原理
先使用 Redis 的 setnx 命令尝试设置锁,如果成功则表示获取到锁,否则表示锁已经被其他线程获取。在获取锁的同时,Lua 脚本会将锁的值设置为当前时间加上锁的有效期。这样可以保证锁的有效期不会因为程序异常而导致一直占用锁,从而产生死锁。
存在缺点
1)锁的过期时间设置不当可能会导致问题。在上面的示例中,锁的过期时间是固定的,为 30 秒,但实际应用场景中,锁的过期时间应该根据具体业务场景和系统负载情况来设置,过短可能会导致频繁地获取和释放锁,过长可能会导致锁失效不及时。
2)可能会存在死锁问题。当获取锁的线程出现异常或者网络异常等情况导致锁未能释放时,其他线程就无法获取到该锁,就会出现死锁的问题。为了避免这种情况的发生,可以在 Redis 中为每个锁设置一个过期时间,避免出现锁一直被占用但未被释放的情况。
3)在 Redis 集群环境下可能存在问题。在 Redis 集群环境下,由于数据分片和主从复制等机制的存在,可能会导致锁在某些节点上未能及时同步,从而出现锁失效或者死锁的问题。为了避免这种情况的发生,可以使用 Redis 的 RedLock 算法来实现分布式锁,该算法可以在多个 Redis 节点之间进行协作,确保锁的正确性和可靠性。
方式三:Redisson
由第一篇帖子可以知道redis的主题要客户端由lettuce、jedis,而redisson也是Redis的一个java客户端,主要提供了分布式锁的实现。其在提供了redis基本操作的同时,还具备其他客户端一些不具备的高精功能,例如:分布式锁+看门狗、分布式限流、远程调用等等。Reddissin的缺点是api抽象,学习成本高。
引入依赖
<!-- 集成redisson依赖 --><dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.13.6</version></dependency>
首先查看redisson-spring-boot-starter的内部依赖
可以看到redisson-spring-boot-starter内部已经引入了redis的依赖,并移除了lettuce和jedis的客户端。所以appliction内的配置就有问题了,需要修改为redisson的配置
完整依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><groupId>org.example</groupId><artifactId>springboot_redis</artifactId><version>1.0-SNAPSHOT</version><properties><maven.compiler.source>8</maven.compiler.source><maven.compiler.target>8</maven.compiler.target><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties><dependencyManagement><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-dependencies</artifactId><version>2.6.2</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.22</version><scope>provided</scope></dependency><!-- 集成redis依赖 -->
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-data-redis</artifactId>-->
<!-- </dependency>--><!-- redis连接池 -->
<!-- <dependency>-->
<!-- <groupId>org.apache.commons</groupId>-->
<!-- <artifactId>commons-pool2</artifactId>-->
<!-- </dependency>--><!-- 集成redisson依赖 --><dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.13.6</version></dependency><dependency><groupId>de.ruedigermoeller</groupId><artifactId>fst</artifactId><version>2.43</version></dependency></dependencies><build><resources><resource><directory>src/main/java</directory><!--所在的目录--><includes><!--包括目录下的.properties,.xml 文件都会被扫描到--><include>**/*.properties</include><include>**/*.xml</include></includes><filtering>false</filtering></resource><resource><directory>src/main/resources</directory><includes><include>**/*.*</include></includes></resource></resources><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><version>2.6.2</version></plugin></plugins></build>
</project>
Redisson配置
1.yml文件配置
需要在resource下新建redisson.yml文件
appliaction.yml
server:port: 7410spring:redis:redisson:file: classpath:redisson.yml
redisson.yml
singleServerConfig:
# password: 123456address: "redis://127.0.0.1:6379"database: 0
threads: 0
nettyThreads: 0
codec: !<org.redisson.codec.FstCodec> {}
transportMode: "NIO"
注意:这种通过读文件进行加载的方式在启动是会报一个错误NoClassDefFoundError: org/nustaq/serialization/FSTObjectOutput
解决方式:添加下面的依赖即可。
<dependency><groupId>de.ruedigermoeller</groupId><artifactId>fst</artifactId><version>2.43</version></dependency>
2.application.yml
这种方式是直接全部都写在application.yml里面
server:port: 7410spring:redis:redisson:config: |singleServerConfig:address: "redis://127.0.0.1:6379"database: 1threads: 0nettyThreads: 0codec: !<org.redisson.codec.FstCodec> {}transportMode: "NIO"
3.RedissonConfig
这种方式是在配置类中进行配置
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.redisson.config.TransportMode;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;/*** @Author majinzhong* @Date 2025/5/15 16:00* @Version 1.0*/
@Configuration
public class RedissonConfig {@Beanpublic RedissonClient redissonClient(){Config config = new Config();config.setTransportMode(TransportMode.NIO);SingleServerConfig singleServerConfig = config.useSingleServer();//可以用"rediss://"来启用SSL连接singleServerConfig.setAddress("redis://127.0.0.1:6379");
// singleServerConfig.setPassword("123456");RedissonClient redisson = Redisson.create(config);return redisson;}
}
上面这三种配置方式,选择其中一种即可。
RedissonController
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;import java.util.concurrent.TimeUnit;/*** @Author majinzhong* @Date 2025/5/15 16:04* @Version 1.0*/
public class RedissonController {@Autowiredprivate RedissonClient redissonClient;public boolean lock(String key, long expireTime) {RLock lock = redissonClient.getLock(key);try {return lock.tryLock(expireTime, TimeUnit.MILLISECONDS);} catch (InterruptedException e) {Thread.currentThread().interrupt();return false;}}public void unlock(String key) {RLock lock = redissonClient.getLock(key);if (lock.isLocked()) {lock.unlock();}}
}
通过示例,lock 方法用于加锁,unlock 方法用于解锁。在 lock 方法中,通过 RedissonClient 的 getLock 方法获取锁对象,然后调用 tryLock 方法进行加锁,如果加锁成功则返回 true,否则返回 false。在 unlock 方法中,通过锁对象的 isLocked 方法判断锁是否被占用,如果是则调用 unlock 方法进行解锁。
对比
相较于前两种方式,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。从而解决了业务逻辑大于锁超时时间的问题。
默认情况下,看门狗的检查锁的超时时间是30秒钟(就是续期30s),也可以通过修改Config.lockWatchdogTimeout来另行指定,锁的初始过期时间默认也是30s。
扩展:Redisson配置
单节点配置redisson.yml
# 单节点配置
singleServerConfig:# 连接空闲超时,单位:毫秒idleConnectionTimeout: 10000# 连接超时,单位:毫秒connectTimeout: 10000# 命令等待超时,单位:毫秒timeout: 3000# 命令失败重试次数,如果尝试达到 retryAttempts(命令失败重试次数) 仍然不能将命令发送至某个指定的节点时,将抛出错误。# 如果尝试在此限制之内发送成功,则开始启用 timeout(命令等待超时) 计时。retryAttempts: 3# 命令重试发送时间间隔,单位:毫秒retryInterval: 1500# 密码#password: redis.shbeta# 单个连接最大订阅数量subscriptionsPerConnection: 5# 客户端名称#clientName: axin# # 节点地址address: redis://127.0.0.1:6379# 发布和订阅连接的最小空闲连接数subscriptionConnectionMinimumIdleSize: 1# 发布和订阅连接池大小subscriptionConnectionPoolSize: 50# 最小空闲连接数connectionMinimumIdleSize: 32# 连接池大小connectionPoolSize: 64# 数据库编号database: 6# DNS监测时间间隔,单位:毫秒dnsMonitoringInterval: 5000
# 线程池数量,默认值: 当前处理核数量 * 2
#threads: 0
# Netty线程池数量,默认值: 当前处理核数量 * 2
#nettyThreads: 0
# 编码
codec: !<org.redisson.codec.JsonJacksonCodec> {}
# 传输模式
transportMode: "NIO"
# 续期时间,单位:毫秒
lockWatchdogTimeout: 10000
多节点配置redisson.yml
clusterServersConfig:idleConnectionTimeout: 10000connectTimeout: 10000timeout: 3000retryAttempts: 3retryInterval: 1500failedSlaveReconnectionInterval: 3000failedSlaveCheckInterval: 60000password: nullsubscriptionsPerConnection: 5clientName: nullloadBalancer: !<org.redisson.connection.balancer.RoundRobinLoadBalancer> {}subscriptionConnectionMinimumIdleSize: 1subscriptionConnectionPoolSize: 50slaveConnectionMinimumIdleSize: 24slaveConnectionPoolSize: 64masterConnectionMinimumIdleSize: 24masterConnectionPoolSize: 64readMode: "SLAVE"subscriptionMode: "SLAVE"nodeAddresses:- "redis://127.0.0.1:7004"- "redis://127.0.0.1:7001"- "redis://127.0.0.1:7000"scanInterval: 1000pingConnectionInterval: 0keepAlive: falsetcpNoDelay: false
threads: 16
nettyThreads: 32
codec: !<org.redisson.codec.MarshallingCodec> {}
transportMode: "NIO"
lockWatchdogTimeout: 10000