Redis 5. 事务和锁机制,秒杀案例 Demo
参考课程:https://www.bilibili.com/video/BV1Rv41177Af
参考书:https://blog.csdn.net/liu8490631/article/details/124290851
1. Redis 事务定义
Redis 事务是一个单独的隔离操作,事务中的所有命令都会序列化,按顺序地执行。
事务在执行过程中,不会被其他地客户端发送来地命令请求所打断。
Redis 事务地主要作用就是串联多个命令防止别地命令插队。
2. Multi,Exec,Discard
输入 Multi 后,输入的命令会依次进入命令队列,但不会执行,直到输入 Exec 后,Redis 会将之前的命令队列中的命令依次执行,discard 会抛弃命令队列中的命令。
类似于 MySQL 中 start transaction/begin 开启事务,commit 提交事务,rollback 回滚事务。
3. 事务错误处理
当 Redis 事务中有命令报错 ERROR,则该事务中的所有命令都不执行:
事务中没有错误,事务执行过程中出现错误,则报错的语句不执行,其他命令正常执行:
4. 事务冲突的问题
一般情况下,三个事务同时修改余额,可能会导致余额不够买一个商品但是买下来了的情况。这就是事务冲突。解决冲突的方法:
4.1 悲观锁(Pessimistic Lock)
每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞,直到它拿到锁。
-
传统的关系型数据库里面就用到了很多这种锁机制,比如行锁,表锁,读锁,写锁等,都是在做操作之前先上锁。
-
缺点:效率低
4.2 乐观锁(Optimistic Lock)
每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。
-
乐观锁适用于多读的应用类型,这样可以提高吞吐量
-
Redis 采用的就是这种 check-and-set 机制实现事务的
-
典型场景:抢票
4.3 WATCH key [key …]
在执行 MULTI
之前,先执行 watch key1 [key2 ...]
,可以监视一个(或者多个)key
,如果在事务执行之前这个或这些 key 被其他命令所改动,那么事务将被打断。
事务 1 和事务 2 同时 watch 一个 key,然后都 multi 开启事务:
- 事务 1 对 key 进行修改,exec,修改成功
- 事务 2 对 key 进行修改,exec,可以看到返回(nil),修改失败
- unwatch 可以取消 key 的监视,若执行 watch 命令后又执行了 exec 或者 discard 的话,就不用执行 unwatch 了。
5. Redis 事务三特性
5.1 单独隔离性
- 事务中的所有命令都会序列化,按照顺序执行。
- 事务在执行过程中,不会被其他的客户端发送来的命令请求所打断。
5.2 没有隔离级别的概念
- 队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行。
5.3 不保证原子性
- 事务执行过程中如果有一条命令执行失败,其后面的命令仍然会被执行,没有回滚
- 若在队列中发生语法错误等则该事务中的所有元素都会被 discard
6. 秒杀案例 Demo
需求:
- 商品库存:个数减少
- 秒杀成功清单:加人
6.0 前情提要
SpringBoot 默认序列化方式可读性太差了:
换成 Jackson
@Configuration
public class RedisConfig {
/**
* 设置redis键值的序列化方式
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
/*
* value值的序列化可采用如下几种方式
* 1.默认采用JdkSerializationRedisSerializer 序列化后的长度最短,时间适中,但不是明文显示
* 2.采用Jackson2JsonRedisSerializer 明文显示,序列化速度最快,长度适中,但会使存入redis中timestamp类型的数据以long类型存储
* 3.采用GenericJackson2JsonRedisSerializer 明文显示,在redis中显示了@class字段保存有类型的包路径,反序列化更容易,但是序列化时间最长,长度最大,明文显示
* 4.自定义FastJsonRedisSerializer实现RedisSerializer接口
*
* 这里使用Jackson2JsonRedisSerializer,并对日期类型做特别处理
*/
Jackson2JsonRedisSerializer<Object> redisSerializer = new Jackson2JsonRedisSerializer<Object>(Object.class);
ObjectMapper om = new ObjectMapper();
// 指定要序列化的域,field,get和set,以及修饰符范围,ANY是都有包括private和public
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
/*
* 指定序列化输入的类型,类必须是非final修饰的,final修饰的类,比如String,Integer等会跑出异常
* 过期方法:om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
*/
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.WRAPPER_ARRAY);
// 日期序列化处理
om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
om.registerModule(new Jdk8Module())
.registerModule(new JavaTimeModule())
.registerModule(new ParameterNamesModule());
redisSerializer.setObjectMapper(om);
// 设置值(value)的序列化采用Jackson2JsonRedisSerializer
template.setValueSerializer(redisSerializer);
template.setHashValueSerializer(redisSerializer);
// 设置键(key)的序列化采用StringRedisSerializer
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
字符串的 value 保存后是 "\"world\""
的形式,数字类型默认为 Integer,即使字符串进去出来也是 Integer,小数默认就是 Double。
6.1 简单案例
package com.xz.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SetOperations;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.web.bind.annotation.*;
import java.util.Set;
@RestController
@RequestMapping("/redisTest")
public class RedisTestController {
@Autowired
private RedisTemplate redisTemplate;
@PostMapping("/test")
public boolean test2 (String uid, String prodid) {
ValueOperations opsString = redisTemplate.opsForValue();
SetOperations opsSet = redisTemplate.opsForSet();
// 判空
if (uid == null || prodid == null) {
return false;
}
// 拼接 key, 库存 key , 秒杀成功用户 key
String kcKey = "sk:" + prodid + ":qt";
String userKey = "sk:" + prodid + ":user";
// 获取库存, 若库存为 null, 秒杀未开始
Object kc = opsString.get(kcKey);
if (kc == null) {
System.out.println("秒杀活动未开始!");
return false;
}
// 判断是否重复秒杀操作
if (Boolean.TRUE.equals(opsSet.isMember(userKey, uid))) {
System.out.println("已经参与秒杀, 不能重复参与!");
return false;
}
// 判断如果商品数量 < 1, 秒杀结束
if ((Integer)kc < 1) {
System.out.println("秒杀已经结束!");
return false;
}
// 秒杀过程 库存 -1, 秒杀成功的用户添加进清单
opsString.decrement(kcKey);
opsSet.add(userKey, uid);
System.out.println("用户 " + uid + " 参与 " + prodid + " 的秒杀活动参与成功!");
return true;
}
6.3 JMeter 测试
Redis 设置 0101 号商品库存为 10:
SET sk:0101:qt 10
500 个线程,每个线程抢两次。
10 件商品卖出去了 200 件。
严重超卖!
6.4 超卖问题
下面这代码并没有完全解决超卖,还不知道啥原因,记录一下,往后学一学,能解决了再来改!我设置一千个线程都超了!
找到原因了找到原因了,监视库存应该放在所有对 Redis 访问或者修改操作之前!
错误代码,监视库存只放在 Redis 写操作之前:
@PostMapping("/test")
public boolean test2 (String uid, String prodid) {
// 判空
if (uid == null || prodid == null) {
return false;
}
// 拼接 key, 库存 key , 秒杀成功用户 key
String kcKey = "sk:" + prodid + ":qt";
String userKey = "sk:" + prodid + ":user";
// 获取库存, 若库存为 null, 秒杀未开始
Object kc = redisTemplate.opsForValue().get(kcKey);
if (kc == null) {
System.out.println("秒杀活动未开始!");
return false;
}
// 判断是否重复秒杀操作
if (Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(userKey, uid))) {
System.out.println("已经参与秒杀, 不能重复参与!");
return false;
}
// 判断如果商品数量 < 1, 秒杀结束
if ((Integer)kc < 1) {
System.out.println("秒杀已经结束!");
return false;
}
/*
* 事务
* */
SessionCallback sessionCallback = new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
// 监视库存
operations.watch(kcKey);
operations.multi();
// 秒杀过程 库存 -1, 秒杀成功的用户添加进清单
redisTemplate.opsForValue().decrement(kcKey);
redisTemplate.opsForSet().add(userKey, uid);
return operations.exec();
}
};
List result = (List) redisTemplate.execute(sessionCallback);
if (result == null || result.size() == 0) {
System.out.println("秒杀失败了......");
return false;
}
System.out.println("用户 " + uid + " 参与 " + prodid + " 的秒杀活动参与成功!");
return true;
}
正确代码,监视库存应该放在所有对 Redis 访问或者修改操作之前:
@PostMapping("/test")
public boolean test2 (String uid, String prodid) {
// 判空
if (uid == null || prodid == null) {
return false;
}
// 拼接 key, 库存 key , 秒杀成功用户 key
String kcKey = "sk:" + prodid + ":qt";
String userKey = "sk:" + prodid + ":user";
/*
* 事务
* */
SessionCallback sessionCallback = new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
// 监视库存
List<String> list = new ArrayList<>();
list.add(kcKey);
operations.watch(list);
Object kc = redisTemplate.opsForValue().get(kcKey);
// 获取库存, 若库存为 null, 秒杀未开始
if (kc == null) {
System.out.println("秒杀活动未开始!");
return null;
}
// 判断是否重复秒杀操作
if (Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(userKey, uid))) {
System.out.println("已经参与秒杀, 不能重复参与!");
return null;
}
// 判断如果商品数量 < 1, 秒杀结束, (再次获取)
if ((Integer) kc < 1) {
System.out.println("秒杀已经结束!");
return null;
}
operations.multi();
// 秒杀过程 库存 -1, 秒杀成功的用户添加进清单
redisTemplate.opsForValue().decrement(kcKey);
redisTemplate.opsForSet().add(userKey, uid);
return operations.exec();
}
};
List result = (List) redisTemplate.execute(sessionCallback);
if (result == null || result.size() == 0) {
System.out.println("秒杀失败了......");
return false;
}
System.out.println("用户 " + uid + " 参与 " + prodid + " 的秒杀活动参与成功!");
return true;
}
再次测试:
完美!
6.5 连接超时问题
Q:Redis 不是单线程吗?为什么还需要连接池?
A: https://blog.csdn.net/forBurnInG/article/details/103893680
Q:连接池最大连接数越大越好?
A:https://zhuanlan.zhihu.com/p/396034724
因为我用的 SpringDataRedis,RedisTemplate 默认用的就是连接池,所以不会遇到连接超时问题。
拿 Mysql 举个例子:
不用连接池:
使用连接池:
不适用连接池的步骤:
- 建立 TCP 连接
- 建立数据库连接
- 执行语句
- 断开数据库连接
- 断开 TCP 连接
连接池的作用:
- 资源重用,节省每次连接服务带来的消耗
- 更快的响应速度,直接利用了现有可用连接,避免了数据库连接初始化和释放过程的时间开销,从而缩减了系统整体响应时间。
6.6 (使用乐观锁)库存遗留问题
设置商品大一点,然后测试:
出现了库存遗留问题。
Lua 脚本解决该问题,我再学学,看看有没有其他方法,有的话我再来补充!
https://www.jianshu.com/p/a555facfd6c8