守听 发表于 2024-9-22 05:25:56

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

全局唯一ID

https://i-blog.csdnimg.cn/direct/e00355b19c4f4ca2b9cc395820e0f89f.png
全局ID天生器,是一种在分布式系统下用来天生全局唯一ID的工具,一般要满足一下特性:
https://i-blog.csdnimg.cn/direct/438798b3b4cd40cabb2e761f50486fd6.png
基本格式如下:
https://i-blog.csdnimg.cn/direct/943c6e7ebdae439fb3cdd4cc37d6cb9d.png
UUID

返回的是16进制的
Redis自增

根据上面图示的格式,我们实现一个Redis自增的全局ID,注册成bean,交给Spring管理

@Component
public class RedisIdWorker {
   
    /**
   * 开始时间戳
   */
    private static final long BEGIN_TIMESTAMP = 1704067200L;
    /**
   * 序列号位数
   */
    private static final long COUNT_BITS = 32;
   
    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
      this.stringRedisTemplate = stringRedisTemplate;
    }
    public long nextId(String keyPrefix){
      // 1. 生成时间戳
      LocalDateTime now = LocalDateTime.now();
      long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
      long timestep = nowSecond - BEGIN_TIMESTAMP;
      // 2. 生成序列号
      // 2.1 获取当前日期,精确到天,好处1: 避免超过2^32, 2:方便统计
      String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
      long count = stringRedisTemplate.opsForValue().increment("icr:"+keyPrefix + ":" + date);
      // 3. 拼接并返回
      return timestep << COUNT_BITS | count;
    }
}

单位测试:

    @Autowired
    private RedisIdWorker redisIdWorker;

    private ExecutorService es = Executors.newFixedThreadPool(500);

    @Test
    void testIdWorker() throws InterruptedException {
      // 使用 CountDownLatch 同步 300 个异步任务。
      // 确保所有任务完成后,计算并输出总的执行时间。
      // 通过这种方式,可以准确地测量所有任务的总执行时间,而不会因为异步执行导致时间计算不准确。
      CountDownLatch latch = new CountDownLatch(300);   // 因为这里是异步执行的,所以统计时间的话不能使用普通的打印时间
      Runnable task = () ->{
            for (int i = 0; i < 100; i++) {
                long id = redisIdWorker.nextId("order");
                System.out.println("id = " + id);
            }
            latch.countDown();
      };

      long begin = System.currentTimeMillis();
      for(int i=0; i<300; i++){
            es.submit(task);// 使用线程池 es 提交 300 个相同的任务。每个任务都会执行上面定义的操作。
      }
      latch.await();// 调用 latch.await() 方法,使当前线程等待,直到计数器的值变为 0。这确保了所有 300 个任务都已完成。
      long end = System.currentTimeMillis();
      System.out.println("time = " + (end - begin));
    }

snowflake算法(雪花算法)

数据库自增

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



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

https://i-blog.csdnimg.cn/direct/8c02e9d941e949d4817cba33e45fa105.png
主要是针对特价券这种需要抢的,普通券就没须要了。
优惠券秒杀下单时要判断两点:


[*]秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
[*]库存是否富足,不敷无法下单
https://i-blog.csdnimg.cn/direct/bec9142afc3a4b9faaa3894ac0c70370.png
@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.扣减库存
      boolean success = seckillVoucherService.update().setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).update();

      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);
    }
超卖问题

正常情况下没问题:
https://i-blog.csdnimg.cn/direct/e05c097086a04e3ab4cae3d7fde75ccb.png
高并发环境下就有问题,不同线程的动作会交叉
https://i-blog.csdnimg.cn/direct/43bdd689866d43da9430b73d632dd901.png
如果同一时候有许多的线程同时来查询,就出现了这个并发安全问题
   多个线程在操作共享的资源,并且操作资源的代码有好几行,这几行代码执行的中心,多个线程相互穿插,就出现了安全问题。
https://i-blog.csdnimg.cn/direct/56521cef465b4d5eb688d7767dd961a8.png
悲观锁很简朴暴力,直接加锁就行,我们演示乐观锁:
乐观锁的关键是判断之前查询得到的数据是否有被修改过, 常见的方式有两种:
乐观锁——版本号法

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

https://i-blog.csdnimg.cn/direct/e76b8241ca7c471385715aba3533a3ce.png
我们只需要把刚刚代码在扣减的时间加上关于库存的比力
@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.扣减库存
      boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .eq("stock",voucher.getStock())   // CAS锁
                .update();

      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);
    }
但我们使用jmeter进行测试,设置库存100件,并发线程200个,测试结果如下:
https://i-blog.csdnimg.cn/direct/b34548710b8c452c819be87744467230.png
错误率高达68.5%
啊。不应该啊。
在看看数据库的库存:
https://i-blog.csdnimg.cn/direct/454d6ba2419d44bc841ae0eb055316dc.png
还剩下79件???
订单也只有21个,没有超卖啊,安全问题确实解决了,那为什么出现了许多失败的情况呢?
还没卖完就结束了?怎么回事?
乐观锁的毛病

https://i-blog.csdnimg.cn/direct/9bc93a9256f24c5bb118592fbe892ceb.png
太小心了,他认为只要有人修改了就不执行,失败率大大提高
其实只要库存大于0, 就没问题。
这就是乐观锁的问题——成功率太低
其实解决方案很简朴,就是把扣减库存的时间的
// 5.扣减库存
      boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .eq("stock",voucher.getStock())   // CAS锁
                .update();
这个严苛的判断条件改成只要库存大于0,就没须要大惊小怪的。
// 5.扣减库存
      boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)   // 把判断条件改成库存大于0就可以避免乐观锁的弊端
                .update();
@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.扣减库存
      boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)   // 把判断条件改成库存大于0就可以避免乐观锁的弊端
                .update();
      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测试结果:
https://i-blog.csdnimg.cn/direct/8812bfd56c33462fb0d79d05d88b57c3.png
数据库也淘汰到0了
https://i-blog.csdnimg.cn/direct/f6e0c5d57a1b4a27bcff62332db4e96f.png
固然这个解决方案不是所有情况都行的,还有其他的解决方案
比如:如果有的问题中没有库存怎么办?
接纳分批加锁的方案,或者是分段锁的方案,也就是说把数据库中的资源分成几份,比如说把数据分成十份,那用户在抢的时间可以去10张表里面分别去抢,这样一来成功率就提高了10倍。这种思想在ConcurrentHashMap中有应用。
总结

https://i-blog.csdnimg.cn/direct/75797628ea6041fcbaff8c5b780dced6.png
一人一单

以前我们的优惠券下单业务是这样的:
https://i-blog.csdnimg.cn/direct/c60035ec124a4a72ac1d010a66eecf21.png
现在修改业务流程
https://i-blog.csdnimg.cn/direct/4a08e3f2234f42bcaf4329659b65e269.png
修改我们的业务代码如下:

    @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. 一人一单
      Long userId = UserHolder.getUser().getId();
      // 5.1 查询订单
      Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
      // 5.2 判断是否存在
      if(count > 0){
            return Result.fail("您已经购买过一次了!");
      }

      // 6.扣减库存
      boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)   // 把判断条件改成库存大于0就可以避免乐观锁的弊端
                .update();

      if(!success){
            return Result.fail("库存不足!");
      }
      // 7.创建订单
      VoucherOrder voucherOrder = new VoucherOrder();
      // 7.1 订单id
      long orderId = redisIdWorker.nextId("order");
      voucherOrder.setId(orderId);
      // 7.2 用户id
      voucherOrder.setUserId(userId);
      // 7.3 代金券id
      voucherOrder.setVoucherId(voucherId);
      save(voucherOrder);
      // 8. 返回订单id
      return Result.ok(orderId);
    }
100的库存,200的线程并发访问,但是只配置一个用户的请求头,jmeter测试
理论上应该只能下一单才对,库存应该还剩下99个
看看数据库库存
https://i-blog.csdnimg.cn/direct/2cf3de1a3d614439a22137298866c43b.png
订单表里面也有10个订单:
https://i-blog.csdnimg.cn/direct/a687ee79f9c2497db1b81ee572f5de4a.png
什么情况?里面的user_id和voucher_id竟然也是一样的!
说明我们做了一人一单的业务逻辑判断,但是并没有解决问题。
现在的问题就是由于先查询,在判断,在扣减,就是由于多个线程并发访问,多个线程一起查询count都是0,都说我可以扣减,那就堕落了,那怎么解决呢?——加锁呀!
先暴力加个悲观锁,把查询订单和扣减库存的方法抽取出来:
Transactional注解也要换到扣减库存的方法上。
@Override
    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("库存不足!");
      }

      return createVoucherOrder(voucherId);
    }

    @Transactional
    public synchronized Result createVoucherOrder(Long voucherId){
      // 5. 一人一单
      Long userId = UserHolder.getUser().getId();
      // 5.1 查询订单
      Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
      // 5.2 判断是否存在
      if(count > 0){
            return Result.fail("您已经购买过一次了!");
      }

      // 6.扣减库存
      boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)   // 把判断条件改成库存大于0就可以避免乐观锁的弊端
                .update();

      if(!success){
            return Result.fail("库存不足!");
      }
      // 7.创建订单
      VoucherOrder voucherOrder = new VoucherOrder();
      // 7.1 订单id
      long orderId = redisIdWorker.nextId("order");
      voucherOrder.setId(orderId);
      // 7.2 用户id
      voucherOrder.setUserId(userId);
      // 7.3 代金券id
      voucherOrder.setVoucherId(voucherId);
      save(voucherOrder);
      // 8. 返回订单id
      return Result.ok(orderId);
    }
锁加在方法上肯定可以解决,但是不建议,由于synchronized加在方法上,就酿成了锁整个方法,锁的对象是this,也就意味着不管任何一个用户来了,都要加这个锁,而且大家是同一把锁,整个方法就串行执行了。我们想要的是只有同一个用户来了在加锁,不同用户来了就不消管。各做各就行,应该对用户id加锁。缩小加锁的范围。
注意两点:

[*]开释锁的时机:我们的锁也不能加载createVoucherOrder方法里面,由于锁要是加载createVoucherOrder方法里面,会出现spring的事务还没提交就开释锁的问题。
[*]事务失效问题
我们在createVoucherOrder函数加了事务,没有给seckillVoucher函数加事务,而seckillVoucher函数调用的时间使用this调用的,事务失效,详细表明看代码里
@Override
    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("库存不足!");
      }

      Long userId = UserHolder.getUser().getId();
      /**
         *          每一个请求过来,这个id对象都是一个全新的id对象,因为要是对userId加锁的话,对象变了锁就变了,那不行
         *          我们希望id的值一样,所以用了toString(),但是toString()依旧不能保证是对对象的值加锁的
         *          toString底层是new 一个String数组,还是new了一个新对象,同一个用户id在不同的请求中过来,每次都new一个,还是不能把锁加载同一个用户上
         *          于是用intern() ,intern()方法可以去字符串常量池中找字符串值一样的引用返回
         *          这样一来,如果你的userId是5,不管你new了多少个字符串,只要值是一样的,返回的结果也一样。这样就可以锁住同一个用户
         *          不同的用户不会被锁住
         */

      synchronized (userId.toString().intern()) {
            // 获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();// 拿到当前对象的代理对象,其实就是IVoucherOrderService这个接口的代理对象,返回的是Object,做个强转
            return proxy.createVoucherOrder(voucherId);// 如果报错了是因为我们的接口中没有这个方法,那我们就在接口中创建一下这个方法就行
      }
    }

    /**
   * 事务加在这,就失效了,为什么呢?
   * 加载这是对createVoucherOrder函数加了事务,没有给seckillVoucher函数加事务,而seckillVoucher函数调用的时候
   * createVoucherOrder(voucherId);
   * 这样使用this调用的,这个this拿到的是当前的VoucherOrderServiceImpl对象
   * 而不是VoucherOrderServiceImpl的代理对象
   * 而事务要想生效,是spring对当前这个类做了动态代理,拿到代理对象做的事务处理
   * 而我们当前的this是非代理对象,这就是事务失效的几种可能性之一
   * 解决方法之一:
   * AopContext.currentProxy()拿到代理对象来调用createVoucherOrder
   *
   * 当然这样解决还得做两件事:
   * 1. 引入aspectj的依赖
   *    <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
            <version>1.9.7</version>
      </dependency>
   * 2. 启动类添加注解@EnableAspectJAutoProxy(exposeProxy = true)暴露代理对象
   */
    @Transactional
    public Result createVoucherOrder(Long voucherId){
      // 5. 一人一单
      Long userId = UserHolder.getUser().getId();
      // 5.1 查询订单
      Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
      // 5.2 判断是否存在
      if(count > 0){
            return Result.fail("您已经购买过一次了!");
      }

      // 6.扣减库存
      boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)   // 把判断条件改成库存大于0就可以避免乐观锁的弊端
                .update();

      if(!success){
            return Result.fail("库存不足!");
      }
      // 7.创建订单
      VoucherOrder voucherOrder = new VoucherOrder();
      // 7.1 订单id
      long orderId = redisIdWorker.nextId("order");
      voucherOrder.setId(orderId);
      // 7.2 用户id
      voucherOrder.setUserId(userId);
      // 7.3 代金券id
      voucherOrder.setVoucherId(voucherId);
      save(voucherOrder);
      // 8. 返回订单id
      return Result.ok(orderId);

    }
100的库存,200的线程并发访问,但是只配置一个用户的请求头,jmeter测试
理论上应该只能下一单才对,库存应该还剩下99个
看看数据库库存对的。YES!!!
一人一单的并发安全问题

上面的解决方案在单机模式下不会有问题,但是在集群模式下就有问题了,什么问题呢?我们来测试
https://i-blog.csdnimg.cn/direct/a0d60f2a906641f781e689ed83dbb183.png
我们用postman使用同一个用户发送两个请求,在锁后打上断点,发现两个集群下两个服务都进入断点了,这一个锁在集群模式下没有锁住,放开后也发现数据库的数据被同一个用户扣减了两个。为什么呢?
来捋一下:
之前是单体项目,正常情况下:
https://i-blog.csdnimg.cn/direct/04e17bc122db41019e89b9ca52614abb.png
多线程并发下,要是没有加锁,会出现并发执行:
https://i-blog.csdnimg.cn/direct/5dd3700a1b184dba9acb8a40fc8f0aa1.png
这就出现了线程安全问题。于是我们加了锁
https://i-blog.csdnimg.cn/direct/59e17b1ce5ef491897cb45d861cf8b2b.png
在集群情况下就出问题了:
现在我们是多台JVM下,锁的原理是,在JVM内部维护一个锁监视器对象,这个监视器对象用的userId,userId在常量池中。在这个JVM内部维护了一个常量池,当userId相同的情况下,永远是同一个锁,也就是锁的监视器就能记录不同线程的情况。
但是当集群的时间,那就各自有各自的JVM,那各自的JVM都有各自的堆、栈、方法区之类的。JVM2也会有自己的常量池,JVM2 的锁监视器只能在当前的JVM内部见识线程,实现互斥。
https://i-blog.csdnimg.cn/direct/5a2737840b7342fca5a36b76acb72102.png
这就有一次出现了并发安全问题,每一个JVM都有自己的锁,就导致并行运行下,就出现问题了,那就得让多个JVM只能使用同一把锁,但这样的锁不是JDK提供的,于是乎跨JVM,或者跨进程的锁就出现了——分布式锁

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: 黑马点评3——优惠券秒杀—全局唯一ID、秒杀下单、超卖问题(乐观锁)、一