【Redis】高并发场景下秒杀业务的实现思路(单机模式) ...

立山  论坛元老 | 2024-12-17 07:33:07 | 显示全部楼层 | 阅读模式
打印 上一主题 下一主题

主题 1863|帖子 1863|积分 5589

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

x
一、超卖问题

业务要求:
   实现一个对优惠券限时抢购的功能。
  

你可能会想,啥抢购啊,说白了不就是买东西吗,先判定一下是否在抢购时间,然后在买的时间判定一下是否还有库存不就行了。如果有,说明优惠券还没抢完,则让订单见效,并让库存减一;如果没有,说明已经抢完了,直接返回异常提示就好了。很容易就能得到下图中的流程:

这业务逻辑感觉似乎没啥弊端,接着就可以根据这个流程编写代码了:
  1.     @Transactional  //事务
  2.     public Result seckillVoucher(Long voucherId) {
  3.         //1.查询优惠券
  4.         SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
  5.         //2.判断秒杀是否开始
  6.         LocalDateTime beginTime = voucher.getBeginTime();
  7.         if(LocalDateTime.now().isBefore(beginTime)){
  8.             //秒杀还未开始,返回提示信息
  9.             return Result.fail("秒杀还为开始!");
  10.         }
  11.         //3.判断秒杀是否结束
  12.         LocalDateTime endTime = voucher.getEndTime();
  13.         if(LocalDateTime.now().isAfter(endTime)){
  14.             //秒杀已经结束,返回提示信息
  15.             return Result.fail("秒杀已经结束!");
  16.         }
  17.         //4.判断库存是否充足
  18.         Integer stock = voucher.getStock();
  19.         if(stock<=0){
  20.             //优惠券已经抢完,没有库存了
  21.             return Result.fail("优惠券已被抢完!");
  22.         }
  23.         //5.扣减库存
  24.         boolean success = seckillVoucherService.update()
  25.                 .setSql("stock = stock-1")
  26.                 .eq("voucher_id", voucherId).update();
  27.         if(!success){
  28.             //数据库更新失败
  29.             return Result.fail("优惠券已被抢完!");
  30.         }
  31.         //6.创建订单
  32.         VoucherOrder voucherOrder=new VoucherOrder();
  33.         //6.1.订单id
  34.         long orderId = redisIdWorker.nextId("order");
  35.         voucherOrder.setId(orderId);
  36.         //6.2.用户id
  37.         Long userId = UserHolder.getUser().getId();
  38.         voucherOrder.setUserId(userId);
  39.         //6.3.代金券id
  40.         voucherOrder.setVoucherId(voucherId);
  41.         save(voucherOrder);
  42.         //7.返回订单id
  43.         return Result.ok(orderId);
  44.     }
复制代码
似乎已经把业务要求成功实现了,是不是万事大吉了?然而,现实场景中,由于秒杀的限时限量的特性,往往会吸引大量用户加入,就会给体系带来并发压力。让我们用jmeter模拟一下高并发场景下的运行情况:

 

 设置200个线程发送秒杀请求,而数据库中存放了100张优惠券。正常情况下,末了应该有100个线程抢券成功,另一百个线程则返回异常信息,则异常率应该为50%。

然而我们可以看到,实际上测试的异常率却是45.50%,这时为什么呢?

再次检察数据库,我们发现数据库中的库存竟然是负数。也就是说实际上卖出了109单,多卖出了9单。这就是所谓的超卖问题。
为什么会这样呢?还是老生常谈的线程安全问题。由于查询库存与与扣减库存的操纵不是原子的,当线程1查完数据库发现库存为1,正准备去扣减库存但还没有完成扣减操纵时,线程2插了进来,举行了查库操纵,也读取到了还未被扣减的库存值1,于是也会去继续举行库存扣减操纵。这就导致了多个线程重复地去扣减库存,也就造成了超卖问题。

为了办理这一问题,很天然的想法就是给查询扣减两个操纵加上锁。这里就会引申两种不同的加锁策略 乐观锁 与 悲观锁
乐观锁与悲观锁


悲观锁:添加同步锁,让线程串行实行


  • 长处:简单粗暴
  • 缺点:性能一样平常
乐观锁:不加锁,在更新时判定是否有其他线程在修改


  • 长处:性能好
  • 缺点:存在成功率低的问题
衡量之下,其实该业务场景中锁冲突发生的概率也不是很高。如果选用悲观锁就会导致浪费很多不须要的性能。使用乐观锁其实就能满意我们的需求了。
而实现乐观锁的关键就在于在举行扣减库存操纵时,要判定之前查询得到的数据是否被修改过。常见的判定方式有以下两种:
1、版本号法:
额外维护一个版本号version字段。在每次实行成功扣减库存操纵时,就让版本号递增。这样,只需要在要扣减库存时判定当前当前版本号与查询库存时查到的版本号是否一致,若一致就认为期间数据未被修改,可以实行扣减库存操纵。否则则扣减失败,返回错误信息。

不外不外,由于需要数据库额外维护一个字段,操纵起来会比较贫困一些。
2、CAS法
其实仔细分析我们会发现,只有在库存为0的时间会发生线程安全问题。事实上当库存大于零时,纵然操纵不是原子的,末了从结果上来看也没啥问题,因为数据库的操纵时原子的。
   我们只需要在真正实行扣减库存操纵时,判定此时库存是否为零即可。若大于零,则成功扣减库存;否则就说明此时没有库存了,返回错误信息即可。
  

这种方法本质上就是把服务端的并发问题给抛给数据库了,利用数据库自身的抗高并发特性来办理问题。不外也是有一定弊端的,如果并发量过大的话,对数据库的压力就会非常大了。这就得自行做出取舍了。
详细代码实现:
  1. @Transactional  //事务
  2.     public Result seckillVoucher(Long voucherId) {
  3.         //1.查询优惠券
  4.         SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
  5.         //2.判断秒杀是否开始
  6.         LocalDateTime beginTime = voucher.getBeginTime();
  7.         if(LocalDateTime.now().isBefore(beginTime)){
  8.             //秒杀还未开始,返回提示信息
  9.             return Result.fail("秒杀还为开始!");
  10.         }
  11.         //3.判断秒杀是否结束
  12.         LocalDateTime endTime = voucher.getEndTime();
  13.         if(LocalDateTime.now().isAfter(endTime)){
  14.             //秒杀已经结束,返回提示信息
  15.             return Result.fail("秒杀已经结束!");
  16.         }
  17.         //4.判断库存是否充足
  18.         Integer stock = voucher.getStock();
  19.         if(stock<=0){
  20.             //优惠券已经抢完,没有库存了
  21.             return Result.fail("优惠券已被抢完!");
  22.         }
  23.         //5.扣减库存
  24.         boolean success = seckillVoucherService.update()
  25.                 .setSql("stock = stock-1")
  26.                 .eq("voucher_id", voucherId).gt("stock",0)//判断此时库存是否大于零
  27.                 .update();
  28.         if(!success){
  29.             //数据库更新失败
  30.             return Result.fail("优惠券已被抢完!");
  31.         }
  32.         //6.创建订单
  33.         VoucherOrder voucherOrder=new VoucherOrder();
  34.         //6.1.订单id
  35.         long orderId = redisIdWorker.nextId("order");
  36.         voucherOrder.setId(orderId);
  37.         //6.2.用户id
  38.         Long userId = UserHolder.getUser().getId();
  39.         voucherOrder.setUserId(userId);
  40.         //6.3.代金券id
  41.         voucherOrder.setVoucherId(voucherId);
  42.         save(voucherOrder);
  43.         //7.返回订单id
  44.         return Result.ok(orderId);
  45.     }
复制代码


可以看到现在就不会出现线程安全问题了。
二、一人一单

我们仔细观察一下上述代码实行后生产的优惠券信息,会发现所有的优惠券的使用者都是同一个用户:

然而实际场景中,商家发放优惠券其实就是想起到一个引流的作用,肯定渴望越多用户加入越好。如果所有优惠券都被一个人抢到了,宣传效果就会大打折扣了。如果做到一人一单,那加入人数肯定是最多的,宣传效果也会比较好。
因此,我们还需要再举行扣减库存操纵之前,先判定该用户之前是否抢过优惠券,如果之前没有产生过订单,才继续实行扣减库存操纵,并创建订单;否则直接返回异常信息即可:
那是不是说我们就在扣减库存前加入以下判定代码就好了:
  1.     //一人一单
  2.     Long userId=UserHolder.getUser().getId();
  3.     //查询订单
  4.     Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
  5.     //判断订单是否存在
  6.     if(count>0){
  7.         //用户已经购买过了
  8.         return Result.fail("用户已经购买过一次了!");
  9.     }
复制代码
让我们测试一下看看,此时让同一个用户同时发送200个请求,库存100个优惠券:

 

   如果是一人一单,那200个请求应该只有一个能成功,结果却少了十张券,这是为什么?
  因为这个查询订单的操纵与后面漏检数据库的操纵依然不是原子的,因此难免会发生线程安全问题。同样的,我们海狮得要对他们举行加锁。为了方便编写,我们将这两个操纵封装成一个方法,并为其添加事件:
  1.     @Transactional
  2.     public Result createVoucherOrder(Long voucherId){
  3.         //一人一单
  4.         Long userId=UserHolder.getUser().getId();
  5.         //查询订单
  6.         Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
  7.         //判断订单是否存在
  8.         if(count>0){
  9.             //用户已经购买过了
  10.             return Result.fail("用户已经购买过一次了!");
  11.         }
  12.         //5.扣减库存
  13.         boolean success = seckillVoucherService.update()
  14.                 .setSql("stock = stock-1")
  15.                 .eq("voucher_id", voucherId).gt("stock",0)//判断此时库存是否大于零
  16.                 .update();
  17.         if(!success){
  18.             //数据库更新失败
  19.             return Result.fail("优惠券已被抢完!");
  20.         }
  21.         //6.创建订单
  22.         VoucherOrder voucherOrder=new VoucherOrder();
  23.         //6.1.订单id
  24.         long orderId = redisIdWorker.nextId("order");
  25.         voucherOrder.setId(orderId);
  26.         //6.2.用户id
  27.         voucherOrder.setUserId(userId);
  28.         //6.3.代金券id
  29.         voucherOrder.setVoucherId(voucherId);
  30.         save(voucherOrder);
  31.         //7.返回订单id
  32.         return Result.ok(orderId);
  33.     }
复制代码
  注意:这里锁应该加在事件之外。如果锁加在事件内,可能会出现锁已开释且其它线程抢到了锁,而此时事件还未提交的情况,这时新增的订单还未写入数据库,就可能导致发生线程安全问题了
  因此我们需要再这个方法外部加锁,最简单的方式就是在调用该方法时加锁:

   需要注意的是,由于我们给createVoucherOrder方法添加了事件,则spring会通过动态署理获取当前类的署理对象,用署理对象来举行事件处置惩罚。而此时我们没有为seckillVoucher方法添加事件,如果直接在该方法中调用createVoucherOrder方法,相当于用非署理对象举行调用,而非署理对象是没有事件功能的,就会导致事件失效。
  我们可以通过AopContext.currentProxy()方法获取当前类的署理对象,用署理对象举行调用就能实现事件功能了。
使用该方法需要添加以下依靠:

在启动类上添加注解袒露署理对象:
  1. //暴露代理对象
  2. @EnableAspectJAutoProxy(exposeProxy = true)
  3. @MapperScan("com.hmdp.mapper")
  4. @SpringBootApplication
  5. public class HmDianPingApplication {
  6.     public static void main(String[] args) {
  7.         SpringApplication.run(HmDianPingApplication.class, args);
  8.     }
  9. }
复制代码
最终代码

  1.     public Result seckillVoucher(Long voucherId) {        //1.查询优惠券        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);        //2.判定秒杀是否开始        LocalDateTime beginTime = voucher.getBeginTime();        if(LocalDateTime.now().isBefore(beginTime)){            //秒杀还未开始,返回提示信息            return Result.fail("秒杀还为开始!");        }        //3.判定秒杀是否结束        LocalDateTime endTime = voucher.getEndTime();        if(LocalDateTime.now().isAfter(endTime)){            //秒杀已经结束,返回提示信息            return Result.fail("秒杀已经结束!");        }        //4.判定库存是否充足        Integer stock = voucher.getStock();        if(stock<=0){            //优惠券已经抢完,没有库存了            return Result.fail("优惠券已被抢完!");        }        Long userId = UserHolder.getUser().getId();        //              intern:返回字符串的规范表示        synchronized (userId.toString().intern()){            //获取当前对象的署理对象            IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();            return proxy.createVoucherOrder(voucherId);        }    }    @Transactional
  2.     public Result createVoucherOrder(Long voucherId){
  3.         //一人一单
  4.         Long userId=UserHolder.getUser().getId();
  5.         //查询订单
  6.         Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
  7.         //判断订单是否存在
  8.         if(count>0){
  9.             //用户已经购买过了
  10.             return Result.fail("用户已经购买过一次了!");
  11.         }
  12.         //5.扣减库存
  13.         boolean success = seckillVoucherService.update()
  14.                 .setSql("stock = stock-1")
  15.                 .eq("voucher_id", voucherId).gt("stock",0)//判断此时库存是否大于零
  16.                 .update();
  17.         if(!success){
  18.             //数据库更新失败
  19.             return Result.fail("优惠券已被抢完!");
  20.         }
  21.         //6.创建订单
  22.         VoucherOrder voucherOrder=new VoucherOrder();
  23.         //6.1.订单id
  24.         long orderId = redisIdWorker.nextId("order");
  25.         voucherOrder.setId(orderId);
  26.         //6.2.用户id
  27.         voucherOrder.setUserId(userId);
  28.         //6.3.代金券id
  29.         voucherOrder.setVoucherId(voucherId);
  30.         save(voucherOrder);
  31.         //7.返回订单id
  32.         return Result.ok(orderId);
  33.     }
复制代码
初始时数据库中有200个优惠券:

让我们测试一下看看,让同十个用户同时发送200个请求: 

此时数据库中还剩下90张券:

说明既没有出现超卖问题,也实现了一人一单。

   那么本篇文章就到此为止了,如果觉得这篇文章对你有帮助的话,可以点一下关注和点赞来支持作者哦。如果有什么讲的不对的地方欢迎在评论区指出,渴望可以或许和你们一起进步✊
  


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

立山

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表