文章目录
- 1. 什么是Redission
- 2. 为什么要使用分布式锁
- 3. 分布式锁的应用场景
- 4. 利用Redisson实现分布式锁与利用原始Redis指令和Lua脚本实现分布式锁的区别
- 5. 在SpringBoot项目中使用Redission实现分布式锁
- 6. 测试分布式锁(附可重入锁的原理)
- 7. 注意事项
- 8. Redission的读锁和写锁(附可重入锁的原理)
- 9. 可能遇到的问题
- 10. 补充知识
本次演示使用的是 Windows 版本的 Redis,Windows 环境下安装 Redis 的教程可以参考我的另一篇博文:Windows环境下安装Redis并设置Redis开机自启
1. 什么是Redission
Redission是一个在Redis基础上实现的Java客户端,它不仅提供了对Redis各种数据结构的访问接口,还封装了一系列的分布式系统常用的高级功能,比如分布式锁、原子操作、分布式集合、发布订阅消息队列等
Redission旨在简化Java应用与Redis服务之间的交互,使得Java开发者能够更加方便地使用Redis提供的各种功能
Redis和Redission的关系如下:
- 基础与扩展:Redis是基础的数据存储服务,而Redission是在此基础上提供的一系列扩展功能,使Redis在Java应用中的使用更加便捷
- 语言绑定:Redis本身是语言无关的,它支持多种编程语言的客户端。Redission作为Java特定的客户端库,为Java开发者提供了友好的API
- 功能封装:Redission封装了Redis的许多功能,并且增加了一些高级特性,如分布式对象、分布式锁、同步器等,这些都是在Redis原生功能之上的抽象
- 易用性:Redission通过抽象和封装,减少了直接使用Redis的复杂性,提高了Java开发者在处理分布式数据结构时的易用性
- 维护与支持:Redission由社区维护,并针对Java应用场景进行优化,而Redis则由其官方团队维护
2. 为什么要使用分布式锁
以下是使用分布式锁的一些主要原因:
- 保持数据一致性: 在分布式系统中,数据一致性是至关重要的。使用分布式锁可以防止并发更新导致的数据不一致问题,确保数据在所有节点之间保持一致
- 避免重复执行: 当多个进程可能执行相同的任务时,分布式锁可以防止任务被重复执行,例如,防止多个节点同时处理同一条数据
- 资源同步: 分布式锁可以用来同步对共享资源的访问,比如数据库、文件系统或者外部服务等
3. 分布式锁的应用场景
以下是分布式锁的一些具体应用场景:
- 订单处理:在电子商务系统中,避免多个服务实例同时处理同一个订单
- 库存管理:确保在更新商品库存时不会因为并发操作导致库存数量错误
- 任务调度:在分布式任务调度系统中,防止同一个任务被多个工作节点同时执行
- 缓存更新:在缓存数据需要更新时,防止多个实例同时写入,导致缓存数据不一致
4. 利用Redisson实现分布式锁与利用原始Redis指令和Lua脚本实现分布式锁的区别
4.1 实现复杂度
- Redisson:
- 提供了封装好的API,实现分布式锁非常简单,只需几行代码即可
- 内置了锁的续期、异常处理等机制,开发者无需关心这些细节
- 原始Redis指令和Lua脚本:
- 需要开发者手动编写Lua脚本和Redis指令来处理锁的获取、释放等操作
- 需要考虑锁的过期、异常处理、可重入性等复杂情况,实现起来较为复杂
4.2 功能性和灵活性
- Redisson:
- 提供了丰富的锁类型,如可重入锁、公平锁、读写锁等
- 功能相对固定,但通常能满足大部分需求
- 原始Redis指令和Lua脚本:
- 可以根据具体需求定制锁的行为,提供更高的灵活性
- 功能取决于开发者如何实现,可以实现非常特定的锁逻辑
4.3 性能
- Redisson:
- 由于封装了额外的逻辑和功能,可能会有一些性能开销
- 但对于大多数应用来说,这种开销是可以接受的
- 原始Redis指令和Lua脚本:
- 直接使用Redis指令和Lua脚本,性能通常更优,因为没有额外的封装层
- 对于性能要求极高的场景,手动优化Lua脚本可能更有优势
4.4 维护和可读性
- Redisson:
- 由Redisson社区维护,开发者可以专注于业务逻辑
- 代码可读性高,易于理解和维护
- 原始Redis指令和Lua脚本:
- 需要开发者自己维护Lua脚本和Redis指令
- 对于不熟悉Redis和Lua的开发者来说,代码可读性可能较低
总的来说,Redisson提供了更高级、更易于使用的抽象,适合大多数场景下的分布式锁需求
原始Redis指令和Lua脚本实现提供了更高的灵活性和性能,但需要更多的开发和维护工作
5. 在SpringBoot项目中使用Redission实现分布式锁
5.1 引入依赖
在项目的 pom.xml
文件中添加以下依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.32.0</version>
</dependency>
最新的版本是 3.36.0(截至2024年9月15日),Maven 官方网站中 redission 的信息:redisson-spring-boot-starter
5.2 编写配置文件
5.2.1 单节点
在 application.yml
文件中配置 Redis 的连接信息
spring:
data:
redis:
host: 127.0.0.1 # Redis服务器的主机地址
port: 6379 # Redis服务器的端口号
password: 123456 # 访问Redis服务器的密码
database: 0 # Redis数据库的索引
timeout: 2000ms # 连接Redis服务器的超时时间
5.2.2 集群模式
Redis 的集群主要有 主从模式、哨兵模式、集群模式 ,Redission 支持三种集群模式,本文只演示集群模式
spring:
data:
redis:
password: 123456
timeout: 2000ms
cluster:
nodes: 127.0.0.1:6379
如果有多个节点,多个节点之间用,
隔开,示例如下
nodes: 127.0.0.1:6379,127.0.0.1:6380, 127.0.0.1:6381, 127.0.0.1:6382
5.3 初始化 RedissonClient
创建一个配置类来初始化 RedissonClient
,并将 RedissionClient 交由 Spring 管理
将 RedissonClient
注入容器后,可以很方便地使用 RedissonClient
来创建和管理锁
5.3.1 单节点
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissionConfiguration {
@Value(value = "${spring.redis.host}")
private String host;
@Value(value = "${spring.redis.port}")
private Integer port;
@Value(value = "${spring.redis.database}")
private Integer database;
@Value(value = "${spring.redis.password}")
private String password;
@Bean
RedissonClient redissonClient() {
Config config = new Config();
SingleServerConfig singleServerConfig = config.useSingleServer();
String address = "redis://" + host + ":" + port;
singleServerConfig.setAddress(address);
singleServerConfig.setDatabase(database);
singleServerConfig.setPassword(password);
return Redisson.create(config);
}
}
5.3.2 集群模式
Redis 的集群主要有 主从模式、哨兵模式、集群模式 ,Redission 支持三种集群模式,本文只演示集群模式
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
@Configuration
public class RedissionConfiguration {
@Value(value = "${spring.data.redis.cluster.nodes}")
private List<String> nodeAddresses;
@Value(value = "${spring.data.redis.password}")
private String password;
@Bean
RedissonClient redissonClient() {
// 创建Redisson配置实例
Config config = new Config();
List<String> nodeList = nodeAddresses.stream().map(node -> "redis://" + node).toList();
String[] nodeAddressArray = nodeList.toArray(new String[0]);
// 使用集群服务器配置
config.useClusterServers()
.setScanInterval(2000) // 设置节点扫描间隔时间为2000毫秒
.setPassword(password) // 设置Redis集群的密码
.addNodeAddress(nodeAddressArray);
return Redisson.create(config);
}
}
6. 测试分布式锁(附可重入锁的原理)
编写一个简单的 controller 类,用于测试分布式锁
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.text.SimpleDateFormat;
import java.util.Date;
@RestController
@RequestMapping("/redission")
public class RedissionController {
private final RedissonClient redissonClient;
private final static String LOCK = "I_AM_LOCK";
public RedissionController(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
@GetMapping("/getString")
public String getString() {
// 1.获取一把锁,只要锁的名字一样,就是同一把锁
RLock rLock = redissonClient.getLock(LOCK);
// 2.加锁
rLock.lock(); // 阻塞式等待
try {
System.out.print(new SimpleDateFormat("HH:mm:ss").format(new Date()) + " ");
System.out.println(Thread.currentThread().getName() + ":获得锁");
Thread.sleep(8000);
} catch (Exception e) {
e.printStackTrace();
} finally {
// 3.解锁
rLock.unlock();
System.out.print(new SimpleDateFormat("HH:mm:ss").format(new Date()) + " ");
System.out.println(Thread.currentThread().getName() + ":释放锁");
}
return "聂可以";
}
}
在浏览器中打开两个标签页,同时访问接口,打印结果如下
http://localhost:8080/redission/getString
在线程阻塞期间,Redis 中会存在一个名为 I_AM_LOCK 的 key,存储的是一个哈希结构的数据,其中 value 字段表示某个线程获取这个锁的次数,锁的可重入也是依赖于 value 字段,同一个线程获取多次锁通过对锁的计数+1 来实现,释放通过对锁的计数 - 1 来实现,当锁的数为零且锁是当前线程的锁的时候才可以释放锁
线程释放锁之后,这个 key 会自动删除
7. 注意事项
-
redissonClient.getLock(LOCK)
方法获取的是写锁(也称为排他锁) -
在 Redisson 中,RLock 接口代表一个可重入的排他锁
-
rlock.lock() 方法持有锁的时间默认是 30 秒,若是业务时间超过锁的持有时间,会自动将锁的持有时间恢复为 30 秒,不用担心因为业务时间过长而导致锁自动过期的问题
-
加锁的业务只要运行完成,就不会给锁续期,如果没有及时释放锁,锁默认在 30 秒后自动释放
rlock.lock (10, TimeUnit.SECONDS); // 手动指定锁的持有时间
8. Redission的读锁和写锁(附可重入锁的原理)
编写一个简单的 controller 类,用于测试读锁和写锁的情况
import org.redisson.api.RLock;
import org.redisson.api.RReadWriteLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/lock")
public class ReadLockAndWriteLockController {
private final RedissonClient redissonClient;
private final StringRedisTemplate stringRedisTemplate;
public ReadLockAndWriteLockController(RedissonClient redissonClient, StringRedisTemplate stringRedisTemplate) {
this.redissonClient = redissonClient;
this.stringRedisTemplate = stringRedisTemplate;
}
@GetMapping("/read")
public String readValue() {
RReadWriteLock myLock = redissonClient.getReadWriteLock("readLock");
RLock rLock = myLock.readLock();
rLock.lock();
String writeValue = "";
try {
System.out.println("读锁加锁成功..." + Thread.currentThread().getId());
writeValue = stringRedisTemplate.opsForValue().get("writeValue");
TimeUnit.SECONDS.sleep(8);
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("读锁解锁成功..." + Thread.currentThread().getId());
rLock.unlock();
}
return writeValue;
}
@GetMapping("/write")
public String writeValue() {
RReadWriteLock myLock = redissonClient.getReadWriteLock("writeLock");
RLock rLock = myLock.writeLock();
rLock.lock();
String writeValue = "";
try {
System.out.println("写锁加锁成功..." + Thread.currentThread().getId());
writeValue = UUID.randomUUID().toString();
TimeUnit.SECONDS.sleep(8);
stringRedisTemplate.opsForValue().set("writeValue", writeValue);
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("写锁解锁成功..." + Thread.currentThread().getId());
rLock.unlock();
}
return writeValue;
}
}
在浏览器中访问接口,测试读锁和写锁的兼容情况
http://localhost:8080/lock/read
http://localhost:8080/lock/write
测试结论:
- 读锁 + 读锁:相当于无锁,并发读情况下,Redis 会记录所有获取了读锁的线程的信息
- 写锁 + 读锁:读锁需要等待写锁释放
- 写锁 + 写锁:第二个写锁需要等待第一个写锁的释放
- 读锁 + 写锁:写锁需要等待读锁的释放
写锁是一个排它锁(互斥锁),读锁是一个共享锁
以下是 Redis 记录的已获取到读锁的线程的信息,其中 value 字段表示某个线程获取这个锁的次数,锁的可重入也是依赖于 value 字段,同一个线程获取多次锁通过对锁的计数+1 来实现,释放通过对锁的计数 - 1 来实现,当锁的数为零且锁是当前线程的锁的时候才可以释放锁
9. 可能遇到的问题
9.1 Redis没有开启集群模式
如果 Redis 为集群模式时,启动项目报出以下错误,是因为你的 Redis 没有开启集群模式
Caused by: org.redisson.client.RedisException: ERR This instance has cluster support disabled. channel: [id: 0x84646ffd, L:/127.0.0.1:11214 - R:127.0.0.1/127.0.0.1:6379] command: (CLUSTER NODES), promise: java.util.concurrent.CompletableFuture@f9abc8f[Not completed, 1 dependents], params: []
at org.redisson.client.handler.CommandDecoder.decode(CommandDecoder.java:420) ~[redisson-3.32.0.jar:3.32.0]
at org.redisson.client.handler.CommandDecoder.decodeCommand(CommandDecoder.java:218) ~[redisson-3.32.0.jar:3.32.0]
at org.redisson.client.handler.CommandDecoder.decode(CommandDecoder.java:146) ~[redisson-3.32.0.jar:3.32.0]
at org.redisson.client.handler.CommandDecoder.decode(CommandDecoder.java:122)
解决方法:在 Redis 的配置文件中搜索 cluster-enabled 关键字,将 cluster-enabled 前面的注释解开,同时指定 Redis 节点的配置文件,接着重启 Redis 集群
9.1.1 开启集群模式
cluster-enabled
9.1.2 指定节点使用的配置文件
cluster-config-file
cluster-config-file nodes.conf
集群模式的配置文件存放在 Redis 的根目录下
9.2 没有指定Redis集群某个节点的IP地址、端口和总线端口
如果 Redis 为集群模式时,启动项目报出以下错误,是因为没有指定Redis集群某个节点的IP地址、端口和总线端口
Caused by: org.redisson.client.RedisConnectionException: Can’t connect to servers!
at org.redisson.cluster.ClusterConnectionManager.doConnect(ClusterConnectionManager.java:160) ~[redisson-3.32.0.jar:3.32.0]
at org.redisson.connection.MasterSlaveConnectionManager.connect(MasterSlaveConnectionManager.java:188) ~[redisson-3.32.0.jar:3.32.0]
at org.redisson.config.ConfigSupport.createConnectionManager(ConfigSupport.java:216) ~[redisson-3.32.0.jar:3.32.0]
解决方法:在 Redis 的配置文件中搜索 cluster-announce-ip 关键字, 编写 Redis集群的 IP 地址、端口和总线端口,,接着重启 Redis 集群
cluster-announce-ip
cluster-announce-ip 127.0.0.1
cluster-announce-port 6379
cluster-announce-bus-port 16379
- cluster-announce-ip: 这个选项指定了集群节点的IP地址。当集群节点启动时,它会向集群中的其他节点宣布自己的IP地址,其他节点可以通过这个IP地址来找到并连接到这个节点
- cluster-announce-port: 这个选项指定了集群节点的端口。集群节点会使用这个端口与其他节点通信。这个端口通常是集群节点的Redis服务器端口
- cluster-announce-bus-port: 这个选项指定了集群节点的总线端口。集群总线(cluster bus)是集群内部用于节点间通信的一种机制。每个集群节点都会监听这个总线端口,并使用它来与其他节点交换集群信息。这个端口通常是集群节点的Redis服务器端口加上10000(例如,如果集群节点的Redis服务器端口是6379,那么总线端口就是16379)
9.3 Redis 集群没有覆盖所有的 16384 个哈希槽
如果 Redis 为集群模式时,启动项目报出以下错误,是因为 Redis 集群没有覆盖所有的 16384 个哈希槽,至于哈希槽是什么,可以参考本文的 补充知识:Redis集群模式下的槽位(slot)部分和 补充知识:cluster-require-full-coverage 部分
Caused by: org.redisson.client.RedisConnectionException: Not all slots covered! Only 104 slots are available. Set checkSlotsCoverage = false to avoid this check.
at org.redisson.cluster.ClusterConnectionManager.doConnect(ClusterConnectionManager.java:169) ~[redisson-3.32.0.jar:3.32.0]
at org.redisson.connection.MasterSlaveConnectionManager.connect(MasterSlaveConnectionManager.java:188) ~[redisson-3.32.0.jar:3.32.0]
at org.redisson.config.ConfigSupport.createConnectionManager(ConfigSupport.java:216) ~[redisson-3.32.0.jar:3.32.0]
这个错误表明你的 Redis 集群没有正确地覆盖所有的 16384 个哈希槽(slots)。通常情况下,一个健康的 Redis 集群应该能够覆盖所有这些槽位。你遇到的问题是因为只有 104 个槽位可用,这可能是由于以下几个原因:
- 节点数量不足:确保你的集群有足够的节点来覆盖所有的槽位
- 节点配置问题:检查每个节点的配置是否正确,并且它们之间能够正常通信
- 节点状态问题:某些节点可能处于故障或离线状态
解决方法:让 Redission 忽略对 Redis 集群要覆盖所有的 16384 个哈希槽的检查
@Bean
RedissonClient redissonClient() {
// 创建Redisson配置实例
Config config = new Config();
List<String> nodeList = nodeAddresses.stream().map(node -> "redis://" + node).toList();
String[] nodeAddressArray = nodeList.toArray(new String[0]);
// 使用集群服务器配置
config.useClusterServers()
.setScanInterval(2000) // 设置节点扫描间隔时间为2000毫秒
.setPassword(password) // 设置Redis集群的密码
.addNodeAddress(nodeAddressArray)
.setCheckSlotsCoverage(false);
return Redisson.create(config);
}
9.4 某个槽位没有具体的节点负责
如果 Redis 为集群模式时,启动项目报出以下错误,是因为某个槽位没有具体的节点负责
org.springframework.data.redis.RedisSystemException: Node for slot: 0 hasn’t been discovered yet. Check cluster slots coverage using CLUSTER NODES command. Increase value of retryAttempts and/or retryInterval settings.
at org.redisson.spring.data.connection.RedissonBaseReactive.lambdaKaTeX parse error: Undefined control sequence: \[ at position 67: …j a v a : 95 ) \̲[̲ r e d i s s o …SerializedFluxSink.error(FluxCreate.java:189) ~[reactor-core-3.5.2.jar:3.5.2]
虽然说我们让 Redission 忽略对 Redis 集群要覆盖所有的 16384 个哈希槽的检查,但是具体要用到某个槽位的时候,如果该槽位没有节点负责,还是会报错的
解决方法:指定每一个 Redis 节点该负责的槽位(由于只有一个节点,所以让节点承包所有的槽位),接着重启 Redis 集群
打开 Redis 的集群配置文件(nodes.conf),将 connected 后面的、vars 前面的数字换成 0-16383 (connected 后面的每个数字都代表某一个槽位)
8b1e14056eb0b9467b2505a05c7830f37619ee00 :6379@16379 myself,master - 0 0 0 connected 0-16383
vars currentEpoch 0 lastVoteEpoch 0
10. 补充知识
10.1 Redis集群模式下的槽位(slot)
CRC16 在 Redis Cluster 中的作用是确保数据能够均匀地分布在集群中的各个节点上,并且能够高效准确地定位到每个 key 所在的节点
Redis Cluster 共有16384个散列槽,对 key 作 CRC16 运算后将结果对 16384 取余,得到的结果就是这个 key 该存储在哪个槽位
每个节点各负责一部分的散列槽,比如说,一个集群有三个节点,其中
- Node A 负责 0 至 5500 散列槽
- Node B 负责 5501 至 11000 散列槽
- Node C 负责 11001 至 16383 散列槽
这样设计可以方便地从集群中新增或移除节点,比如需要新增一个节点 D,只需要从 A、B、C 三个节点各分配一部分散列槽给D 节点
同样,如果要移除A,只需要将 A 负责的散列槽分发给 B 和 C,当 A 不再负责任何散列槽时,可以安全地从集群中移除
10.2 cluster-require-full-coverage
在 Redis 集群的配置文件中,cluster-require-full-coverage
是一个配置项,其可能的值是 yes
或 no
- 当
cluster-require-full-coverage
设置为yes
时,这意味着Redis集群需要所有散列槽都被至少一个节点覆盖才能正常工作。如果在某个时刻,由于某些节点故障或者其他原因导致某些散列槽没有被任何节点服务,那么整个集群将停止处理写操作(不过读操作可能仍然可用,这取决于具体的配置和情况)。这种配置确保了集群的一致性,因为在某些散列槽没有被覆盖的情况下,可能会导致数据丢失或写入失败 - 当
cluster-require-full-coverage
设置为no
时,即使某些散列槽没有被任何节点服务,集群仍然可以继续处理那些可以被服务到的散列槽上的写操作。这种配置可以提高集群的可用性,因为它允许部分故障发生时,集群的其他部分仍然可以正常工作
当 cluster-require-full-coverage
设置为 yes
时,其意义是:
- 集群需要全部散列槽都有节点在服务,才能接受写操作
- 如果有散列槽未被服务,整个集群将无法执行写命令,客户端会收到一个错误信息,指出集群当前无法处理写请求
- 这是一种较为保守的配置方式,更注重数据的一致性和完整性,但在某些情况下可能会牺牲集群的可用性
通常,在生产环境中,是否设置 cluster-require-full-coverage
为 yes
取决于具体的应用需求和对于一致性与可用性的权衡