SpringBoot整合Redis
在pom.xml文件中引入redis相关依赖
<!--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>
<version>2.6.0</version>
</dependency>
修改springboot配置文件
#Redis服务器地址
spring.redis.host=192.168.199.129
#Redis服务器连接端口号
spring.redis.port=6379
#Redis数据库索引(默认0)
spring.redis.database=0
#连接超时时间(ms)
spring.redis.timeout=180000
#连接池最大连接数
spring.redis.lettuce.pool.max-active=20
#最大阻塞等待时间(负数表示没有限制)
spring.redis.lettuce.pool.max-wait=-1
#连接池中的最大空闲连接
spring.redis.lettuce.pool.max-idle=5
#连接池中最小空闲连接
spring.redis.lettuce.pool.min-idle=0
创建配置类
@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)) //缓存过期10分钟 ---- 业务需求。
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))//设置key的序列化方式
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer)) //设置value的序列化
.disableCachingNullValues();
RedisCacheManager cacheManager = RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
return cacheManager;
}
}
测试
成功!
Redis事务和锁机制
Redis事务是一个单独的隔离操作:事务中的所有命令都会序列化,按顺序的执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
Redis事务的主要作用就是串联多个命令防止别的命令插队。
Multi,Exec,discard
输入Multi命令开始,输入的命令都会依次进入命令队列中,但不会执行,直到输入Exec后,Redis会将之前的命令列表中的命令一次执行。
组队过程中可以通过discard来放弃组队。
事务的错误处理
组队阶段命令错误
报告错误,并且执行时整个队列都会被取消,其他命令也不执行
执行阶段错误
不会报错,但在执行后只有出错的命令被取消,其他命令正常执行
为什么要使用事务
案例:当多个人是同一个账户同时进行操作。
此时就产生了错误
两种解决方式
悲观锁
每次拿数据时都假定对方会修改,所以会给数据上锁,别人想拿到这个数据就会block(阻塞)直到它拿到锁,传统的关系型数据库里面就用到了很多这种锁,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
乐观锁
每次拿数据都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。Redis就是利用这种check-and-set机制实现事务的。
Redis中使用锁机制
watch key...:在执行multi之前,先执行watch key1 key2...可以监视一个或多个key,如果在事务执行之前这个或这些key被其他命令所改动,那么事务将被打断
设置一个值,并在两个客户端中都监视这个key,并都开启事务操作
客户端一
客户端二
分别在事务中操作这个key
客户端一
客户端二
客户端一执行事务
客户端二执行事务
事务执行失败
unwatch命令:取消watch命令对所有key的监视
Redis事务三特性
单独的隔离操作
事务中的所有命令都会序列化,按顺序的执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
没有隔离级别的概念
队列中的命令没有提交之前都不会被实际执行,因为事务提交前任何指令都不会被实际执行
不保证原子性
事务中如果有一条指令执行失败,其后的命令仍然会被执行,没有回滚。
秒杀案例基本实现
前端页面
<html>
<head>
<meta charset="UTF-8" xmlns:th="http://www.thymeleaf.org">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<script type="text/javascript" src="static/jquery.min.js"></script>
</head>
<body>
<h1>一元秒杀</h1>
<form id="msform" action="/app/redis/doseckill">
<input type="hidden" id="prodid" name="prodid" value="0101">
<input type="button" id="miaosha_btn" name="seckill_btn" value="秒杀点我">
</form>
</body>
<script type="text/javascript">
$(document).on('click', '#miaosha_btn', function () {
var url = $("#msform").attr("action");
$.post(url, $("#msform").serialize(), function (data) {
if (data == "false") {
alert("抢光了");
$("#miaosha_btn").attr("disabled", true);
} else
alert("抢到了");
});
})
</script>
</html>
测试前需要先在redis中添加库存
后端代码
@Controller
@RequestMapping("/redis")
public class RedisTestController {
@Autowired
private RedisTemplate redisTemplate;
@ResponseBody
@RequestMapping("/doseckill")
public boolean seckill(String prodid) {
String userid = new Random().nextInt(2000) + "";
System.out.println(userid);
//prodid非空判断(避免请求不是通过页面发出的)
if (null == prodid) {
return false;
}
//拼接key
String prodKey = "sk:" + prodid + ":qt";
String userKey = "sk:" + prodid + ":user";
//获取库存,如果为库存为null,秒杀还未开始,或已结束
String prod = redisTemplate.opsForValue().get(prodKey) + "";
if (null == prod) {
System.out.println("秒杀未开始或已结束");
return false;
}
//判断用户是否重复参加
Boolean isExit = redisTemplate.opsForSet().isMember(userKey, userid);
if (isExit) {
System.out.println("用户重复参加");
return false;
}
//判断商品数量,如果小于1则抢完了
if (Integer.parseInt(prod) < 1) {
System.out.println("手慢了,抢完了");
return false;
}
//秒杀过程
//库存-1
redisTemplate.opsForValue().decrement(prodKey, 1);
String num = redisTemplate.opsForValue().get(prodKey) + "";
System.out.println("库存剩余" + num);
//成功用户添加到清单
redisTemplate.opsForSet().add(userKey, userid);
System.out.println(userid + "秒杀成功!");
Set<String> members = redisTemplate.opsForSet().members(userKey);
String users = "";
for (String member : members) {
users += member;
}
System.out.println("已参加用户:" + users);
return true;
}
}
通过页面测试即可
秒杀并发模拟
使用工具ab模拟测试
centos6自带,7,8需要安装
一路y即可
安装成功
使用
参数:-n表示请求次数,-c表示并发次数,-t当使用put/post方式提交,设置类型,-p放置post请求参数
测试:
因为需要携带数据,现在当前目录下新建postfile
vi postfile
编辑如下
保存退出
设置商品库存
输入命令开始模拟并发操作
ab -n 1000 -c 100 -p postfile -T application/x-www-form-urlencoded http://自己端口号:8080/app/redis/doseckill
结果如下
商品库存
此时发现高并发条件下程序出现超卖的问题
也可能会出现连接超时
超卖和连接超时的解决
SpringBoot配置Redis连接池
在引入了spring-boot-starter-data-redis和commons-pool2依赖后,在spring配置文件中设置(默认是不使用连接池的)
spring.redis.host=192.168.199.129
spring.redis.port=6379
spring.redis.database=0
#连接超时事件
spring.redis.timeout=5000ms
#连接池最大连接数
spring.redis.lettuce.pool.max-active=20
#连接池中最大空闲连接
spring.redis.lettuce.pool.max-idle=10
#连接池中最小空闲连接
spring.redis.lettuce.pool.min-idle=5
#连接池最大阻塞等待时间
spring.redis.lettuce.pool.max-wait=5000ms
不使用springboot需要通过代码实现配置
超卖问题的解决
修改controller方法代码
@Controller
@RequestMapping("/redis")
public class RedisTestController {
@Autowired
private RedisTemplate redisTemplate;
@ResponseBody
@RequestMapping("/doseckill")
public boolean seckill(String prodid) {
String userid = new Random().nextInt(2000) + "";
System.out.println(userid + "参与秒杀");
//prodid非空判断(避免请求不是通过页面发出的)
if (null == prodid) {
return false;
}
//拼接key
String prodKey = "sk:" + prodid + ":qt";
String userKey = "sk:" + prodid + ":user";
//监视库存
redisTemplate.watch(prodKey);
//获取库存,如果为库存为null,秒杀还未开始,或已结束
String prod = redisTemplate.opsForValue().get(prodKey) + "";
if (null == prod) {
System.out.println("秒杀未开始或已结束");
return false;
}
//判断用户是否重复参加
Boolean isExit = redisTemplate.opsForSet().isMember(userKey, userid);
if (isExit) {
System.out.println(userid + "用户重复参加");
return false;
}
//判断商品数量,如果小于1则抢完了
if (Integer.parseInt(prod) < 1) {
System.out.println("手慢了,抢完了");
return false;
}
//秒杀过程
//使用事务
redisTemplate.setEnableTransactionSupport(true);
redisTemplate.multi();
//库存-1
redisTemplate.opsForValue().decrement(prodKey, 1);
//成功用户添加到清单
redisTemplate.opsForSet().add(userKey, userid);
List results = redisTemplate.exec();
if (results == null || results.size() == 0) {
System.out.println(userid + "秒杀失败!");
RedisConnectionUtils.unbindConnection(redisTemplate.getConnectionFactory());
return false;
} else {
System.out.println(userid + "秒杀成功!");
RedisConnectionUtils.unbindConnection(redisTemplate.getConnectionFactory());
return true;
}
}
}
重新设置库存,并删除秒杀成功列表
执行模拟并发命令
ab -n 1000 -c 100 -p postfile -T application/x-www-form-urlencoded http://自己端口号:8080/app/redis/doseckill
查看结果
超卖问题解决
但是依然存在乐观锁造成的库存遗留问题,就是模拟并发操作结束了,但是库存还没有抢完
那么如何解决呢?
使用LUA脚本语言
LUA脚本在Redis中的优势
-
具体操作暂时不做了解
Redis持久化操作
Redis 提供了两种持久化机制:第一种是 RDB,又称快照(snapshot)模式,第二种是 AOF 日志,也就追加模式。
RDB(Redis DataBase)
在指定的时间间隔将内存中的数据集快照写入磁盘,它恢复时是将快照文件直接读到内存中
备份是如何执行的
Redis会单独创建(fork)一个子进程来进行持久化,会先将数据写入到一个临时文件中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件。整个过程中,主进程是不进行任何IO操作的,这就确保了极高的性能,如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那么RDB方式要比AOF方式更加的高效。RDB的缺点是最后一次持久化后的数据可能丢失。
Fork
Fork的作用是复制一个与当前进程一样的进程。新进程的所有数据(变量,环境变量,程序计数器等)数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程
在Linux程序中,fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用,处于效率考虑。Linux中引入了“写时复制技术”,也就是只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。
那么子进程的物理空间没有代码,怎么去取指令执行exec系统调用呢?
在fork之后exec之前两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间,如果不是因为exec,内核会给子进程的数据段、堆栈段分配相应的物理空间(至此两者有各自的进程空间,互不影响),而代码段继续共享父进程的物理空间(两者的代码完全相同)。而如果是因为exec,由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间。
在网上看到还有个细节问题就是,fork之后内核会通过将子进程放在队列的前面,以让子进程先执行,以免父进程执行导致写时复制,而后子进程执行exec系统调用,因无意义的复制而造成效率的下降。
RDB持久化流程
相关配置(redis.conf)中
临时文件名字
临时文件路径
表示当前启动目录所在目录下
当Redis无法写入磁盘的时候,直接关掉Redis的写操作
检查完整性
在存储快照后,还可以让redis使用CRC64算法来进行数据校验。
但是这样做会增加大约10%的性能消耗,如果希望获取到最大的性能提升,可以关闭此功能,推荐yes
save:设置保存策略
就是在多少秒内,至少几个key改变,则在规定秒数后进行持久化操作。否则不执行,重新计时,计数
save vs bgsave
save:只管保存,其他不管,全部阻塞,手动保存,不建议
bgsave:Redis会在后台异步进行快照操作,快照同时还可以响应用户端请求
可以通过lastsave命令获取最后一次成功执行快照的时间
RDB优势和劣势
优势:
适合大规模的数据恢复
对数据完整性和一致性的要求不高更适合使用
节省磁盘空间
恢复速度快
劣势:
Fork的时候,内存中的数据被克隆了一份,大致两倍的膨胀性需要考虑
虽然Redis在fork时使用了写时拷贝技术,但如果数据庞大时还是比较消耗性能
在一定时间间隔做一次备份,如果Redis意外down掉,那么就会丢失最后一次快照后的所有修改
AOF(Append Only File)
以日志的形式来记录每个写操作(增量保存),将Redis执行过的所有写指令记录下来(读操作不记录)只追加文件但不可以改写文件,redis启动之初会读取该文件重新构建数据,换而言之,redis重启的话就根据日志文件的内容将写指令从前到后执行一次已完成数据的恢复工作
AOF持久化流程
客户端的请求写命令会被append追加到AOF缓冲区内;
AOF缓冲区根据AOF持久化策略[always,everysec,no]将操作sync同步到磁盘的AOF文件中;
AOF文件大小超过重写策略或手动重写时,会对AOF文件rewrite重写,压缩AOF文件容量;
Redis服务重启时,会重新load加载AOF文件中的写操作达到数据恢复的目的;
AOF开启
默认不开启
可以在redis.conf中配置文件名称,默认为appendonly.aof
AOF文件的保存路径和RDB的路径一致
将appendonly改为yes,重启redis来开启AOF
AOF和RDB同时开启的情况:系统默认取AOF的数据(数据不存在丢失),但是开启AOF前的数据就没有了
异常恢复
AOF同步频率
appendfsync always
始终同步,每次Redis的写入都会立刻记入日志;性能较差但是数据完整性比较好
appendfsync everysec
每秒同步,每秒记入日志一次,如果宕机,本秒的数据可能丢失
appendfsync no
redis不主动进行同步,把同步时机交给操作系统.
Rewrite压缩
AOF采用文件追加方式,文件会越来越大,为避免出现此种状况,新增了重写机制,当AOF文件的大小超过所设定的阈值时,Redis就会启动AOF文件的内容压缩,只保留可以恢复数据的最小指令集,可以使用命令bgrewriteof
重写原理,如何实现
AOF文件持续增长二过大时,会fork出一条新进程来将文件重写(也是先写临时文件最后再rename),redis4.0版本之后的重写,事实上就是把rdb的快照,以二进制的形式附在新的aof头部,作为已有的历史数据,替换掉原来的流水账操作。
重写策略
no-appendfsync-on-rewrite
如果no-appendfsync-on-rewrite=yes,不写入aof文件只写入缓存,用户请求不会阻塞,但是在这段时间如果宕机会丢失这段时间的缓存数据。(降低数据安全性,提高性能)
如果no-appendfsync-on-rewrite=no,还是会把数据放入磁盘,但遇到重写操作,可能会发生阻塞(数据安全,性能降低)
触发机制,何时重写
Redis会记录上次重写时AOF的大小,默认配置是当AOF文件的大小是上次rewrite后大小的一倍且文件大于64M时触发
重写虽然可以节约大量磁盘空间,减少恢复时间,但是每次重写还是有一定负担的,因此设定Redis要满足一定的条件才会进行重写。
auto-aof-rewrite-percentage:设置重写的基准值,文件达到100%开始重写(原来重写后的double)
auto-aof-rewrite-min-size:设置重写的基准值,最小文件64MB,达到这个值开始重写
重写流程
AOF优势和劣势
优势:
备份机制更稳健,丢失数据概率很低
可读的日志文本,通过操作AOF文件,可以处理误操作
劣势:
比起RDB占用更多的磁盘空间
恢复备份速度更慢
每次读写都同步的话,有一定的性能压力