【黑马点评】优惠券秒杀(单体模式)

优惠券秒杀业务流程

一人一单:要保证一个用户最多只能下一单

在这里插入图片描述

  1. 用户提交优惠券id
  2. 根据优惠券id查询优惠券信息
  3. 判断秒杀是否开始或结束,如果秒杀尚未开始或已经结束就直接返回错误信息
  4. 判断库存是否充足,如果库存数量小于1就直接返回错误信息
  5. 根据用户id和订单id查询订单,判断该用户是否下过单,如果下过单就直接返回错误信息
  6. 扣减库存
  7. 创建购买优惠券的订单

线程安全问题

存在两个典型的多线程安全问题:

  1. 先判断库存是否充足,再扣减库存 => 库存超卖问题
  2. 先判断用户是否下过单,再创建订单 => 一人一单线程安全问题

解决线程安全问题的方法:加锁

通常由两种解决方案:乐观锁&悲观锁
在这里插入图片描述在这里插入图片描述

其中,乐观锁需要在更新数据时判断数据是否被其他线程修改,可以通过比较现在查到的数据与之前查到的数据的数据值或版本号是否一致来实现。

乐观锁适合更新数据的场景(本来就有数据),如果是插入数据的场景(本来没有数据)就只能使用悲观锁来解决。

库存超卖

对于库存超卖问题,属于更新数据的场景,本项目中使用乐观锁解决,更新数据时判断数据值是否发生改变。

具体的做法:在扣减库存的mybatis-plus链式查询语句中追加eq("stock", voucher.getStock())条件,其中voucher是前面的步骤中查到的优惠券对象,调用voucher.getStock()方法获得之前查到的库存数据,而stock字段的值就是现在的数据。
在这里插入图片描述对应的sql语句:

update tb_seckill_voucher 
set stock = stock - 1
where voucher_id = {voucherId}
  and stock = {voucher.getStock()} 

用Apache的JMeter做压力测试:库存数量100,线程数200。
理论上来说,异常率(失败的请求占总请求数量的百分比)应该在50%左右,但实际的结果是异常率高达90%。
在这里插入图片描述
经过分析发现异常率高的原因在于:由于我们设置的条件是只有之前的数据和当前数据完全一致时才能成功扣减库存,因此如果有多个线程在查询库存数量时查询到同一个数量值,那么这些线程中只有一个能执行成功。其他线程都会判断得出数据被修改,然后直接结束流程返回错误信息。但是实际上此时只要还有库存,这些线程也可以执行扣减库存的操作。

于是做出一下改进:将stock = {voucher.getStock()} 的条件改为stock > 0
在这里插入图片描述此时再进行压测,发现异常率刚好是50%
在这里插入图片描述

一人一单线程安全问题

初始方案

而对于一人一单问题,属于插入数据的场景,只能使用悲观锁解决。

最初始的方案是将 “5.判断是否下过单;6. 扣减库存;7. 创建订单” 这三个步骤提取出来,创建一个新的方法createVoucherOrder,在原本的seckillVoucher方法中调用。
注意:

  1. 需要将这个方法设置为sychronized方法。
  2. 为了保证6、7两个操作的原子性,需要为该方法加上@Transactional注解,此时原本的seckillVoucher方法就不需要加@Transactional注解了。

在这里插入图片描述

测试后发现,确实可以解决一人一单问题,但由于锁的粒度太大,执行效率较低

sychronized方法是对方法所在类的Class对象加锁,在这里就是对VoucherOrderServiceImpl对象加锁,而这个对象全局就只有一个。原本我们只是想控制同一用户的多个并发请求之间的线程安全问题,现在所有用户执行该方法创建订单时都得改为串行执行,因此执行效率较低。

改进1

改进方法就是:不使用同步方法,而是使用同步代码块(同步代码块将囊括该方法中的包括最后一个return语句在内的全部代码),并且将加锁对象设置为userId.toString().intern(),即在字符串常量池中创建一个内容为用户id的字符串对象,该对象对于每一个用户是唯一的。

在这里插入图片描述

改进2

做了这一改进之后,仍存在一些问题。

由于我们加了@Transactional注解,所以createVoucherOrder方法受Spring事务的控制,由Spring负责在整个方法执行完之后提交事务。而Spring的事务是基于AOP实现的,AOP是基于代理实现的,代理对象会先调用目标对象的createVoucherOrder方法,执行完以后再执行提交事务的代码。

当同步代码块结束时,即createVoucherOrder方法运行完最后一行时,锁就释放了,此时事务尚未提交,数据尚未同步到数据库。如果在这时其他线程插入进来拿到锁进入同步代码块执行,查到的就是脏数据,会造成线程安全问题。

解决方法就是:

  1. 对整个createVoucherOrder方法加锁,即在原本的seckillVoucherOrder方法中,对调用createVoucherOrder的语句加锁。
  2. 并且要通过代理对象调用createVoucherOrder方法,这样事务才能生效。
    • 要获得事务代理对象需要用到AspectJ,并且要在启动类中添加@EnableAspectJAutoProxy(exposeProxy = true)注解。

在这里插入图片描述

总结

  1. 基于乐观锁解决库存超卖问题
    • 在更新数据时判断当前查到的库存值是否充足,如果库存不足就直接返回错误信息。
    • 判断条件从“当前库存值与之前查到的库存值是否一致”弱化为“当前查到的库存值是否充足”,依然可以解决库存超卖问题,并且可以降低异常率。
  2. 基于悲观锁解决一人一单线程安全问题
    • 将从“判断是否下过单”开始的代码提取出来,作为子方法
    • 该子方法中包括了扣减库存和创建订单两个操作,为保证原子性,需要为子方法加上@Transactional注解
    • 为了解决一人一单线程安全问题,需要对原方法中调用子方法的语句加上sychronized锁(悲观锁),加锁对象为userId.toString().intern()
      • 之所以使用同步代码块的方式,而不是将子方法设为sychronized方法,是因为sychronized方法是对全局唯一的VoucherServiceOrderImpl对象加锁,锁的粒度太大,执行效率较低。
      • 加锁对象是在字符串常量池中创建的内容为用户id的字符串对象,该对象对于每一个用户是唯一的。这里的intern()必不可少,否则每一次调用userId.toString()都会创建一个新的字符串对象,这样每一个线程都使用不同的锁,相当于根本没加锁。
      • 为了保证提交完事务之后再释放锁,所以在原方法中调用子方法的语句上加锁,而不是在子方法内部加锁。并且为了保证事务生效,调用子方法时通过事务代理对象来调用,而不是this。这样加锁的内容实际上是包含了事务代理对象中关于事务处理的增强代码的。

上述方案在单体模式(只有一台Tomcat服务器)下没有问题,但是在使用Tomcat集群的分布式系统中无法正确解决一人一单线程安全问题。原因在于:上述方案中使用的锁userId.toString().intern()是放在字符串常量池中的,如果只有一台Tomcat服务器 => 只有一个JVM => 只有一个字符串常量池 => 每个用户只有一把锁,但如果有多台Tomcat服务器 ,就无法保证每个用户只有一把锁。

  • 7
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
黑马rabbitMQ优惠卷秒杀是指利用RabbitMQ消息队列来实现优惠卷的秒杀活动。RabbitMQ是一个开源的消息队列中间件,它可以实现高效的消息传递和异步通信。在秒杀活动中,由于瞬间会有大量用户同时请求抢购,传统的同步处理方式无法满足高并发的需求,而使用RabbitMQ可以将请求异步化,提高系统的并发处理能力。 具体实现过程如下: 1. 创建一个消息队列:首先需要创建一个RabbitMQ消息队列,用于存储用户的秒杀请求。 2. 生成优惠卷:在秒杀活动开始前,需要提前生成一定数量的优惠卷,并将其存储在数据库中。 3. 用户抢购请求:用户在秒杀活动开始时,发送抢购请求到消息队列中。 4. 消费者处理请求:创建多个消费者来监听消息队列中的请求,并进行处理。当有新的请求进入队列时,消费者会从队列中获取请求,并进行相应的处理逻辑。 5. 校验优惠卷:消费者在处理请求时,会先校验用户是否有资格参与秒杀活动,并检查优惠卷的库存情况。 6. 分发优惠卷:如果用户符合条件并且优惠卷有库存,消费者会将优惠卷分发给用户,并更新数据库中的库存信息。 7. 返回结果:消费者处理完请求后,将处理结果返回给用户,告知用户是否成功抢购。 通过使用RabbitMQ消息队列,可以有效地解决高并发场景下的请求处理问题,提高系统的性能和稳定性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值