黑马点评3——优惠券秒杀—全局唯一ID、秒杀下单、超卖问题(乐观锁)、一 ...

守听  金牌会员 | 2024-9-22 05:25:56 | 显示全部楼层 | 阅读模式
打印 上一主题 下一主题

主题 874|帖子 874|积分 2622

全局唯一ID


全局ID天生器,是一种在分布式系统下用来天生全局唯一ID的工具,一般要满足一下特性:

基本格式如下:

UUID

返回的是16进制的
Redis自增

根据上面图示的格式,我们实现一个Redis自增的全局ID,注册成bean,交给Spring管理
  1. @Component
  2. public class RedisIdWorker {
  3.    
  4.     /**
  5.      * 开始时间戳
  6.      */
  7.     private static final long BEGIN_TIMESTAMP = 1704067200L;
  8.     /**
  9.      * 序列号位数
  10.      */
  11.     private static final long COUNT_BITS = 32;
  12.    
  13.     private StringRedisTemplate stringRedisTemplate;
  14.     public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
  15.         this.stringRedisTemplate = stringRedisTemplate;
  16.     }
  17.     public long nextId(String keyPrefix){
  18.         // 1. 生成时间戳
  19.         LocalDateTime now = LocalDateTime.now();
  20.         long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
  21.         long timestep = nowSecond - BEGIN_TIMESTAMP;
  22.         // 2. 生成序列号
  23.         // 2.1 获取当前日期,精确到天,好处1: 避免超过2^32, 2:方便统计
  24.         String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
  25.         long count = stringRedisTemplate.opsForValue().increment("icr:"+keyPrefix + ":" + date);
  26.         // 3. 拼接并返回
  27.         return timestep << COUNT_BITS | count;
  28.     }
  29. }
复制代码
单位测试:
  1.     @Autowired
  2.     private RedisIdWorker redisIdWorker;
  3.     private ExecutorService es = Executors.newFixedThreadPool(500);
  4.     @Test
  5.     void testIdWorker() throws InterruptedException {
  6.         // 使用 CountDownLatch 同步 300 个异步任务。
  7.         // 确保所有任务完成后,计算并输出总的执行时间。
  8.         // 通过这种方式,可以准确地测量所有任务的总执行时间,而不会因为异步执行导致时间计算不准确。
  9.         CountDownLatch latch = new CountDownLatch(300);   // 因为这里是异步执行的,所以统计时间的话不能使用普通的打印时间
  10.         Runnable task = () ->{
  11.             for (int i = 0; i < 100; i++) {
  12.                 long id = redisIdWorker.nextId("order");
  13.                 System.out.println("id = " + id);
  14.             }
  15.             latch.countDown();
  16.         };
  17.         long begin = System.currentTimeMillis();
  18.         for(int i=0; i<300; i++){
  19.             es.submit(task);  // 使用线程池 es 提交 300 个相同的任务。每个任务都会执行上面定义的操作。
  20.         }
  21.         latch.await();  // 调用 latch.await() 方法,使当前线程等待,直到计数器的值变为 0。这确保了所有 300 个任务都已完成。
  22.         long end = System.currentTimeMillis();
  23.         System.out.println("time = " + (end - begin));
  24.     }
复制代码
snowflake算法(雪花算法)

数据库自增

这里不是说使用数据库的自增字段,而是说单独使用一张表,专门用来做自增,订单表需要id,就从这张表里获取(redis自增的数据库版)
Redis自增id策略



  • 天天一个Key,方便统计订单量
  • ID构造是时间戳+计数器
实现优惠券秒杀下单


主要是针对特价券这种需要抢的,普通券就没须要了。
优惠券秒杀下单时要判断两点:


  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否富足,不敷无法下单

  1. @Override
  2.     @Transactional
  3.     public Result seckillVoucher(Long voucherId) {
  4.         // 1. 查询优惠券
  5.         SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
  6.         // 2. 判断秒杀是否开始
  7.         if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
  8.             // 尚未开始
  9.             return Result.fail("秒杀尚未开始!");
  10.         }
  11.         // 2/ 判断秒杀是否结束
  12.         if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
  13.             // 已经结束
  14.             return Result.fail("秒杀已经结束!");
  15.         }
  16.         // 4. 判断库存是否充足
  17.         if (voucher.getStock()<1) {
  18.             // 库存不足
  19.             return Result.fail("库存不足!");
  20.         }
  21.         // 5.扣减库存
  22.         boolean success = seckillVoucherService.update().setSql("stock = stock - 1")
  23.                 .eq("voucher_id", voucherId).update();
  24.         if(!success){
  25.             return Result.fail("库存不足!");
  26.         }
  27.         // 6.创建订单
  28.         VoucherOrder voucherOrder = new VoucherOrder();
  29.         // 6.1 订单id
  30.         long orderId = redisIdWorker.nextId("order");
  31.         voucherOrder.setId(orderId);
  32.         // 6.2 用户id
  33.         Long userId = UserHolder.getUser().getId();
  34.         voucherOrder.setUserId(userId);
  35.         // 6.3 代金券id
  36.         voucherOrder.setVoucherId(voucherId);
  37.         save(voucherOrder);
  38.         // 7. 返回订单id
  39.         return Result.ok(orderId);
  40.     }
复制代码
超卖问题

正常情况下没问题:

高并发环境下就有问题,不同线程的动作会交叉

如果同一时候有许多的线程同时来查询,就出现了这个并发安全问题
   多个线程在操作共享的资源,并且操作资源的代码有好几行,这几行代码执行的中心,多个线程相互穿插,就出现了安全问题。
  

悲观锁很简朴暴力,直接加锁就行,我们演示乐观锁:
乐观锁的关键是判断之前查询得到的数据是否有被修改过, 常见的方式有两种:
乐观锁——版本号法


线程2在扣减库存的时间发现版本不一致就无法更新了
那现在我们想,我们即用库存,又用版本,比力版本的变化,是不是可以使用库存的变化代替呢?固然可以,于是乎,有了新方案:cas
乐观锁——CAS法


我们只需要把刚刚代码在扣减的时间加上关于库存的比力
  1. @Override
  2.     @Transactional
  3.     public Result seckillVoucher(Long voucherId) {
  4.         // 1. 查询优惠券
  5.         SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
  6.         // 2. 判断秒杀是否开始
  7.         if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
  8.             // 尚未开始
  9.             return Result.fail("秒杀尚未开始!");
  10.         }
  11.         // 2/ 判断秒杀是否结束
  12.         if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
  13.             // 已经结束
  14.             return Result.fail("秒杀已经结束!");
  15.         }
  16.         // 4. 判断库存是否充足
  17.         if (voucher.getStock()<1) {
  18.             // 库存不足
  19.             return Result.fail("库存不足!");
  20.         }
  21.         // 5.扣减库存
  22.         boolean success = seckillVoucherService.update()
  23.                 .setSql("stock = stock - 1")
  24.                 .eq("voucher_id", voucherId)
  25.                 .eq("stock",voucher.getStock())   // CAS锁
  26.                 .update();
  27.         if(!success){
  28.             return Result.fail("库存不足!");
  29.         }
  30.         // 6.创建订单
  31.         VoucherOrder voucherOrder = new VoucherOrder();
  32.         // 6.1 订单id
  33.         long orderId = redisIdWorker.nextId("order");
  34.         voucherOrder.setId(orderId);
  35.         // 6.2 用户id
  36.         Long userId = UserHolder.getUser().getId();
  37.         voucherOrder.setUserId(userId);
  38.         // 6.3 代金券id
  39.         voucherOrder.setVoucherId(voucherId);
  40.         save(voucherOrder);
  41.         // 7. 返回订单id
  42.         return Result.ok(orderId);
  43.     }
复制代码
但我们使用jmeter进行测试,设置库存100件,并发线程200个,测试结果如下:

错误率高达68.5%
啊。不应该啊。
在看看数据库的库存:

还剩下79件???
订单也只有21个,没有超卖啊,安全问题确实解决了,那为什么出现了许多失败的情况呢?
还没卖完就结束了?怎么回事?
乐观锁的毛病


太小心了,他认为只要有人修改了就不执行,失败率大大提高
其实只要库存大于0, 就没问题。
这就是乐观锁的问题——成功率太低
其实解决方案很简朴,就是把扣减库存的时间的
  1.   // 5.扣减库存
  2.         boolean success = seckillVoucherService.update()
  3.                 .setSql("stock = stock - 1")
  4.                 .eq("voucher_id", voucherId)
  5.                 .eq("stock",voucher.getStock())   // CAS锁
  6.                 .update();
复制代码
这个严苛的判断条件改成只要库存大于0,就没须要大惊小怪的。
  1. // 5.扣减库存
  2.         boolean success = seckillVoucherService.update()
  3.                 .setSql("stock = stock - 1")
  4.                 .eq("voucher_id", voucherId)
  5.                 .gt("stock",0)   // 把判断条件改成库存大于0就可以避免乐观锁的弊端
  6.                 .update();
复制代码
  1. @Override    @Transactional    public Result seckillVoucher(Long voucherId) {        // 1. 查询优惠券        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);        // 2. 判断秒杀是否开始        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {            // 尚未开始            return Result.fail("秒杀尚未开始!");        }        // 2/ 判断秒杀是否结束        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {            // 已经结束            return Result.fail("秒杀已经结束!");        }        // 4. 判断库存是否富足        if (voucher.getStock()<1) {            // 库存不敷            return Result.fail("库存不敷!");        }        // 5.扣减库存
  2.         boolean success = seckillVoucherService.update()
  3.                 .setSql("stock = stock - 1")
  4.                 .eq("voucher_id", voucherId)
  5.                 .gt("stock",0)   // 把判断条件改成库存大于0就可以避免乐观锁的弊端
  6.                 .update();
  7.         if(!success){            return Result.fail("库存不敷!");        }        // 6.创建订单        VoucherOrder voucherOrder = new VoucherOrder();        // 6.1 订单id        long orderId = redisIdWorker.nextId("order");        voucherOrder.setId(orderId);        // 6.2 用户id        Long userId = UserHolder.getUser().getId();        voucherOrder.setUserId(userId);        // 6.3 代金券id        voucherOrder.setVoucherId(voucherId);        save(voucherOrder);        // 7. 返回订单id        return Result.ok(orderId);    }
复制代码
100的库存,200的线程并发访问,jmeter测试结果:

数据库也淘汰到0了

固然这个解决方案不是所有情况都行的,还有其他的解决方案
比如:如果有的问题中没有库存怎么办?
接纳分批加锁的方案,或者是分段锁的方案,也就是说把数据库中的资源分成几份,比如说把数据分成十份,那用户在抢的时间可以去10张表里面分别去抢,这样一来成功率就提高了10倍。这种思想在ConcurrentHashMap中有应用。
总结


一人一单

以前我们的优惠券下单业务是这样的:

现在修改业务流程

修改我们的业务代码如下:
  1.     @Override
  2.     @Transactional
  3.     public Result seckillVoucher(Long voucherId) {
  4.         // 1. 查询优惠券
  5.         SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
  6.         // 2. 判断秒杀是否开始
  7.         if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
  8.             // 尚未开始
  9.             return Result.fail("秒杀尚未开始!");
  10.         }
  11.         // 2/ 判断秒杀是否结束
  12.         if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
  13.             // 已经结束
  14.             return Result.fail("秒杀已经结束!");
  15.         }
  16.         // 4. 判断库存是否充足
  17.         if (voucher.getStock()<1) {
  18.             // 库存不足
  19.             return Result.fail("库存不足!");
  20.         }
  21.         // 5. 一人一单
  22.         Long userId = UserHolder.getUser().getId();
  23.         // 5.1 查询订单
  24.         Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
  25.         // 5.2 判断是否存在
  26.         if(count > 0){
  27.             return Result.fail("您已经购买过一次了!");
  28.         }
  29.         // 6.扣减库存
  30.         boolean success = seckillVoucherService.update()
  31.                 .setSql("stock = stock - 1")
  32.                 .eq("voucher_id", voucherId)
  33.                 .gt("stock",0)   // 把判断条件改成库存大于0就可以避免乐观锁的弊端
  34.                 .update();
  35.         if(!success){
  36.             return Result.fail("库存不足!");
  37.         }
  38.         // 7.创建订单
  39.         VoucherOrder voucherOrder = new VoucherOrder();
  40.         // 7.1 订单id
  41.         long orderId = redisIdWorker.nextId("order");
  42.         voucherOrder.setId(orderId);
  43.         // 7.2 用户id
  44.         voucherOrder.setUserId(userId);
  45.         // 7.3 代金券id
  46.         voucherOrder.setVoucherId(voucherId);
  47.         save(voucherOrder);
  48.         // 8. 返回订单id
  49.         return Result.ok(orderId);
  50.     }
复制代码
100的库存,200的线程并发访问,但是只配置一个用户的请求头,jmeter测试
理论上应该只能下一单才对,库存应该还剩下99个
看看数据库库存

订单表里面也有10个订单:

什么情况?里面的user_id和voucher_id竟然也是一样的!
说明我们做了一人一单的业务逻辑判断,但是并没有解决问题。
现在的问题就是由于先查询,在判断,在扣减,就是由于多个线程并发访问,多个线程一起查询count都是0,都说我可以扣减,那就堕落了,那怎么解决呢?——加锁呀!
先暴力加个悲观锁,把查询订单和扣减库存的方法抽取出来:
Transactional注解也要换到扣减库存的方法上。
  1. @Override
  2.     public Result seckillVoucher(Long voucherId) {
  3.         // 1. 查询优惠券
  4.         SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
  5.         // 2. 判断秒杀是否开始
  6.         if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
  7.             // 尚未开始
  8.             return Result.fail("秒杀尚未开始!");
  9.         }
  10.         // 2/ 判断秒杀是否结束
  11.         if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
  12.             // 已经结束
  13.             return Result.fail("秒杀已经结束!");
  14.         }
  15.         // 4. 判断库存是否充足
  16.         if (voucher.getStock()<1) {
  17.             // 库存不足
  18.             return Result.fail("库存不足!");
  19.         }
  20.         return createVoucherOrder(voucherId);
  21.     }
  22.     @Transactional
  23.     public synchronized Result createVoucherOrder(Long voucherId){
  24.         // 5. 一人一单
  25.         Long userId = UserHolder.getUser().getId();
  26.         // 5.1 查询订单
  27.         Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
  28.         // 5.2 判断是否存在
  29.         if(count > 0){
  30.             return Result.fail("您已经购买过一次了!");
  31.         }
  32.         // 6.扣减库存
  33.         boolean success = seckillVoucherService.update()
  34.                 .setSql("stock = stock - 1")
  35.                 .eq("voucher_id", voucherId)
  36.                 .gt("stock",0)   // 把判断条件改成库存大于0就可以避免乐观锁的弊端
  37.                 .update();
  38.         if(!success){
  39.             return Result.fail("库存不足!");
  40.         }
  41.         // 7.创建订单
  42.         VoucherOrder voucherOrder = new VoucherOrder();
  43.         // 7.1 订单id
  44.         long orderId = redisIdWorker.nextId("order");
  45.         voucherOrder.setId(orderId);
  46.         // 7.2 用户id
  47.         voucherOrder.setUserId(userId);
  48.         // 7.3 代金券id
  49.         voucherOrder.setVoucherId(voucherId);
  50.         save(voucherOrder);
  51.         // 8. 返回订单id
  52.         return Result.ok(orderId);
  53.     }
复制代码
锁加在方法上肯定可以解决,但是不建议,由于synchronized加在方法上,就酿成了锁整个方法,锁的对象是this,也就意味着不管任何一个用户来了,都要加这个锁,而且大家是同一把锁,整个方法就串行执行了。我们想要的是只有同一个用户来了在加锁,不同用户来了就不消管。各做各就行,应该对用户id加锁。缩小加锁的范围。
注意两点:

  • 开释锁的时机:我们的锁也不能加载createVoucherOrder方法里面,由于锁要是加载createVoucherOrder方法里面,会出现spring的事务还没提交就开释锁的问题。
  • 事务失效问题
    我们在createVoucherOrder函数加了事务,没有给seckillVoucher函数加事务,而seckillVoucher函数调用的时间使用this调用的,事务失效,详细表明看代码里
  1. @Override
  2.     public Result seckillVoucher(Long voucherId) {
  3.         // 1. 查询优惠券
  4.         SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
  5.         // 2. 判断秒杀是否开始
  6.         if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
  7.             // 尚未开始
  8.             return Result.fail("秒杀尚未开始!");
  9.         }
  10.         // 2/ 判断秒杀是否结束
  11.         if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
  12.             // 已经结束
  13.             return Result.fail("秒杀已经结束!");
  14.         }
  15.         // 4. 判断库存是否充足
  16.         if (voucher.getStock()<1) {
  17.             // 库存不足
  18.             return Result.fail("库存不足!");
  19.         }
  20.         Long userId = UserHolder.getUser().getId();
  21.         /**
  22.          *          每一个请求过来,这个id对象都是一个全新的id对象,因为要是对userId加锁的话,对象变了锁就变了,那不行
  23.          *          我们希望id的值一样,所以用了toString(),但是toString()依旧不能保证是对对象的值加锁的
  24.          *          toString底层是new 一个String数组,还是new了一个新对象,同一个用户id在不同的请求中过来,每次都new一个,还是不能把锁加载同一个用户上
  25.          *          于是用intern() ,intern()方法可以去字符串常量池中找字符串值一样的引用返回
  26.          *          这样一来,如果你的userId是5,不管你new了多少个字符串,只要值是一样的,返回的结果也一样。这样就可以锁住同一个用户
  27.          *          不同的用户不会被锁住
  28.          */
  29.         synchronized (userId.toString().intern()) {
  30.             // 获取代理对象(事务)
  31.             IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();  // 拿到当前对象的代理对象,其实就是IVoucherOrderService这个接口的代理对象,返回的是Object,做个强转
  32.             return proxy.createVoucherOrder(voucherId);  // 如果报错了是因为我们的接口中没有这个方法,那我们就在接口中创建一下这个方法就行
  33.         }
  34.     }
  35.     /**
  36.      * 事务加在这,就失效了,为什么呢?
  37.      * 加载这是对createVoucherOrder函数加了事务,没有给seckillVoucher函数加事务,而seckillVoucher函数调用的时候
  38.      * createVoucherOrder(voucherId);
  39.      * 这样使用this调用的,这个this拿到的是当前的VoucherOrderServiceImpl对象
  40.      * 而不是VoucherOrderServiceImpl的代理对象
  41.      * 而事务要想生效,是spring对当前这个类做了动态代理,拿到代理对象做的事务处理
  42.      * 而我们当前的this是非代理对象,这就是事务失效的几种可能性之一
  43.      * 解决方法之一:
  44.      * AopContext.currentProxy()拿到代理对象来调用createVoucherOrder
  45.      *
  46.      * 当然这样解决还得做两件事:
  47.      * 1. 引入aspectj的依赖
  48.      *    <dependency>
  49.             <groupId>org.aspectj</groupId>
  50.             <artifactId>aspectjweaver</artifactId>
  51.             <version>1.9.7</version>
  52.         </dependency>
  53.      * 2. 启动类添加注解@EnableAspectJAutoProxy(exposeProxy = true)暴露代理对象
  54.      */
  55.     @Transactional
  56.     public Result createVoucherOrder(Long voucherId){
  57.         // 5. 一人一单
  58.         Long userId = UserHolder.getUser().getId();
  59.         // 5.1 查询订单
  60.         Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
  61.         // 5.2 判断是否存在
  62.         if(count > 0){
  63.             return Result.fail("您已经购买过一次了!");
  64.         }
  65.         // 6.扣减库存
  66.         boolean success = seckillVoucherService.update()
  67.                 .setSql("stock = stock - 1")
  68.                 .eq("voucher_id", voucherId)
  69.                 .gt("stock",0)   // 把判断条件改成库存大于0就可以避免乐观锁的弊端
  70.                 .update();
  71.         if(!success){
  72.             return Result.fail("库存不足!");
  73.         }
  74.         // 7.创建订单
  75.         VoucherOrder voucherOrder = new VoucherOrder();
  76.         // 7.1 订单id
  77.         long orderId = redisIdWorker.nextId("order");
  78.         voucherOrder.setId(orderId);
  79.         // 7.2 用户id
  80.         voucherOrder.setUserId(userId);
  81.         // 7.3 代金券id
  82.         voucherOrder.setVoucherId(voucherId);
  83.         save(voucherOrder);
  84.         // 8. 返回订单id
  85.         return Result.ok(orderId);
  86.     }
复制代码
100的库存,200的线程并发访问,但是只配置一个用户的请求头,jmeter测试
理论上应该只能下一单才对,库存应该还剩下99个
看看数据库库存对的。YES!!!
一人一单的并发安全问题

上面的解决方案在单机模式下不会有问题,但是在集群模式下就有问题了,什么问题呢?我们来测试

我们用postman使用同一个用户发送两个请求,在锁后打上断点,发现两个集群下两个服务都进入断点了,这一个锁在集群模式下没有锁住,放开后也发现数据库的数据被同一个用户扣减了两个。为什么呢?
来捋一下:
之前是单体项目,正常情况下:

多线程并发下,要是没有加锁,会出现并发执行:

这就出现了线程安全问题。于是我们加了锁

在集群情况下就出问题了:
现在我们是多台JVM下,锁的原理是,在JVM内部维护一个锁监视器对象,这个监视器对象用的userId,userId在常量池中。在这个JVM内部维护了一个常量池,当userId相同的情况下,永远是同一个锁,也就是锁的监视器就能记录不同线程的情况。
但是当集群的时间,那就各自有各自的JVM,那各自的JVM都有各自的堆、栈、方法区之类的。JVM2也会有自己的常量池,JVM2 的锁监视器只能在当前的JVM内部见识线程,实现互斥。

这就有一次出现了并发安全问题,每一个JVM都有自己的锁,就导致并行运行下,就出现问题了,那就得让多个JVM只能使用同一把锁,但这样的锁不是JDK提供的,于是乎跨JVM,或者跨进程的锁就出现了——分布式锁

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

本帖子中包含更多资源

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

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

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

本版积分规则

守听

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表