文章目录
一.超卖问题
1.1 出现原因
在多个用户同时发起对同一个商品的下单请求时,先查询商品库存,再修改商品库存,会出现资源竞争问题,导致商品被超卖。
1.2 乐观锁、悲观锁
1.2.1 悲观锁
对于数据冲突,持一种悲观的态度,先以一种预防的姿态在修改数据之前加锁,然后再对数据进行读写
,在它释放锁之前其他任何人不能对其数据进行操作,直到锁被释放。一般关系型数据库的锁机制都是基于悲观锁的,如oracle中select ... for update
特点:完全保证数据的独占性和正确性,但是加锁释放锁的过程会造成消耗性能不高
1.2.2 乐观锁
对于数据冲突,持一种乐观态度,操作数据时不会对操作的数据进行加锁(这使得多个任务可并行操作数据),等到数据提交时再验证数据是否存在冲突
(CAS
即 check-and-set 一般通过版本号对比的方式实现);
特点:乐观锁是一种并发类型的锁,本身不对数据进行加锁通而是通过业务实现锁的功能,省掉了对数据加锁和解锁的过程,可一定程度上提高性能。但是在并发非常高的情况下,CAS导致大部分操作无功而返而浪费资源,因此在高并发的场景下,乐观锁的性能反而不如悲观锁。
1.3 redis乐观锁
MULTI 、 EXEC 、 DISCARD 和 WATCH
是 Redis 事务相关的命令;事务带有2个重要属性:
事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。
1.3.1 MULTI
MULTI 执行之后, 客户端可以继续向服务器发送任意多条命令, 这些命令不会立即被执行, 而是被放到一个队列中
1.3.2 EXEC
exec命令负责触发并执行事务中的所有命令:
如果客户端在使用 MULTI 开启了一个事务之后,却因为断线而没有成功执行 EXEC ,那么事务中的所有命令都不会被执行;
如果客户端成功在开启事务之后执行 EXEC ,那么事务中的所有命令都会被执行。
1.3.3 事务中的错误
使用事务时可能会遇上以下两种错误:
事务在执行 EXEC 之前,入队的命令可能会出错。比如,命令可能会产生语法错误(参数数量错误,参数名错误等),或者其他更严重的错误,比如内存不足(如果服务器使用 maxmemory 设置了最大内存限制的话)。从 Redis 2.6.5 开始,服务器会对命令入队失败的情况进行记录,并在客户端调用 EXEC 命令时,拒绝执行并自动放弃这个事务。
命令可能在 EXEC 调用之后失败。如,事务中的命令可能处理了错误类型的键,比如将列表命令用在了字符串键上面。在 EXEC 命令执行之后所产生的错误, 并没有对它们进行特别处理: 即使事务中有某个/某些命令在执行时产生了错误, 事务中的其他命令仍然会继续执行。
1.3.4 DISCARD
当执行 DISCARD 命令时, 事务会被放弃, 事务队列会被清空, 并且客户端会从事务状态中退出
1.3.5 WATCH
WATCH 命令为 Redis 事务提供 check-and-set (CAS)操作
。
被 WATCH 的键会被监视是否被改动过了。 如果有至少一个被监视的键在 EXEC 执行之前被修改了, 那么整个事务都会被取消。
客户端 1启动监听,并正在进行set操作:
客户端2同时修改键k11的值:
客户端1exec提交事务,执行失败
注:使用UNWATCH
命令可以手动取消对键的监视。
1.4 超卖问题解决
1.4.1 利用乐观锁淘汰用户,解决超卖问题:
@RestController
@RequestMapping("/redisTest")
public class RedisTestController {
@Autowired
private RedisTemplate redisTemplate;
@PostMapping("/secKill")
public boolean testRedis(String uid, String prodid) {
/*模拟不同用户*/
Random random = new Random();
int i = random.nextInt(10000);
uid = uid + i;
//1 uid和prodid非空判断
if (uid == null || prodid == null) {
return false;
}
String kcKey = "sk:" + prodid + ":qt";
String userKey = "sk:" + prodid + ":user";
/*redisTemplate 开启事务支持*/
redisTemplate.setEnableTransactionSupport(true);
/*监视库存*/
redisTemplate.watch(kcKey);
String kc = redisTemplate.opsForValue().get(kcKey).toString();
if (kc == null) {
System.out.println("秒杀还没有开始,请等待");
return false;
}
if (redisTemplate.opsForSet().isMember(userKey, uid)) {
System.out.println("已经秒杀成功了,不能重复秒杀");
return false;
}
if (Integer.parseInt(kc) <= 0) {
System.out.println("秒杀已经结束了");
return false;
}
redisTemplate.multi();
redisTemplate.opsForValue().decrement(kcKey, 1);
redisTemplate.opsForSet().add(userKey, uid);
List exec = redisTemplate.exec();
if (exec == null || exec.size() == 0) {
System.out.println("秒杀失败,晚了一步,重新秒杀");
return false;
}
return true;
}
}
使用ab压测工具,执行命令:
ab -n 20000 -c 2000 -k -p ./postfile -T application/x-www-form-urlencoded http://192.168.0.109:8080/redisTest/secKill
结果发现:
注:
RedisTemplate 的默认配置不支持事务
(a) 在代码中若不添加 redisTemplate.setEnableTransactionSupport(true);
则抛出
异常io.lettuce.core.RedisCommandExecutionException: ERR EXEC without MULTI
此时ab压测仍然发现超卖现象
(b) 更常见的写法仍是采用 RedisTemplate 的默认配置,通过使用 SessionCallback,该接口保证其内部所有操作都是在同一个Session中。
SessionCallback<Object> callback = new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
operations.multi();
添加逻辑
return operations.exec();
}
};
redisTemplate.execute(callback));
二.库存遗留
2.1 库存遗留
高并发情况下,CAS导致大部分请求失败。
2000个请求,500的库存,仍然有库存剩余
2.2 LUA脚本
Lua 是一个小巧的脚本语言,Lua脚本可以被C/C++ 代码调用,也可以调用C/C++的函数,
LUA
2.2.1 redis中的LUA脚本
将复杂的或者多步的redis操作,写为一个脚本,一次提交给redis执行,减少反复连接redis的次数。
Redis 使用单个 Lua 解释器去运行所有脚本,保证脚本会以原子性(atomic)的方式执行
: 当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。 这和使用 MULTI / EXEC 包围的事务很类似。 在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)。 另一方面,这也意味着,执行一个运行缓慢的脚本并不是一个好主意。当不得不使用一些跑得比较慢的脚本时,其他客户端会因为服务器正忙而无法执行命令。
2.2.2 通过lua脚本解决秒杀问题
redis 2.6版本以后,通过lua脚本解决争抢问题,实际上是redis 利用其单线程的特性,用任务队列的方式解决多任务并发问题。
2.2.3 redisTemplate执行LUA脚本
@RestController
@RequestMapping("/redisTest")
public class RedisTestController {
@Autowired
private RedisTemplate redisTemplate;
String secKillScript = "local userid=KEYS[1];\r\n" +
"local prodid=KEYS[2];\r\n" +
"local qtkey='sk:'..prodid..\":qt\";\r\n" +
"local usersKey='sk:'..prodid..\":usr\";\r\n" +
"local userExists=redis.call(\"sismember\",usersKey,userid);\r\n" +
"if tonumber(userExists)==1 then \r\n" +
" return 2;\r\n" +
"end\r\n" +
"local num= redis.call(\"get\" ,qtkey);\r\n" +
"if tonumber(num)<=0 then \r\n" +
" return 0;\r\n" +
"else \r\n" +
" redis.call(\"decr\",qtkey);\r\n" +
" redis.call(\"sadd\",usersKey,userid);\r\n" +
"end\r\n" +
"return 1";
@PostMapping("/secKill")
public boolean testRedis(String uid, String prodid) {
/*模拟不同用户*/
Random random = new Random();
int i = random.nextInt(10000);
uid = uid + i;
//1 uid和prodid非空判断
if (uid == null || prodid == null) {
return false;
}
//调用lua脚本并执行
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setResultType(Long.class);//返回类型是Long
redisScript.setScriptText(secKillScript);
Object execute = redisTemplate.execute(redisScript, Arrays.asList(uid, prodid), "mypar");
String reString = String.valueOf(execute);
if ("0".equals(reString)) {
System.err.println("已抢空!!");
return false;
} else if ("1".equals(reString)) {
System.out.println("抢购成功!!!!");
return true;
} else if ("2".equals(reString)) {
System.err.println("该用户已抢过!!");
return false;
} else {
System.err.println("抢购异常!!");
return false;
}
}
}