Springboot整合Redis
一、Springboot整合redis
redis可以通过使用java代码来实现 第一部分文档中 在终端操作redis的所有命令,Spring已经帮我们封装了所有的操作,所以变得很简单了。
Spring专门提供了一个模块来进行这些操作的封装,这个模块就是Spring-data-redis,当然该模块下抽象出了很多接口,但是我们在开发中应用中你只要关注两个类就够了,分别是“org.springframework.data.redis.core.StringRedisTemplate”和 “org.springframework.data.redis.core.RedisTemplate”。
这两个类封装了程序对 redis 的所有操作,而且当 SpringBoot 出来之后,在SpringBoot 中整合 Spring-data-redis 可以说是简单、优雅、大方、得体。
1.1 项目相关配置说明
-
创建springboot工程,pom.xml中引入redis 相关依赖
注意:注意版本冲突问题!
<dependencies>
<!--web启动器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- spring2.X集成redis所需common-pool2-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
</dependencies>
在 spring-boot-starter-data-redis 依赖中就包含了另外两个依赖:
spring-data-redis 、lettuce-core
- 配置文件 application.yml 中配置redis的相关信息
server:
port: 9993
spring:
#应用名称
application:
name: spring_test_handler
# Redis配置
redis:
# Redis服务器地址
host: 127.0.0.1
# Redis服务器连接端口
port: 6379
# Redis服务器连接密码(默认为空)
password: 123456..
# 连接超时时间(毫秒)
timeout: 1800000
# Redis数据库索引(默认为0)
database: 0
# 连接池驱动的配置
lettuce:
pool:
# 连接池中最大连接数
max-active: 20
# 取得连接的最大等待时间 -1 表示没限制
max-wait: -1
# 连接池中允许空闲连接的最大数量
max-idle: 8
# 连接池中空闲连接的最小数量
min-idle: 2
1.2 使用 RedisTemplate
- 创建测试类并执行代码:
package com.test.test_redis_j2cache.testSpace;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
@SpringBootTest
public class RedisConnet {
/**
* 自动注入 spring 提供好的操作 redis 的模板类 RedisTemplate
* */
@Autowired
private RedisTemplate redisTemplate;
@Test
public void testRedisTemplate(){
//保存一个简单的数据存入redis中
redisTemplate.opsForValue().set("name","张三");
//从redis中获取存入的数据
Object name = redisTemplate.opsForValue().get("name");
System.out.println("从redis中获取的name数据为:"+name);
}
}
这里有个需要注意的点:
在数据库中使用命令 get key 无法获取我们通过程序保存的” name : 张三 “值。 原因是我们使用 RedisTemplate 类操作的数据都是被转成了字节数组保存到 redis 中的,包括 Key 和 value 都被转换了。
RedisTemplate 操作数据是将数据转化成字节数组保存到 redis,这是一个序列化的过程,取数据时将字节数组转换成对象是一个反序列化过程。 使用这种方式的优点是存取的速度快(因为数据被序列化了),缺点是存储的数据只能 java 程序读取(因为存储的时候是 java序列化)。
如果只是要保存普通的字符串则使用 StringRedisTemplate。
-
也可以通过创建一个配置类,实现 redisTemplate 存入非二进制数据。
配置类是固定的写法,直接引入即可。
package com.test.test_redis_j2cache.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
/**
* @ClassName : CachingConfigurerSupport
* @Description : Redis配置类: 实现 RedisTemplate存入数据序列化方式的改变
* @Author : AD
*/
@EnableCaching
@Configuration
public class RedisConfig extends CachingConfigurerSupport {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
RedisSerializer<String> redisSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
template.setConnectionFactory(factory);
//key序列化方式
template.setKeySerializer(redisSerializer);
//value序列化
template.setValueSerializer(jackson2JsonRedisSerializer);
//value hashmap序列化
template.setHashValueSerializer(jackson2JsonRedisSerializer);
return template;
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisSerializer<String> redisSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
//解决查询缓存转换异常的问题
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
// 配置序列化(解决乱码的问题),过期时间600秒
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(600))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
.disableCachingNullValues();
RedisCacheManager cacheManager = RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
return cacheManager;
}
}
同个配置类就可以实现 RedisTemplate 存入数据序列化的问题!
1.3 使用StringRedisTemplate
当你的 redis 存的是字符串数据或者你要存取的数据就是字符串类型的时候,那么你就使用 StringRedisTemplate 就可以了。
但是如果你的数据是复杂的对象类型,而取出的时候又不想做任何的数据转换,此时可以将对象转换成 json 格式的数据再进行存储。
- pom.xml中引入fastjson,对json数据格式的操作工具
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.43</version>
</dependency>
- StringRedisTemplate测试类代码:
package com.test.test_redis_j2cache.testSpace;
import com.alibaba.fastjson2.JSON;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.HashMap;
import java.util.Map;
/**
* @ClassName : RedisConnet
* @Description : redis连接测试
* @Author : AD
*/
@SpringBootTest
public class RedisConnet {
/**
* 自动注入 spring 提供好的操作 redis 的模板类 RedisTemplate
* */
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
public void testStringRedisTemplate(){
stringRedisTemplate.opsForValue().set("age","测试中文22");
Object age = stringRedisTemplate.opsForValue().get("age");
System.out.println("从redis中获取的age数据为:"+age);
Map<String, Object> map = new HashMap<>();
map.put("name","张三");
map.put("age",22);
map.put("sex","男");
//通过fastjson 将map数据转换为json格式字符串在使用StringRedisTemplate进行存储
stringRedisTemplate.opsForValue().set("map", JSON.toJSONString(map));
}
}
- 测试结果
现在是面向json格式的字符串进行操作,优势是任何语言都可以读取存储的数据,因为基本上主流的语言都是支持json格式的数据。
总结:
- 其实两者之间的区别主要在于他们使用的序列化类:
- RedisTemplate 使用的是 JdkSerializationRedisSerializer 存入数据会将数据先序列化成字节数组 后在存入Redis 数据库,取出数据的时候会将 redis 中的字节数组转换成对象(反序列化的过程)。
- StringRedisTemplate 使用的是 StringReidsSerializer
- RedisTemplate 是 StringRedisTemplate 的父类,在继承的过程中指定了泛型就是<String,String>
二、Springboot的redis注解介绍
在开中某些方法查询的结果要放到缓存中(redis),下一次再调用该方法查询的时候先缓存中查找,如果缓存中有数据就直接返回,否则从关系型数据库中去查询数据。
主配置文件中增加 : spring.cache.type = redis
启动类/配置类 中增加: @EnableCaching
2.1 @Cacheable注解的使用
-
封装Service层
这里封装一个Service层,模拟一个查询数据库的方法,同时方法上使用 **@Cacheable** 注解。
package com.test.test_redis_j2cache.server;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* @ClassName : CacheRedisServer
* @Description : 测试@Cacheable注解,模拟server层查询数据库
*/
@Service
public class CacheRedisServer {
/**
* Description: 缓存中有 names 就会直接返回(不在查询关系型数据库),没有则要查询
* AOP 编程 --> 在执行该方法之前要先做一个查询缓存的增强处理 --> 既然是AOP操作,就需要先生成代理对象
*
* @param
* @return java.util.List<java.lang.String>
*/
@Cacheable(value = "names")
public List<String> getList(){
//模拟重数据库中查询数据
List<String> list = new ArrayList<>();
list.add("张三");
list.add("王五");
System.out.println("执行了查询数据库操作!!");
return list;
}
}
- 创建一个测试类,模拟Controller层,调用Service层查询数据库获取数据
package com.test.test_redis_j2cache.testSpace;
import com.test.test_redis_j2cache.server.CacheRedisServer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest
/**
* @ClassName : RedisConnet
* @Description : redis连接测试
* @Author : AD
*/
@SpringBootTest
public class RedisConnet {
//注入查询数据库的Service层
@Autowired
private CacheRedisServer cacheRedisServer;
@Test
public void testSpringCache() {
System.out.println("====启动Test调用Server层方法=======");
System.out.println("list = " + cacheRedisServer.getList());
}
}
通过执行Test方法两次,可以发现,Service层方法并未执行,而是AOP操作直接通过redis缓存中,获取到响应数据。
2.2 缓存 @Cacheable
根据方法对其返回结果进行缓存,下次请求时,如果缓存存在,则直接读取缓存数据返回;如果缓存不存在,则执行方法,并把返回的结果存入缓存中。一般用在查询方法上。查看源码,属性值如下:
属性/方法名 | 解释 |
---|---|
value | 缓存名,必填,它指定了你的缓存存放在哪块命名空间 |
cacheNames | 与value差不多,二选一即可 |
key | 可选属性,可以使用 SpEl 标签自定义缓存的key |
-
缓存 @CachePut
使用该注解标志的方法,每次都会执行,并将结果存入指定的缓存中。其他方法可以直接从响应的缓存中读取缓存数据,而不需要再去查询数据库。一般用在新增方法上。
比如 addEmp 方法上可以添加注解(或者更新的方法上)。查看源码,属性值如下:
属性/方法名 解释 value 缓存名,必填,它指定了你的缓存存放在哪块命名空间 cacheNames 与value差不多,二选一即可 key 可选属性,可以使用 SpEl 标签自定义缓存的key
-
缓存@CacheEvict
使用该注解标志的方法,会清空指定的缓存。一般用在更新或者删除方法上,查看源码,属性值如下:
属性/方法名 解释 value 缓存名,必填,它指定了你的缓存存放在哪块命名空间 cacheNames 与value差不多,二选一即可 key 可选属性,可以使用 SpEl 标签自定义缓存的key allEntries 是否清空所有缓存,默认为false。如果指定为true,则方法调用后将立即清空所有的缓存 注意: 缓存默认使用的是 RedisTemplate 的操作方式,使用注解的缓存策略实现保存数据的时候是将数据转换成字节数组去保存的,如果不需要字节数组,则需要额外的配置。改变缓存方式序列化策略。
配置类代码: [ 1.2 使用 RedisTemplate ] 节中的 RedisConfig ,redis配置类。
三、流水线操作Redis数据(Pipeline)
3.1 Pipeline概述
在向 redis 操作数据的时候,比如保存数据就得向 redis 服务发送命令,要发送命令客户端和服务端必须建立连接,但是建立连接是需要耗费时间和性能的,在这种操作模式下,如果批量操作数据,比如要保存一万个对象,那么客户端和服务端就得建立一万次连接(虽然有了连接池,但是还是每次都需要从连接池中获取连接)。
这样就会不断的创建连接浪费时间和性能,于是就出现了 redis 的流水线操作,只需要取得一次连接,所有的命令都在一个连接中去发送,从而节省出了客户端和服务端频繁建立连接的时间提高了性能。
将命令和结果打包之后一次发送,节省很多网络时间。Redis的命令是非常块的,微秒级的,网络却很慢,所以 pipeline 要做到的就是控制网络传输时间和建立连接的时间 两种时间的开销!
3.2 Springboot 实现Pipeline
- 传统方式实现redis保存一千个对象
package com.test.test_redis_j2cache.testSpace;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.HashMap;
import java.util.Map;
/**
* @ClassName : RedisConnet
* @Description : redis连接测试
* @Author : AD
*/
@SpringBootTest
public class RedisConnet {
@Autowired
private RedisTemplate redisTemplate;
/**
* Description: 传统方式操作redis,保存1000个map数据 计算耗时
* @param
* @return void
*/
@Test
public void testRedis1(){
//记录开始时间
long currentTimeMillis = System.currentTimeMillis();
HashMap map = new HashMap();
map.put("name", "张三");
map.put("age", 22);
map.put("sex", "男");
for (int i = 0; i < 1000; i++) {
redisTemplate.opsForValue().set("emp"+i,map);
}
//记录结束时间
long newCurrentTimeMillis = System.currentTimeMillis();
System.out.println("传统方式耗时:"+(newCurrentTimeMillis-currentTimeMillis));
}
}
通过传统方式可以大概记录出,向redis中存入1千条map数据总共消耗了34648ms:
- 采用 流水线Pipeline 方式向redis存入1千条数据
package com.test.test_redis_j2cache.testSpace;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @ClassName : RedisConnet
* @Description : redis连接测试
* @Author : AD
*/
@SpringBootTest
public class RedisConnet {
@Autowired
private RedisTemplate redisTemplate;
/**
* Description: 采用流水线方式 Pipeline 向redis存入1千条数据,计算耗时
* @param
* @return void
*/
@Test
public void testRedis2() {
//记录开始时间
long currentTimeMillis = System.currentTimeMillis();
HashMap map = new HashMap();
map.put("name", "张三");
map.put("age", 22);
//返回值就是所有的执行结果保存到 list 集合中
List list = redisTemplate.executePipelined(new RedisCallback<Object>() {
@Override
public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
for (int i = 0; i < 1000; i++) {
redisConnection.set(("emp" + i).getBytes(), JSON.toJSONString(map).getBytes());
}
return null;
}
});
//记录结束时间
long newCurrentTimeMillis = System.currentTimeMillis();
System.out.println("流水线方式耗时:" + (newCurrentTimeMillis - currentTimeMillis));
System.out.println("执行pipeline命令的结果:"+list.toString());
}
}
通过测试,使用流水线 pipeline方式,仅仅只花费了 1615ms。
在运行如此多的命令时,需要考虑的另一个问题是内存空间的消耗,因为对于程序而言,它最终会返回一个 List 对象,如果过多的命令执行返回的结果都保存到这个 List 中,一个对象如果占用内存过大可能会造成内存溢出,尤其在那些高并发的网站中就很容易造成 JVM 内存溢出的异常, 这个时候应该考虑使用迭代的方法执行 Redis命令。
- 分批迭代执行 redis存储任务:
package com.test.test_redis_j2cache.testSpace;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @ClassName : RedisConnet
* @Description : redis连接测试
* @Author : AD
*/
@SpringBootTest
public class RedisConnet {
/**
* 自动注入 spring 提供好的操作 redis 的模板类 RedisTemplate
* */
@Autowired
private RedisTemplate redisTemplate;
/**
* Description: pipeline 分批迭代方式向redis存入1千条数据
* @return void
* @date 2024-05-29
*/
public static int index;
@Test
public void testRedis3() {
//记录开始时间
long currentTimeMillis = System.currentTimeMillis();
HashMap map = new HashMap();
map.put("name", "张三");
map.put("age", 22);
//分批迭代方式向redis存入1千条数据
for (int i = 0; i <10; i++) {
//记录每次迭代的起始位置
index =i*100;
List list = redisTemplate.executePipelined(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
for (int j = 0; j < 100; j++) {
redisConnection.set(("emp" + (index + j)).getBytes(), JSON.toJSONString(map).getBytes());
}
return null;
}
});
//返回值就是所有的执行结果保存到 list 集合
System.out.println("第"+(i+1)+"次执行pipeline命令的结果:" + list.toString());
}
//记录结束时间
long newCurrentTimeMillis = System.currentTimeMillis();
System.out.println("分批迭代方式耗时:" + (newCurrentTimeMillis - currentTimeMillis));
}
}
如此操作,可以避免内存益处现象。
总结:
- 在使用流水线操作的时候添加数据一定要使用RedisConnection类对象。
- 流水线pipeline操作必须要把key 和 value 都转换成字节数组之后才能进行操作。
四、在Spring中实现Redis事务
在 Redis 中事务默认也是自动提交的,如果要求事务手工提交则需要先开启事务,然后再
手工提交事务,提交了事务才会执行的命令。
4.1 在Spring中控制事务:
package com.test.test_redis_j2cache.testSpace;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @ClassName : RedisConnet
* @Description : redis连接测试
* @Author : AD
*/
@SpringBootTest
public class RedisConnet {
@Autowired
private RedisTemplate redisTemplate;
/**
* Description: 测试redis 事务的使用, redisCallback 回调函数
*
* @param
* @return void
* @date 2024-05-29
*/
@Test
public void testRedisCallback(){
//创建事务回调函数,并封装命令
SessionCallback sessionCallback = new SessionCallback(){
@Override
public Object execute(RedisOperations redisOperations) throws DataAccessException {
//开启事务
redisOperations.multi();
Object result = null;
try {
//执行命令
redisOperations.opsForValue().set("name","张三");
redisOperations.opsForValue().set("age",22);
redisOperations.opsForValue().increment("age"); //年龄自增长加1
//提交事务
result = redisOperations.exec();
}catch (Exception e){
//事务回滚
redisOperations.discard();
e.printStackTrace();
System.out.println("sessionCallback 事务回滚:"+e.getMessage());
}
return result;
}
};
//执行事务
Object execute = redisTemplate.execute(sessionCallback);
System.out.println("事务执行结果:"+execute.toString());
}
}
事务操作执行结果:
总结: 在 redis 中,对于一个存在问题的命令,如果在入队的时候就已经出错,之前的入队的命令可以被回滚(回滚的本质是清空队列中的命令),如果这个错误命令在入队的时候并没有报错,而是在执行的时候出错了 ,那么 redis 默认跳过这个命令执行后续命令(无法回滚其他正确的命令)。也就是说 redis 事务没有完全实现事务的原子性,所以 redis 的事务又叫做弱事务。
4.2 Redis实现的乐观锁
Redis 自己提供了乐观锁的支持,基本步骤如下:
- A用户对数据开启监视: 在这里会读取出要监视的数据的值
- 开启事务
- 此时B用户修改了被 A 用户监视的数
- A 用户再次修改数据的时候是需要使用 exec 命令提交事务的,但是此时会在提交之前发现了监控的数据不一样了(监控到数据发生了改变),则当前的事务提交失败了。 在 Redis 中是直接支持乐观锁的。
- Springboot实现乐观锁
package com.test.test_redis_j2cache.testSpace;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* @ClassName : RedisConnet
* @Description : redis连接测试
* @Author : AD
*/
@SpringBootTest
public class RedisConnet {
@Autowired
private RedisTemplate redisTemplate;
/**
* Description: 测试Redis 乐观锁的实现
*
* @param
* @return void
* @date 2024-05-29
*/
@Test
public void testRedisLock(){
//创建事务回调函数,并封装命令
SessionCallback sessionCallback = new SessionCallback(){
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
operations.opsForValue().set("age",18);
//开启监视数据(乐观锁的实现)
operations.watch("age");
//开启事务
operations.multi();
try {
System.out.println("线程进入睡眠状态 10s");
TimeUnit.SECONDS.sleep(10);
}catch (Exception e) {
e.printStackTrace();
}
//事务中修改监视数据age
operations.opsForValue().set("age",100);
return operations.exec();
}
};
Object execute = redisTemplate.execute(sessionCallback);
System.out.println("事务执行结果打印:"+execute.toString());
Object age = redisTemplate.opsForValue().get("age");
System.out.println("目前redis中age的值:"+age);
}
}
在没有B用户的干预情况下,java测试类正常执行后的输出结果为:
-
现在重新执行一次测试类方法,并在线程睡眠的 10s 时间内,(即A用户java测试类提交了一个key=age 的数据,并且对该数据进行了监视),在对 key = age 数据进行修改操作(模拟用户B),之后看测试类的运行结果。
查看测试类这边的输出结果:
可以发现,被监视的数据,中途被B用户修改之后,A用户(测试类)对age参数的修改是没有成功的。最后的age数据也变为了 B用户修改的200。这就是乐观锁的实现。
五、分布式锁
在传统单体应用单机部署的情况下,为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,可以使用并发处理相关的功能进行互斥控制。
随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上(跨 JVM 进程),这将使原单机部署情况下的并发控制锁策略失效,原始的互斥控制就不能提供分布式锁的能力。为了解决这个问题就需要一种跨机器的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!
分布式锁主流的实现方案:
- 基于数据库实现分布式锁
- 基于缓存(Redis等)
- 基于Zookeeper
每一种分布式锁解决方案都有各种的优缺点,性能最高的是redis实现,可靠性最强的是zookeeper。
5.1 分布式锁场景
在分布式开发的系统之中,一定会在项目之中出现有若干个业务中心,那么某些操作就需要通过总的业务中心协调这些所有子业务中心的调用,但是,所有的数据库都在各自的业务中心上,那么在这样的情况下,总业务层是无法使用传统的方式进行互斥控制的,我们只能通过分布式锁来解决。
这里采用秒杀业务作为场景,来实现分布式锁的构建。
上图中的秒杀业务场景中可能会出现一个问题:因为有很多用户同时参与秒杀活动。如果商品还剩下1件时,A用户秒杀成功后库存减为0,但是还没提交事务;此时 B 用户查询到库存还是1,继续参与秒杀活动并且成功。这样就会出现超卖现象。
在之前单服务器部署项目的时候我们可以使用传统的互斥锁解决这一问题,但是现在是分布式项目,所以之前传统的锁无法实现我们的需求了,于是分布式锁就登场了。
要解决这个问题就得使用分布式锁的思想来实现了,分布式锁的实现思路: 第一个用户先到则马上去到 redis 中去注册锁,注册成功则认为获取了锁,取得当前秒杀的操作权,当库存管理和订单处理业务都执行完毕之后就
去 redis 中去注销(释放锁),后面来访问的用户需要到 redis 中查看业务是否被其他用户注册了,如果被注册了就等待释放锁。
5.2 redis中实现分布式锁演示
分布式锁主要是通过 setnx 命令来实现。
# 设置锁,setnx命令会判断该key是否存在,不存在返回1并且设置value值,存在返回0
setnx user 10
# 释放锁,通过del 删除锁
del user
# 释放锁成功之后下一个用户才可 通过 setnx 来获取锁
那这种实现方式有个问题存在:现在上锁之后,锁都是我们程序员手动释放的。如果我们因为一些原因没有释放锁,那锁就会一直存在。
解决该问题的方案很简单,就是上锁以后,给锁设置一个过期时间,当时间到达之后,锁就自动释放了。
setnx user 10 # 设置锁
expire user 20 # 设置锁的过期时间 20秒
现在我们设置锁,并且设置锁的失效时间是由两个命令实现的。并不具备原子性。如果上锁成功,还没来得及设置锁的过期时间,redis服务就挂掉了,那锁的失效时间还是没有设置成功,如何解决?
我们可以在上锁的同时设置过期时间。
setnex user 10 nx ex 20 # 上锁的同时 设置过期时间是20
ttl user # 查看指定key值的存活时期 不存在就为负数
5.3 springboot 实现分布式锁
5.3.1 分布式锁核心实现代码
- 定义分布式锁工具类(核心程序)
package com.test.test_redis_j2cache.util;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* @ClassName : RedisLockUtil
* @Description : 分布式锁实现工具类
* @Author : AD
*/
@Component
public class RedisLockUtil {
@Autowired
private RedisTemplate redisTemplate;
/**
* Description: 获取锁:本质就是在 redis 中保存一个键值
*
* @param key 在 redis 中注册锁的 key 值
* @param uid 用户的编号,作为锁的 value
* @param time 超时时间 超过了指定的时间则锁要自动释
* @param timeUnit 时间单位
* @return java.lang.Boolean
*/
public Boolean lock(String key, String uid, Long time, TimeUnit timeUnit){
//调用命令 setnx
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(key, uid, time, timeUnit);
return aBoolean;
}
/**
* Description: 释放锁的方法:本质就是删除 redis 中的锁的 ke
*
* @param key 要删除的数据的 key
* @param uid
* @return java.lang.Boolean
*/
public Boolean unlock(String key, String uid){
//先查询判断是否存在锁
String lock =(String) redisTemplate.opsForValue().get(key);
//可以释放锁
if (lock != null){
//判断锁是否是自己的锁,防止释放到其他人的锁
if (lock.equalsIgnoreCase(uid)){
return redisTemplate.delete(key);
}
}
return false;
}
}
RedisLockUtil 工具类,主要封装了分布式锁的获取和释放方法。在业务处理之前先通过调用工具类中的获取锁方法,获取锁成功之后在执行后续操作,最后需要释放锁。
- 迷模拟秒杀活动的控制层Controller封装:
package com.test.test_redis_j2cache.controller;
import com.test.test_redis_j2cache.util.RedisLockUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import java.util.concurrent.TimeUnit;
/**
* @ClassName : TestRedisLockController
* @Description : 测试Redis分布式锁测试Controller
* @Author : AD
*/
@RestController
public class TestRedisLockController {
@Autowired
private RedisLockUtil redisLockUtil;
/**
* 定义分布式锁Key
* */
private String lockKey = "redisLock";
/**
* 指定库存数量
* */
private static int STOCK = 3;
/**
* Description: 模拟秒杀活动下单访问接口。
*/
@GetMapping("/testSpike")
public String testSpikeMethod(ServletRequest request){
//以当前请求线程的名称作为用户的编号
//String uid = Thread.currentThread().getName();
//以当前请求线程的session id作为用户的编号
HttpServletRequest httpServletRequest =(HttpServletRequest) request;
String uid = httpServletRequest.getSession().getId().substring(0,5);
System.out.println("======== 当前参与秒杀用户"+uid+" ========");
try {
while (true){
//尝试获取锁
if (redisLockUtil.lock(lockKey, uid,30L, TimeUnit.SECONDS)){
//判断库存是否充足
if (STOCK > 0){
//秒杀任务抢锁成功,同时库存还有剩余,扣减库存
System.out.println("uid="+uid+"-->[获取到锁,正在执行秒杀业务....");
TimeUnit.SECONDS.sleep(4);
//扣减库存
--STOCK;
System.out.println("uid="+uid+"-->[秒杀成功,库存剩余:"+STOCK+"]");
return "亲爱的 " + uid + "用户你好,你已经成功参与秒杀,请稍后查看!";
}
System.out.println("uid="+uid+"-->[秒杀失败,库存余额不足!!!!!!!");
return "uid="+uid+"-->秒杀失败,该商品被抢空了,请选择其他商品!";
}else {
//获取锁失败,说明该用户已经抢占了锁,等待30秒后重试
System.out.println("uid="+uid+"-->没有获取到锁,重新尝试获取锁....");
TimeUnit.MILLISECONDS.sleep(2000);
}
}
}catch (Exception e){
e.printStackTrace();
return "服务器繁忙异常,请稍后重试...";
}finally {
//释放锁操作!
Boolean unlock = redisLockUtil.unlock(lockKey, uid);
String sout = unlock ? "释放锁操作成功!" : "释放锁操作失败!";
System.out.println("uid="+uid+"-->"+sout);
}
}
}
封装好Controller层后,我们同时访问多次该接口,来模拟高并发情况。
访问结果如下:
5.3.2 解决分布式锁续期问题
如果出现了业务时间很长,那么可以续期,也就是要将当前用户的锁延期再释放。使用一个线程在用户取得锁之后的开始计算时间,当到超时时间的三分之二的时候去 redis中查看当前用户是否还占有锁,如果占有锁意味着可能当前业务可能要超时,此时就为当前用户的锁续期,续期可以续为超时的三分之二,本次案例续期一次(具体根据需求)
- 修改RedisLockUtil工具类,添加分布式锁续期的方法。
package com.test.test_redis_j2cache.util;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* @ClassName : RedisLockUtil
* @Description : 分布式锁实现工具类
* @Author : AD
*/
@Component
public class RedisLockUtil {
@Autowired
private RedisTemplate redisTemplate;
/**
* Description: 获取锁:本质就是在 redis 中保存一个键值
*
* @param key 在 redis 中注册锁的 key 值
* @param uid 用户的编号,作为锁的 value
* @param time 超时时间 超过了指定的时间则锁要自动释
* @param timeUnit 时间单位
* @return java.lang.Boolean
*/
public Boolean lock(String key, String uid, Long time, TimeUnit timeUnit){
//调用命令 setnx
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(key, uid, time, timeUnit);
if (aBoolean){
//如果获取分布式锁成功,同时需要执行为当前业务续期的问题(也就是要给当前业务获取到的锁加时间)
extendLockTime(key,uid,time,timeUnit);
}
return aBoolean;
}
/**
* Description: 释放锁的方法:本质就是删除 redis 中的锁的 ke
*
* @param key 要删除的数据的 key
* @param uid
* @return java.lang.Boolean
*/
public Boolean unlock(String key, String uid){
//先查询判断是否存在锁
String lock =(String) redisTemplate.opsForValue().get(key);
//可以释放锁
if (lock != null){
//判断锁是否是自己的锁,防止释放到其他人的锁
if (lock.equalsIgnoreCase(uid)){
return redisTemplate.delete(key);
}
}
return false;
}
/**
* Description: 续期锁的方法:本质就是更新 redis 中的锁的超时时间。这里只做了一次续期操作。
*
* @param key 分布式锁Key
* @param uid 用户的编号,作为锁的 value
* @param extendTime 分布式锁续期时间
* @param timeUnit 时间单位
* @return java.lang.Boolean
*/
public void extendLockTime(String key,String uid,Long extendTime,TimeUnit timeUnit){
//从成功获取锁开始计算 往后推 超时时间的三分之二 去检测当前用户是否还持有锁,如果持有锁,表示该业务可能超时(原始的超时时间不够处理业务),则需要为其续期
//能否在此直接休眠? 如果在这里休眠 则主线程都休眠了,所以不能在此直接休眠
//创建一个子线程来实现续期
Thread thread = new Thread(() -> {
try {
//休眠时间为 超时时间的三分之二
TimeUnit.SECONDS.sleep(extendTime/3*2);
//去检测当前业务是否还持有锁
if (uid.equalsIgnoreCase((String) redisTemplate.opsForValue().get(key))){
//为当前用户进行锁续期
redisTemplate.expire(key, extendTime, timeUnit);
System.out.println("当前用户:"+uid+" ---> 分布式锁续期成功!");
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
//线程要设置为守护线程
thread.setDaemon(true);
//将线程启动
thread.start();
}
}
这里还需要将修改Contrller层封装的秒杀接口,将锁自动释放时间设置为3秒,模拟业务执行需要时间设置为4s,然后调用秒杀接口查看输出日志:
上面的锁续期只能续期一次,有可能出现续期之后时间还是不够,可以根据需求设置续期具体次数(比如 3 次),超过 3 次则认为当前用户操作的业务出现问题了,强制释放锁,但是要记录好日志,同时发送消息给运维人员人工处理,一般续期时是过期时间的三分之二。
5.3.3 秒杀业务存在的问题记录
-
当请求获取到锁–>正在执行业务逻辑,当前还没有释放锁,但是这个时候程序挂掉了,那么该请求将永远拿到该锁不会再去释放,别的请求将无法获取到锁。
解决方案:在获取锁的时候,为锁同时添加一个过期时间
-
释放别人的锁 --> A用户已经获取到锁,在执行业务,但是还没有执行完成,过期时间到了(这里不考虑续期),那么该锁就会被自动释放[key 值自动失效](但是A用户后面还会执行 unlock释放锁业务);此时请求 B 能够获取该锁,且执行业务逻辑。此时请求 A 执行完成需要释放锁,但是此时释放的锁是请求 B 的,也就是释放别人的锁。
释放别人的锁解决方法:在上述获取锁的时候,我们仅单单设置了 key 值,但是 value 设置的为 null,我们可以将上述版本优化为在拿取锁的时候同时设置 key 和value,将 value 设置为随机数,在释放锁的时候,先去判断一下该 key 的 value 值是否是之前设置的 value 值,是的话说明是自己的锁,进行释放,否则不是。
通过 K V 同时判断是否是自己的分布式锁,这样就避免了释放别人的锁的问题。
-
业务没有执行完毕,但是分布式锁时间已经过期,这种问题可以采用写一个守护线程,然后每隔固定时间去查看 redis 锁是否过期,如果没有过期的话就延长其过期时间,也就是为其锁续期。
上面也就是俗称的看门狗机制,上述逻辑已经有技术实现——Redission。
为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
-
互斥性。在任意时刻,只有一个客户端能持有锁。
-
不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
-
解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
-
加锁和解锁必须具有原子性。
六、Redisson实现分布式锁
实现Redis的分布式锁,除了自己基于redis client 原生api 来实现之外,还可以使用开源框架:Redisson。
Redisson是一个企业级的开源 Redis Client,也提供了分布式锁的支持, Redisson就是用于在 Java 程序中操作 Redis 数据库,它使得我们可以在程序中轻松地使用 Redis。Redisson 在java.util 中常用接口的基础上,为我们提供了一系列具有分布式特性的工具类。
Redisson底层采用的是 Netty 框架。支持 Redis 2.8 以上版本,支持 Java1.6+以上版本。
- 添加Redisson 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!--web启动器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- spring2.X集成redis所需common-pool2-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- Redisson:提供了一系列具有分布式特性的工具类,包括分布式锁,分布式集合,分布式消息等。 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.14.1</version>
</dependency
在项目的 pom.xml 依赖管理文件中引入 redisson开源框架的依赖 org.redisson.Redisson。
- 配置 Redisson 定义配置类
package com.test.test_redis_j2cache.config;
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.stereotype.Component;
/**
* @ClassName : RedissonConfig
* @Description : 开源框架分布式锁Redisson实现 初始化配置类
* @Author : AD
*/
@Component
public class RedissonConfig {
@Value("${spring.redis.host}")
private String HOST ;
@Value("${spring.redis.port}")
private String PORT ;
@Value("${spring.redis.password}")
private String PASSWORD ;
/**
* Description: 单机模式初始化RedissonClient
*
* @param
* @return org.redisson.api.RedissonClient
*/
@Bean
public RedissonClient redisClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://"+HOST+":"+PORT).setPassword(PASSWORD);
return Redisson.create(config);
}
}
- 通过 Redisson 框架,完成分布式锁的实现:
package com.test.test_redis_j2cache.controller;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.concurrent.TimeUnit;
/**
* @ClassName : RedissonLockController
* @Description : Redisson框架实现分布式锁功能!!
* @Author : AD
*/
@RestController
public class RedissonLockController {
@Autowired
HttpServletRequest request;
@Autowired
private RedissonClient redissonClient;
/**
* 规定好分布式锁的key值
* */
private static final String LOCK_KEY ="RedissonLock";
/**
* 定义好库存数量
* */
private static int STOCK = 3;
@GetMapping("testRedisson")
public String redissonLockTest(){
//以requestId作为 用户id,区别请求唯一性
String uid = request.getSession().getId().substring(0, 5);
//公平锁 保证 Redisson 客户端线程将以其请求的顺序获得锁
//获取锁 这里使用 while 循环 实现了一个自旋锁的
RLock fairLock = redissonClient.getFairLock(LOCK_KEY);
try {
// 具有 Watch Dog 自动延期机制 默认续 30s 每隔 30/3=10
fairLock.lock();
System.out.println("【" + uid + "】 获取到了锁,正在执行秒杀的业务.....");
if (STOCK > 0){
//表示获取到锁了
//正常是需要调用其他子业务中心的(比如说库存管理和订单管理中心
TimeUnit.SECONDS.sleep(2);
//减去库存
--STOCK;
System.out.println("【" + uid + "】 秒杀成功,剩余库存:" + STOCK);
return "秒杀成功,剩余库存:" + STOCK;
}else {
System.out.println("【" + uid + "】 秒杀失败,库存已经被抢光!!");
return "秒杀失败,库存已经被抢光!!";
}
}catch (Exception e){
e.printStackTrace();
return "服务器繁忙,请稍后重试!";
}finally {
//释放锁
System.out.println("【" + uid + "】 准备释放锁!");
if (fairLock.isLocked() && fairLock.isHeldByCurrentThread()){
fairLock.unlock();
}
}
}
}
多次调用该请求接口测试结果如下:
总结:
- 关于分布式锁的实现方式有很多种,并不只是可以使用 redis 实现,只不过使用redis 是更为简便和性能更高的选择,所以核心是你要理解分布式锁的思想,具体使用什么技术实现其实可以根据实际情况自己选择的,比如可以使用 mysql 实现,也可以使用 zookeeper 实现。
- 每一种方案都有其自己的优势和劣势,包括 redis 也有自己的不足,但是总体来说瑕不掩瑜,关于分布式锁中还有很多细节没有考虑,比如高可用层面的问题(当前的 redis 如果宕机了则会导致整个系统瘫痪)等。
- 就算是使用同一种技术来实现分布式锁,也都有不同的实现方案,比如案例还可以使用:面向切面的编程,使用 SpringAop 的前置通知(取得锁)和后置通知(释放锁)。