秒杀
超卖问题
如下,我们先来复现问题,抢购秒杀券的代码逻辑也是很简单,
先判断优惠券是否开始了,是的化,判断库存是否充足,如果是的化,扣减库存,最后创建订单
如下是代码
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券
SeckillVoucher voucher = seckillVoucher.getById(voucherId);
if(voucher == null) {
return Result.fail("优惠券不存在");
}
//2.判断秒杀是否开始
if(voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀活动还没开始!");
}
//3.判断秒杀是否结束
if(voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀活动已经结束了!");
}
//4.判断库存是否充足
if(voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
//5.扣减库存
boolean isSuccess = seckillVoucher.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
//6.判断是否成功
if(!isSuccess) {
return Result.fail("扣减库存失败!");
}
//7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//7.1订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//7.2用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
//7.3代金券id
voucherOrder.setVoucherId(voucherId);
//7.4保存到voucher_order表中
save(voucherOrder);
//8.返回订单id
return Result.ok(orderId);
}
问题出现如下代码
我们判断是否充足的时候,有可能很多线程进来刚好都通过了,就会有问题
测试
设置jmeter
这里是加上token头,因为我的系统写了token头,才能通过,你要是没有的化,就不用
测试结果
如此就是超卖了
乐观锁 & 悲观锁
理解乐观锁 & 悲观锁
乐观锁有可能你还不是很懂,但是你一定要知道这个,乐观锁,实际上没有加锁,主打的就是一个乐观,如果发现没有问题,就不上锁,如果有问题,就通过特殊的手段保证线程的安全,这里的特殊的手段一般来说,就是类似于cas这样的,使用一个标识来判断是否有线程安全问题
悲观锁就很好理解了,就是平常我们加的粒度很大的锁,例如synchronized
乐观锁的思想
乐观锁的实操都是一个统一的思想,就是cas,比较 + 交换
比较的是什么,得到的旧值 和 我们再一次得到的值(理解为新值) 判断是否是一致的,如果不是一致的,那么就代表着有线程安全问题
我们再来理解一下这里的比较的意思,为什么要比较我们得到的值,举个例子
一开始我们拿到 stock = 100
然后过了几s,我们再去获取stock,发现stock = 98
是不是就说明这里的stock被人用过了,那么就有线程安全问题!此时我们就退出,或者人为再去加锁,都是可以的,一般来说,乐观锁不会直接加锁
我们再来想一个问题,为什么要有乐观锁???
我直接加锁不好吗?? 为的是两个字 性能!!!
我们一旦加了大粒度的锁,就会消耗性能,在那等吗,当然消耗了,所以就有了乐观锁的存在,它实际上是没有锁的,所以性能当然高!!!
乐观锁的缺点
那难道说,乐观锁,就那么好,没什么缺点? 肯定是有的, 会有完成率的问题
完成率不高,甚至于说,本来200 人抢100张优惠券的问题,但是由于设置的乐观锁, 再高并发下,很容易很多的线程都没有抢到,这种问题,在我这里也出现了, 解决办法就是改变比较条件就行,实例请看下面
乐观锁解决超卖
想我这里就是,简单的cas,判断是否是刚刚的库存
乐观锁完成率不高问题
我们这里更改了条件,只要库存 > 0的化,就可以成功!
这里为什么可以保证原子性,我觉得需要特别说明一下
我们请求打到数据库的时候那个时间点 有条件 stock > 0
因为有事务的原因,mysql这里的写操作是线程安全的,所以这里不会有问题
一人一单问题
一人一单问题,也是可能会有线程安全问题
我们先来看流程图
再超卖问题解决之下,去判断是否已经下过一单了,是的化,就不去下单
代码如下
@Override
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券
SeckillVoucher voucher = seckillVoucher.getById(voucherId);
if (voucher == null) {
return Result.fail("优惠券不存在");
}
//2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀活动还没开始!");
}
//3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀活动已经结束了!");
}
//4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//5.一人一单
int count = query().eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
if(count > 0) {
return Result.fail("你已经买过了");
}
//6.扣减库存
boolean isSuccess = seckillVoucher
.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock",0)
.update();
//7.判断是否成功
if (!isSuccess) {
return Result.fail("扣减库存失败!");
}
//8.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//8.1订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//8.2用户id
voucherOrder.setUserId(userId);
//8.3代金券id
voucherOrder.setVoucherId(voucherId);
//8.4保存到voucher_order表中
save(voucherOrder);
//9.返回订单id
return Result.ok(orderId);
}
问题处在这
如果高并发的情况下,就有可能会有问题
复现线程安全问题
jmeter设置: 和超卖问题的复现jmeter设置是一致的
原先订单数100
抢购17号优惠券,正常来说,一个用户只能抢1张
测试结果
下了10单
这里就是一人一单出了线程安全问题!
加锁解决
@Autowired
private SeckillVoucherServiceImpl seckillVoucher;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券
SeckillVoucher voucher = seckillVoucher.getById(voucherId);
if (voucher == null) {
return Result.fail("优惠券不存在");
}
//2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀活动还没开始!");
}
//3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀活动已经结束了!");
}
//4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
return createVoucherOrder(voucherId);
}
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//5.一人一单
int count = query()
.eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
System.out.println("此时count为" + count);
if (count > 0) {
return Result.fail("用户已经购买过一次");
}
//6.扣减库存
boolean isSuccess = seckillVoucher
.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
//7.判断是否成功
if (!isSuccess) {
return Result.fail("扣减库存失败!");
}
//8.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//8.1订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//8.2用户id
voucherOrder.setUserId(userId);
//8.3代金券id
voucherOrder.setVoucherId(voucherId);
//8.4保存到voucher_order表中
save(voucherOrder);
//9.返回订单id
return Result.ok(orderId);
}
再整个方法上加锁,这样确实是万无一失
测试
结果是正确的
优化加锁
如果直接再方法上加锁的化,那么锁的是类对象,也就是这里的service类对象,那么单用户情况下就没问题,但是在多用户情况下就会有问题,因为这里的锁是service类,那么相当于锁的是全部人,也就是说,别的用户还得等你抢完了才能枪,所以这里的锁的粒度有问题,应该锁的是对应的用户而不是所有用户!!!
@Transactional
public Result createVoucherOrder(Long voucherId) {
//只锁住相同用户,所以这里用userId
Long userId = UserHolder.getUser().getId();
//这里是更细粒度的锁,这里不能直接用Long userId来锁,因为有可能是同一个对象,jvm知识
//所以这里用字符串对象,但是Long的toString()里边也是new String(),所以这里要intern()
//避免相同的用户却有着不同的锁,再字符串池里边找到我们那个唯一的用户string
synchronized (userId.toString().intern()) {
//5.一人一单
int count = query()
.eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
System.out.println("此时count为" + count);
if (count > 0) {
return Result.fail("用户已经购买过一次");
}
//6.扣减库存
boolean isSuccess = seckillVoucher
.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
//7.判断是否成功
if (!isSuccess) {
return Result.fail("扣减库存失败!");
}
//8.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//8.1订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//8.2用户id
voucherOrder.setUserId(userId);
//8.3代金券id
voucherOrder.setVoucherId(voucherId);
//8.4保存到voucher_order表中
save(voucherOrder);
//9.返回订单id
return Result.ok(orderId);
}
}
这样子锁的才是用户,多人抢的化,就不会相互干涉
事务失效
提到这个我不得不说,这个问题比较难理解,这里的问题相关springboot中的事务
我们来看这里的代码
在这个方法上我们加上了事务 @Transactional 也就是springboot事务处理
,这个注解默认什么都不写的情况下,事务的隔离级别是数据库的隔离级别,而我这里的数据库是mysql,也就是读已提交
什么是读已提交,也就是说,只能读到已经提交的事务,那些没有提交的事务,别的事务是看不到的
这个隔离级别就是为了解决脏读 + 脏写的问题来着,但是反而在这里会出现问题
我门来看这里的流程
- 事务开始
- 上锁
- 业务代码
- 释放锁
- 事务结束
因为这里的锁是嵌套在这个方法里边的,并不是方法上的,所以说,我们释放锁的时候,事务不一定结束!! 换种方法说,就是事务没有提交!
这个问题很关键! 你事务没有提交,意思是别人根本读不到你这里的已经下了单的order,并且你还已经释放锁了,所以别的线程进来,就可以又来下单
所以总的来说,你看这个代码,这个问题的出现就是那么一瞬间的事,但是还是有可能会出现问题的
我们总结一下,为什么会出现这个问题,就是因为释放锁 和 事务的提交不同步,先释放锁了,才去提交事务,这样别人就有可乘之机,所以我们的解决方法就是先去提交事务,再去释放锁
那么我门的锁,就应该锁的是这整个方法了
代码
@Override
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券
SeckillVoucher voucher = seckillVoucher.getById(voucherId);
if (voucher == null) {
return Result.fail("优惠券不存在");
}
//2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀活动还没开始!");
}
//3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀活动已经结束了!");
}
//4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
return createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//5.一人一单
int count = query()
.eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
System.out.println("此时count为" + count);
if (count > 0) {
return Result.fail("用户已经购买过一次");
}
//6.扣减库存
boolean isSuccess = seckillVoucher
.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
//7.判断是否成功
if (!isSuccess) {
return Result.fail("扣减库存失败!");
}
//8.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//8.1订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//8.2用户id
voucherOrder.setUserId(userId);
//8.3代金券id
voucherOrder.setVoucherId(voucherId);
//8.4保存到voucher_order表中
save(voucherOrder);
//9.返回订单id
return Result.ok(orderId);
}
这里还有一个问题,就是这里没有调用事务
这里的 return createVoucherOrder(voucherId);
实际上的写法是这样
return this.createVoucherOrder(voucherId);
是用这个类的对象来调用的,但是由于spring底层是通过aop来实现事务管理的,我们要用代理对象才能发起一个事务,不然还是会有问题!!!
代码
@Override
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券
SeckillVoucher voucher = seckillVoucher.getById(voucherId);
if (voucher == null) {
return Result.fail("优惠券不存在");
}
//2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀活动还没开始!");
}
//3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀活动已经结束了!");
}
//4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
//获取代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//5.一人一单
int count = query()
.eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
System.out.println("此时count为" + count);
if (count > 0) {
return Result.fail("用户已经购买过一次");
}
//6.扣减库存
boolean isSuccess = seckillVoucher
.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
//7.判断是否成功
if (!isSuccess) {
return Result.fail("扣减库存失败!");
}
//8.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
//8.1订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//8.2用户id
voucherOrder.setUserId(userId);
//8.3代金券id
voucherOrder.setVoucherId(voucherId);
//8.4保存到voucher_order表中
save(voucherOrder);
//9.返回订单id
return Result.ok(orderId);
}
要设置这个还得加一个依赖
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
在主启动类上
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
一人一单(集群)
搭建集群
我这里搭建的集群是jvm的集群,在idea中的jvm集群
idea复用一个8082接口的应用程序
按alt + 8 可以跳出service
然后复制一份应用程序
更改端口
nginx配置
打开nginx conf文件下的nginx.conf
这里需要修改就是,下面的把注释打开,并且把上面的8081固定的关闭
这里的意思就是,请求8080,转到http://backend
然后nginx自动会轮询这两个server,一个是8081,一个是8082
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/json;
sendfile on;
keepalive_timeout 65;
server {
listen 8080;
server_name localhost;
# 指定前端项目所在的位置
location / {
root html/hmdp;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
location /api {
default_type application/json;
#internal;
keepalive_timeout 30s;
keepalive_requests 1000;
#支持keep-alive
proxy_http_version 1.1;
rewrite /api(/.*) $1 break;
proxy_pass_request_headers on;
#more_clear_input_headers Accept-Encoding;
proxy_next_upstream error timeout;
# proxy_pass http://127.0.0.1:8081;
proxy_pass http://backend;
}
}
upstream backend {
server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
}
}
更改完之后,要在cmd上重新启动一下
nginx.exe -s reload
问题出现
将两个应用程序以调试的模式打开
在这里打个断点
apifox的设置
第一个用户的配置
第二个用户是一样的
也就是说是同一用户,不过是不同的集群
测试
原先数据库的数据
首先是优惠券表
然后是优惠券订单表
是空的
2个接口都发起请求
发现两个应用程序都进去了
测试发现两个请求都进来了,这和我们的一人一单有问题,他这里会生成两个订单
如下
正常来说,我这里设了锁,应该是只能下一单,但是这里再集群情况下,下了两单,所以发生了线程安全问题!
发生问题的有原因
我门要先搞清楚集群的问题,如果是两个集群的化,那么代表的是两个jvm,相当于两个不同的进程,而我们之前那样子加锁,它的范围是jvm的内部,所以这里加锁无效,从这,就引申出分布式锁的概念
简单总结一下,就是没锁上,需要更大范围的锁!
分布式锁
分布式锁有三种实现
-
对于mysql来说,它的互斥锁的实现就是通过事务来实现的,我们再写的时候,会再写上加锁,但我认为这个还是很难的,如果要实现的哈
用redis来实现,比较好实现,就是用setnx,来实现互斥锁
zookeeper 我还不懂,掠过
我这里的获取锁 + 释放锁,已经写好了
代码如下
/**
* 尝试获取锁
* @param pattern key
* @param value 值
* @param <T>
* @return
*/
public <T> boolean tryLock(String pattern,T value)
{
Boolean flag = redisTemplate.opsForValue().setIfAbsent(pattern, value, 2, TimeUnit.MINUTES);
return BooleanUtil.isTrue(flag);
}
/**
* 解锁
* @param pattern
*/
public void unlock(String pattern) {
//删除锁
redisTemplate.delete(pattern);
}
给我封装到了我的redis 操作的工具类里边了
问题解决
我们先来看,解决问题的流程,做好一个心里预期
这个流程还算简洁的
我们直接看解决的代码
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private RedisCache redisCache;
/**
* 抢购秒杀券
*
* @param voucherId
* @return
*/
@Override
// @Transactional
public Long seckillVoucher(Long voucherId) {
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
log.info("当前库存为 : {}", voucher.getStock());
if (Objects.isNull(voucher)) {
throw new BaseException("优惠券不存在!");
}
LocalDateTime nowTime = LocalDateTime.now();
//优惠券时间是否开始了
if (voucher.getBeginTime().isAfter(nowTime)) {
throw new BaseException("优惠券时间还没开始!");
}
//是否结束了
if (voucher.getEndTime().isBefore(nowTime)) {
throw new BaseException("优惠券时间已经结束了");
}
//判断库存是否充足
if (voucher.getStock() < 1) {
throw new BaseException("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//锁的value是当前线程id
long threadId = Thread.currentThread().getId();
boolean isSuccess = redisCache.tryLock(RedisConstants.LOCK_SECKILL_VOUCHER_KEY, threadId + "", RedisConstants.LOCK_SECKILL_VOUCHER_TTL, TimeUnit.SECONDS);
if (!isSuccess) {
throw new BaseException("用户已经买过了!");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
String lockId = redisCache.getObject(RedisConstants.LOCK_SECKILL_VOUCHER_KEY);
//判断是否是一样的锁
if (StrUtil.isNotBlank(lockId) && lockId.equals(Thread.currentThread().getId() + "")) {
redisCache.unlock(RedisConstants.LOCK_SECKILL_VOUCHER_KEY);
}
}
}
@Transactional
public synchronized Long createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//一人一单问题
LambdaQueryWrapper<VoucherOrder> orderWrapper = new LambdaQueryWrapper<>();
orderWrapper.eq(VoucherOrder::getUserId, UserHolder.getUser().getId())
.eq(VoucherOrder::getVoucherId, voucherId);
int count = count(orderWrapper);
if (count > 0) {
throw new BaseException("你已经买过了!");
}
//扣减库存
boolean isSuccess = seckillVoucherService
.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!isSuccess) {
throw new BaseException("扣减库存失败!");
}
long orderId = redisIdWorker.nextId("order");
VoucherOrder voucherOrder = VoucherOrder.builder()
.id(orderId)
.userId(userId)
.voucherId(voucherId)
.build();
save(voucherOrder);
return orderId;
}
}
改动的代码如下
代码很简洁,就是加一个锁,只不过这个锁是再redis中,如果获取失败,那就说明,有人再抢,有人再抢的化,就直接爆错退出,这样才符合一人一单
测试
这里的测试我就不写了,因为没什么意思,最后结果就是一人一单
小问题
这里的小问题,就是如果按照我门上面这种写法,锁住是所有的用户,而我们加锁是对各个用户加锁,做到一人一单,用户之间应该是隔离的才对,所以这里应该再锁上加上用户的标记,这样别的用户就可以进来了,去枪单
这里实现还是很简单的,就是写rediskey的时候加上 userId
分布式锁误删问题
因为我们加了redis分布式锁,并且这里的分布式锁,是有过期时间的,所以就会延申出这个问题
为什么要设置过期时间
我们首先先声明一点,为什么必须要加过期时间,咱们这个问题的出现就是由于这个过期时间的问题,为什么我们不能做成永久key呢?
这个问题的答案,就是如果我们做成永久key,一个线程拿到了锁,然后突然发生异常了,或者业务阻塞了, 那么就相当于说,锁释放不了了,那么程序的性能就会大大降低,甚至于我们有可能得人为去干预这个问题
虽然说,我们这个例子,理论上来说,是可以用永久key的,但是大部分的业务是不行的,所以这里要设置过期时间
问题的出现
这个问题得想一想才行
这个情况是比较极端的,但是不代表没有可能会出现
极端情况下,我们线程1占有了锁,然后突然业务阻塞了,但是业务阻塞的时间比锁的过期时间还要长,这就会导致业务还没结束,锁已经被释放了!
那么在高并发的情况下,另外一个线程2,乘虚而入,拿到了锁,并且开始执行业务
而在这个时候,在线程2拿到了锁,线程1突然醒了过来,执行了业务代码,然后去执行释放锁的代码
那么这里就会出问题了,线程1不知道这个锁已经被换了主人了,他直接就把锁释放掉了
那么会导致什么结果呢???
在高并发的情况下,线程3一看没有锁了,就乘虚而入
线程2还没执行完
线程3就拿到了锁
这样下去,当线程2完成了业务,他就释放了锁,那么此时的线程3本来持有锁的,锁被人删了,后面线程4就乘虚而入
这样就像线程1删了线程2的锁
线程2删了线程3的锁
线程3删了线程4的锁
这样子迭代下去,不出问题才怪
这个问题属于是线程安全问题
解决办法
解决办法的思想也很简单,既然你删错锁了,是因为你不知道此时的锁的主人是谁,你以为是自己的,那么我门只要在锁上写上一个标记,代表着此时的锁是谁的.我们去释放锁的时候,就去判断是不是自己的锁,这样就没什么问题了
代码
按道理来说这里不应该用线程id来当作标识,因为还是有可能会重复,所以应该用uuid来当标识才对
修改如下
这样就不会有可能是有重复的问题了
原子性问题的出现
我们看上面的解决办法,好像已经很不错了,但是还是有一个漏洞,那就是这里的流程 判断锁是不是自己 和 释放锁不是一个原子操作
我们来看这个图,就能看明白,这里是一个很极端的情况
首先线程1拿到锁,然后执行完业务,想要释放锁,按照我们的解决方法,我们获取锁,是不是自己,发现是, 就在这个瞬间,突然线程1发生了阻塞
这里的阻塞,有可能是jvm的垃圾回收所导致,或者其他
当我们阻塞的时间超过了锁的过期时间,就会超时释放锁
那么线程2也会乘虚而入,拿到锁
当线程1醒过来的时候,因为前面已经判断过了,所以就会去删线程2的锁
还是会出现误删问题!!!
当然了,出现这个问题的条件是很苛刻的,就是线程1在判断完锁是自己的时候,突然发生阻塞,并且阻塞的时间超过redis锁的过期时间
解决办法
所以我们要想解决这个棘手的问题,我们就要让判断锁是不是自己 和 释放锁变成一个原子操作
这里就引出了redis 的lua脚本,它可以做到原子性!
LUA脚本
简单的介绍lua脚本
它是一个脚本语言,有点类似于js
在redis中,执行lua脚本
这里的key 和 value可以不用写死,可以作为参数传递
特别要注意这里的KEYS,和ARGV数组,需要注意的是,这里的数组是从1开始的
解决上面的问题
先写一个lua脚本
脚本的意思就是判断锁 + 释放锁
原先java代码的改变
/**
* 释放锁
*/
private void unlock(String lockKey,String uuid) {
DefaultRedisScript<Long> longDefaultRedisScript = new DefaultRedisScript<>();
longDefaultRedisScript.setLocation(new ClassPathResource("unlock.lua"));
longDefaultRedisScript.setResultType(Long.class);
redisCache.execute(
longDefaultRedisScript,
Arrays.asList(lockKey),
uuid
);
}
这里不再我的工具类里边写unlock了,这里的unlock比较特殊所以要自己写一个方法在下边
这样字,这样的代码就十分健壮了
Redis秒杀优化
为了得到更好的性能,我们需要优化,也不得不需要优化
我们来看整体的这个下单的流程
这些一连的操作全部都打到了数据库,数据库压力太大,我们得寻求其他的做法,减轻数据库压力
解决办法
我们可以把校验订单 + 下假单写在主线程中
这里的校验的意思是 一人一单问题 + 超卖问题
这里的下假单的意思是,我们把关键的信息记录下来,真正下单的代码我们异步执行
所以就类似于剥离代码逻辑,流程如下
抢购一个订单,我们保存优惠券id _+ 用户id + 订单id 到阻塞队列里边
然后异步的从阻塞队列里边拿到任务,去执行真正下单的操作
解决思路
为了解决这个问题,我们得先想好,数据结构
这里的库存判断,很简单,在redis中判断
这里的一人一单的判断比较特殊,我们用redis中的set集合来判断,set集合众所周知,是不能重复的,所以这里用set很合适
整体的解决过程
我想说,我们写一个解决方案的时候,用流程图来写,是非常简单明了的,对业务的熟悉也是最好的,所以我觉得不管是什么功能只要把流程图写好,就可以很完善,不只是写代码,就是后来维护代码都很轻松
我们来捋一下这里的过程
首先是执行lua脚本,这里的lua脚本 就干两件事 校验库存 + 校验是否一人一单
然后是判断返回结果
如果是0的化,说明下单成功,将这个单加入到阻塞队列里边
如果是1 或者 2的化,说明校验不通过,下单失败
代码
这是总的代码,你可以直接看,但是后面我会一一做解释
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private RedisCache redisCache;
@Autowired
private StringRedisTemplate stringRedisTemplate;
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
@Autowired
private RedissonClient redissonClient;
//阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
//线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//获得当前类的代理对象
IVoucherOrderService proxy;
//这个注解的意思是,当前类初始化之后就执行这个
@PostConstruct
private void init() {
//开一个子线程
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
//子线程实际要干的事
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
//这个方法take,如果阻塞队列里边没有值的化,会阻塞,所有不用担心卡死
VoucherOrder voucherOrder = orderTasks.take();
handleVoucherOrder(voucherOrder);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
}
}
}
}
/**
* 创建订单
* @param voucherOrder
*/
private void handleVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
//这里实际上也不用加锁,完全是ok的,但是还是为了托底
RLock lock = redissonClient.getLock("order:" + userId);
boolean isSuccess = lock.tryLock();
if(!isSuccess) {
log.info("不允许重复下单");
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}
//初始化lua脚本
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
/**
* 抢购秒杀券
*
* @param voucherId
* @return
*/
@Override
public Long seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//这里的lua脚本已经做了判断秒杀库存 + 一人一单
Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
Collections.emptyList(), voucherId.toString(), userId.toString());
int r = result.intValue();
if(r != 0) {
throw new RuntimeException(r == 1 ? "库存不足" : "不能重复下单");
}
long orderId = redisIdWorker.nextId("order");
VoucherOrder voucherOrder = VoucherOrder.builder()
.id(orderId)
.voucherId(voucherId)
.userId(userId)
.build();
//加入到阻塞队列里边
orderTasks.add(voucherOrder);
//获取代理对象,这里要再这里获得就是因为,我们实际去创建订单的子线程获取不到,只要在这个主线程才能获取到
//因为这里的currentProxy也是用ThreadLocal获取的,在子线程中获取是获取不到的
proxy = (IVoucherOrderService) AopContext.currentProxy();
return orderId;
}
/**
* 创建实际的订单,这里的订单消息是从异步队列里边取出来的
* @param voucherOrder
*/
@Transactional
public synchronized void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
//一人一单问题,这里按道理来说,是不不会有一人一单问题的,但是还是要做个拖底
LambdaQueryWrapper<VoucherOrder> orderWrapper = new LambdaQueryWrapper<>();
orderWrapper.eq(VoucherOrder::getUserId, userId)
.eq(VoucherOrder::getVoucherId, voucherId);
int count = count(orderWrapper);
if (count > 0) {
throw new BaseException("你已经买过了!");
}
//扣减库存
boolean isSuccess = seckillVoucherService
.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!isSuccess) {
throw new BaseException("扣减库存失败!");
}
save(voucherOrder);
}
}
lua
我们编写先从校验lua脚本开始
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
return 0
这里写的也很明白了,第一get stockkey 判断是否>0如果不是的化,返回1
然后判断下过单的set里边,是否有当前的用户,如果是的胡啊,返回2
两个情况都不满足的化,说明满足下单资格,扣库存 + 加用户id到已经下单的set里边
返回0
主代码
/**
* 抢购秒杀券
*
* @param voucherId
* @return
*/
@Override
public Long seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//这里的lua脚本已经做了判断秒杀库存 + 一人一单
Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
Collections.emptyList(), voucherId.toString(), userId.toString());
int r = result.intValue();
if(r != 0) {
throw new RuntimeException(r == 1 ? "库存不足" : "不能重复下单");
}
long orderId = redisIdWorker.nextId("order");
VoucherOrder voucherOrder = VoucherOrder.builder()
.id(orderId)
.voucherId(voucherId)
.userId(userId)
.build();
//加入到阻塞队列里边
orderTasks.add(voucherOrder);
//获取代理对象,这里要再这里获得就是因为,我们实际去创建订单的子线程获取不到,只要在这个主线程才能获取到
//因为这里的currentProxy也是用ThreadLocal获取的,在子线程中获取是获取不到的
proxy = (IVoucherOrderService) AopContext.currentProxy();
return orderId;
}
这里的流程也是,先是去调用lua,然后是判断返回值
如果校验通过的化,就开始创建;一个假订单,然后将下单的任务交给orderTasks,这个orderTasks,我们自己创建的阻塞队列
这里比较特殊的是proxy代理对象这个proxy代理对象在后边会用到,我们先抛开一遍,后面讲到之后再来说
//阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
//线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//获得当前类的代理对象
IVoucherOrderService proxy;
//这个注解的意思是,当前类初始化之后就执行这个
@PostConstruct
private void init() {
//开一个子线程
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
//子线程实际要干的事
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
//这个方法take,如果阻塞队列里边没有值的化,会阻塞,所有不用担心卡死
VoucherOrder voucherOrder = orderTasks.take();
handleVoucherOrder(voucherOrder);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
}
}
}
}
/**
* 创建订单
* @param voucherOrder
*/
private void handleVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
//这里实际上也不用加锁,完全是ok的,但是还是为了托底
RLock lock = redissonClient.getLock("order:" + userId);
boolean isSuccess = lock.tryLock();
if(!isSuccess) {
log.info("不允许重复下单");
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}
我们从上往下看,第一是我们要创建一个阻塞队列orderTasks
然后创建一个线程池,其实这里化,一个线程就差不多了
比较特殊是这里的init方法
我们得再每个这样的类初始化的时候,都要去开一个线程,执行线程中的操作
线程中的操作大体是如下
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
//这个方法take,如果阻塞队列里边没有值的化,会阻塞,所有不用担心卡死
VoucherOrder voucherOrder = orderTasks.take();
handleVoucherOrder(voucherOrder);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
}
}
}
}
比较特殊是这里直接用while true,你可能会疑惑这里会不会一直消耗资源,答案是不会的,这的orderTasks.take()如果阻塞队列中,没有任务的化,就会阻塞,根本不用担心这卡死的问题
然后就是创建订单了
/**
* 创建订单
* @param voucherOrder
*/
private void handleVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
//这里实际上也不用加锁,完全是ok的,但是还是为了托底
RLock lock = redissonClient.getLock("order:" + userId);
boolean isSuccess = lock.tryLock();
if(!isSuccess) {
log.info("不允许重复下单");
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}
/**
* 创建实际的订单,这里的订单消息是从异步队列里边取出来的
* @param voucherOrder
*/
@Transactional
public synchronized void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
//一人一单问题,这里按道理来说,是不不会有一人一单问题的,但是还是要做个拖底
LambdaQueryWrapper<VoucherOrder> orderWrapper = new LambdaQueryWrapper<>();
orderWrapper.eq(VoucherOrder::getUserId, userId)
.eq(VoucherOrder::getVoucherId, voucherId);
int count = count(orderWrapper);
if (count > 0) {
throw new BaseException("你已经买过了!");
}
//扣减库存
boolean isSuccess = seckillVoucherService
.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!isSuccess) {
throw new BaseException("扣减库存失败!");
}
save(voucherOrder);
}
这里的很多操作属于是冗余的了,但是写了也是安全一点
比较特殊的是这里的proxy
虽然我前面说过这个问题,我在这里再说一下,为什么我们要用proxy来调用这里的底层的创建订单的方法呢?
答案是为了,使得方法上的事务有效,如果我们直接调用createVoucherOrder()的化,实际上的调用者是this,也就是当前类对象,这样子是调用不了底层的事务功能的
具体为什么,就是spring事务的实现的问题了,spring事务的实现是通过aop来实现的,换句话来说,是用当前类的代理对象来实现事务的,所以如果不用proxy的化,会出现事务失效的问题!!!
测试
数据库
订单库
空的
redis中
order删除掉
500个用户准备
这里要用到io操作什么的,最好你能懂io操作
@Test
public void bulkLogin() {
//查询所有用户
List<User> users = userService.list();
//获取resource目录下的文件路径(用于存放生成的token)
String path = new File("").getAbsolutePath() + "\\src\\main\\resources\\tokens.txt";
//String file = Objects.requireNonNull(BulkLogin.class.getClassLoader().getResource("tokens.txt")).getFile();
//创建字符写入流
FileWriter fw = null;
try {
fw = new FileWriter(path);
} catch (IOException e) {
throw new RuntimeException(e);
}
//创建字符写入缓冲区
BufferedWriter bw = new BufferedWriter(fw);
for(int i = 0; i < 500; i++) {
User user = users.get(i);
//8.保存用户信息到redis中
//8.1随机生成token作为登录令牌
String token = UUID.randomUUID().toString();
//8.2将user对象转为hashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
//System.out.println(map);
//导入redis
redisCache.setObject(RedisConstants.LOGIN_USER_KEY + token, userDTO);
//设置有效期,30分钟
// stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY, 30, TimeUnit.MINUTES);
//这一行代码是同时获取Map中的key和value,当然和这个批量登陆功能没关系
//Set<Map.Entry<String, Object>> set = map.entrySet();
try {
//开始写入
bw.write(token + "\n");
//强制刷新
bw.flush();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
System.out.println("运行成功!");
}
这里是用springbooottest来写入的,会在redis中,生成500个对象还会圣痕tokens.txt对象给jmeter测试用
jmeter设置
启动!!!
测试结果
订单表
100单
redis
库存也为0
买的用户也是100个
成功
总结
一路走过来,就这一个秒杀问题会纠结着很多问题
锁的问题 + 分布式不一致的问题,还有一些线程池,再优化当中加入了队列
不管怎么说,这里的秒杀,还是十分简单的,日后我写到真正更难的秒杀
我也会更新到这里来
我来总结一下这里的秒杀优化
就是为了提高性能,提高吞吐量
比较关键的就是把必要的校验写道主线程去,把可以异步的操作写道子线程去,这样就可以稍微减少点数据库的压力,也很合理
虽然这里用的是阻塞队列,但是再生产环境中,一般用的是mq,所以我们还得去好好学mq,队列,这个东西再分布式里边很常见,而且还是基本操作,所以我们得好好熟悉,这个东西
这一篇写下来,我也感到自己的薄弱点 就是设计 + 思维 ,这个东西还是得多写写,还有就是多线程相关的知识我并不是很了解,我需要再去温习,复习,多用才行,ok,就这样!
Redis实现消息队列
因为上面我们用的是,java中的阻塞队列,对于我们这种为了解决一人一单的问题,他的对象是单个用户,相当于单机,是没有问题的,但是如果说,换成分布式的化,就会有问题,不能是单机,这个时候就需要独立的消息队列了,你可以用mq,也可以用redis来实现一个消息队列
基于List
Redis的List数据结构是一个双向链表,很容易模拟出队列效果
队列是入口和出口不在一边,lpush + rpop rpush lpop 但是为了消费者有一个阻塞的效果
还是用 lpush + brpop 阻塞式的获取,当队列里面没有数量,就会阻塞
优缺点
- 利用Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全性有保证
- 可以满足消息有序性
缺点:
- 无法避免消息丢失
- 只支持单消费者,多消费不行]
基于PubSub
PubSub式Redis2.0版本引入的消息传递模型,消费者可以订阅一个或多个channel,生产者向对应的channel发送消息后,所有订阅者都能收到消息
- SUBSCRIBE channel
- PUBLISH channel msg: 向一个频道发送消息
- PSUBSCRIBE pattern: 订阅pattern格式匹配的所有频道
? 代表一个字符
* 代表0个或多个
[ae] 指定字符,这里只能式a或者e
优缺点
优点
采用发布订阅模型,支持多生产,多消费
缺点:
- 不支持数据持久化
- 无法避免消息丢失
- 消息堆积有上限,超出时数据丢失
基于Stream
Stream是redis5.0引入的新的数据类型,可以实现一个功能非常完善的消息队列
创建消息队列并添加消息
- key是队列名
- nomkstream 可选项,如果队列不存在,不创建队列,默认是会创建的
- maxlen|mined threshold limit count 设置消息队列的最大消息数量
- * | ID 消息的id标识,*的意思是自动帮我们创建,创建的格式是 时间戳-标识 如果要自己写,也要遵循这个格式
- field value 发送的消息,键值对形式
举例
创建队列
> xadd s1 * k1 v1
1707815190435-0
返回值就是id
查看队列长度
> xlen s1
1
读取
有了创建我们就要读取,一样是来看命令,看命令的时候,看比较重要的就好了,其他的化,用到再来查
- count 读取几个消息
- block 秒数 是否阻塞读取,不写的化,就不是阻塞读
- streams key 读取的是哪个消息队列
- ID 起始id,只返回大于该id的消息 0 就是第一个消息,$就是最新消息
我们需要比较关注的是,这个ID,我们举个例子,你就能懂了
举例
> xadd s1 * k1 v1
1707815705120-0
这样子,我们创建了一个新的队列,并且加入新的消息
再添加一个消息
> xadd s1 * k2 v2
1707815776325-0
读取
我们从第一个消息开始读
> xread streams s1 0
s1
1707815705120-0
k1
v1
1707815776325-0
k2
v2
先读的就是最早的消息,到现在的消息
$的bug
对于说,$这个,读取最新的消息,这里有一个比较奇特的现象
如果我们先xadd 添加了一条消息,
然后再去读取最新的消息,是没有用的,这个是一个bug
如下,我演示一下
> xadd s1 * k3 v3
1707816034050-0
> xread streams s1 $
null
我们必须用阻塞的方式去读取最新的消息,但是一旦阻塞的时候读取到了消息,阻塞就会停止,如果此时还有消息来,就会造成消息漏读,和上面的这个bug是一个意思
我们先再一个控制台,阻塞读取消息
> xread count 1 block 0 streams s1 $
block 0指的是,永久阻塞,$表示读取的是最新消息
我们再另一个控制台,发消息
> xadd s1 * k4 v4
1707816215215-0
在原先的控制台
> xread count 1 block 0 streams s1 $
s1
1707816215215-0
k4
v4
就可以读取到,如果不继续阻塞读取,在此时再加消息
> xadd s1 * k5 v5
1707816280622-0
> xread count 1 block 0 streams s1 $
它就读不到,但是实际上是有这个消息的
所以总结来看的化,这样子太简单了,而且会有消息漏读的风险
,所以我们得再继续学习更完善的stream的消息队列-消费者组
基于Stream-消费者组
消费者组就是将多个消费者划分到同一个组中
特点
- 不存在重复消费,不同消息分给组内不同消费者
- 消息标识: 消费组会维护一个消息标识,记录最后一个处理的消息,如果服务宕机了,还是会从消息标识之后拿消息,安全
- 消息确认: 一个消息接手之后,需要xack来确认消息,标记为已处理,否则会进入pending-list的头部
创建消费者组
比较重要的还是这个ID,0的意思是第一个消息,也就是最早的哪个消息,$标识最新消息
其他的常用命令
这里的destroy打错了,destroy才是正确的
从消费者组中读取消息
比较特殊的是这的noack,意思是不用确认,最好不要加这个参数,安全一点
这里的ID,就和上面的不同了,这里是 > ,就从下一个未消费的消息开始,有点类似于我们刚开始最简单的那种,从最新消息开始,因为我们维护了消息标识,这样就可以从上次读的地方往下读,不会出现消息漏读的情况
如果是其他情况,就会从pending-list里边读了,这里的pending-list是消费了,但是没有确认的消息,例如如果是0,就从pending-list的第一个消息开始,对于这种情况,一般都是出现了错误的时候,才会来这里读取,比如说,消费之后,宕机了,就来这里找没确认的消息,保证了消息不会漏掉
举例
我们再创建消费者组的时候,必须先创建队列才行,因为这个消费者组像是队列的包装一样
> xadd s1 * k1 v1
1707818258832-0
> xadd s1 * k2 v2
1707818265446-0
> xadd s1 * k3 v3
1707818271439-0
创建队列,并且加入三条消息
> xgroup create s1 g1 0
OK
读取
> xreadgroup group g1 c1 streams s1 >
s1
1707818258832-0
k1
v1
1707818265446-0
k2
v2
1707818271439-0
k3
v3
再继续读,就读的是空了
> xreadgroup group g1 c1 streams s1 >
null
这里被消费者c1读完之后,如果我们换一个消费者读也是读不到的
> xreadgroup group g1 c2 streams s1 >
null
如果我们去读pengding-list的化
我们只要修改最后的>,变成0
> xreadgroup group g1 c1 streams s1 0
s1
1707818258832-0
k1
v1
1707818265446-0
k2
v2
1707818271439-0
k3
v3
我们可以看到,我们写的0的化,这里的读取顺序,和我们消费顺序是一致的,从最早的消费记录到最晚的消费记录
java代码实现消息监听
我们先来看伪代码
我们来捋一下流程,其实很简单
第一,我们先监听队列,使用的是阻塞模式,每次等2000ms,然后判断是否读取到消息,没有的化,继续去阻塞读取
如果有消息,就去处理消息,并且一定要ack
如果这个过程,有报错,那么我们就得去读取pending-list中的我们没有确认的消息,处理的逻辑和我们处理正常消息是一致的
基于Stream实现异步秒杀
创建一个Stream类型的消息队列
xgroup create stream.orders g1 0 mkstream
修改lua脚本
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3 订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
--3.6.发送消息到队列中, XADD stream.orders * k1 v1,这里变成id,是为了迎合实体类里边的id
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
return 0
VoucherOrderServiceImpl
package com.hmdp.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.Result;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.IVoucherOrderService;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.stream.*;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @author jjking
* @date 2023-11-04 21:32
*/
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private SeckillVoucherServiceImpl seckillVoucher;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
//线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//这个注解的意思就是当前类初始化完毕就执行
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
/**
* 在redis消息队列中取消息
*/
private class VoucherOrderHandler implements Runnable {
String queueName = "stream.orders";
@Override
public void run() {
//队列名字
while (true) {
//1.获取队列中的订单信息
//这个take方法是阻塞方法,所以不用担心while true死循环
try {
//1. 获取消息队列中的订单消息 xreadgroup group g1 c1 count 1 block 2000 streams streams.order >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(queueName, ReadOffset.lastConsumed())
);
//2.判断消息获取是否成功
if(list == null || list.isEmpty()) {
continue;
}
//解析消息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> values = record.getValue();
//2.1失败,没有消息,继续下一次循环
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
//2.2成功,创建订单+
handleVoucherOrder(voucherOrder);
//3. ACK确认 sack stream.orders g1 id
stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
} catch (Exception e) {
log.debug("处理订单异常");
handlePendingList();
e.printStackTrace();
} finally {
}
}
}
/**
* 处理消息失败,pendinglist 确认ack
*/
private void handlePendingList() {
while (true) {
//1.获取队列中的订单信息
//这个take方法是阻塞方法,所以不用担心while true死循环
try {
//1. 获取pending-list中的订单消息 xreadgroup group g1 c1 count 1 streams streams.order 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(queueName, ReadOffset.from("0"))
);
//2.判断消息获取是否成功
if(list == null || list.isEmpty()) {
//如果获取失败,说明pending-list没有异常信息,结束循环
break;
}
//解析消息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> values = record.getValue();
//2.1失败,没有消息,继续下一次循环
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
//2.2成功,创建订单+
handleVoucherOrder(voucherOrder);
//3. ACK确认 sack stream.orders g1 id
stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
} catch (Exception e) {
log.debug("处理pending-list异常");
//如果又出现异常,应该继续回去处理pending-list
continue;
} finally {
}
}
}
}
IVoucherOrderService proxy;
/**
* 创建订单
* @param voucherOrder
*/
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//获取用户
Long userId = voucherOrder.getUserId();
RLock lock = redissonClient.getLock("order:" + userId);
//获取锁们,这里的锁只是兜底方案
boolean isSuccess = lock.tryLock();
//判断是否后去锁成功
if(!isSuccess) {
log.debug("不允许重复下单");
//获取锁失败
return;
}
try {
//获取代理对象
proxy.createVoucherOrder(voucherOrder);
} finally {
//手动释放锁
lock.unlock();
}
}
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
//直接会在resource下面找
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
/**
* 3.0redis做缓存,并且redis做消息队列
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
//获取订单
long orderId = redisIdWorker.nextId("order");
//1.执行lua脚本,尝试判断用户是否有购买资格
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(),String.valueOf(orderId)
);
//2.判断结果
//2.1.结果不为0,没有购买资格
int r = result.intValue();
// System.out.println("此时的结果为" + r);
if(r != 0) {
return Result.fail(r == 1 ? "库存不足" : "不能重复下单!");
}
//获取代理对象
proxy = (IVoucherOrderService) AopContext.currentProxy();
//3.返回订单id
return Result.ok(orderId);
//获取用户
}
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
//5.一人一单
int count = query()
.eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
System.out.println("此时count为" + count);
if (count > 0) {
log.debug("createVoucherOrder 里边库存不足");
return;
}
//6.扣减库存
boolean isSuccess = seckillVoucher
.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
//7.判断是否成功
if (!isSuccess) {
log.debug("扣减库存失败");
return;
}
//8.4保存到voucher_order表中
save(voucherOrder);
}
}
测试
redis结果