物来顺应,未来不迎,当时不杂,既过|

万事胜意k

园龄:2年8个月粉丝:11关注:4

Redis学习之秒杀业务

秒杀业务的核心流程是:判断日期和库存、扣减库存、创建订单

如下图:

image-20231008144119363

注意,秒杀库存信息和商品信息最好是独立的两张表,不要放在一起影响性能。

@Resource
   private ISeckillVoucherService seckillVoucherService;
   @Resource
   private RedisIdWorker redisIdWorker;
   @Override
   public Result seckillVoucher(Long voucherId) {
       // 1.查询优惠券
       SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
       // 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 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);
       return Result.ok(orderId);
  }

订单超卖问题

出现的原因:有多个人同时下单,在库存扣减前大家查到的库存都大于0,所以都 触发了扣库存操作。

image-20231008144420766

解决方法

image-20231008144454250

1)悲观锁:假定每次并发都会冲突,所以干脆给操作整体加锁,将并发改为同步执行。 可以通过 synchronized 关键字实现。 优点是实现简单,缺点是严重影响性能(大家可以同时抢购)。

2)乐观锁:假定并发不一定会冲突,所以不加锁,而是通过判断数据是否在查出来之后被其他线程修改过,来决定是否允许操作。

乐观锁主要有版本号法和 CAS 两种实现方式。

版本号法:

给数据增加一个版本号字段,每次修改操作版本号 + 1,就可以通过版本号来判断数据是否有被修改。

image-20231008144613223

CAS 是对乐观锁的简化,即直接用一个每次都会查询和更新的字段来代替版本号,比如库存 stock 字段:

image-20231008144717818

优点:性能好

缺点:存在成功率低的问题(很多人查到的版本号是一样的,结果只能有一个人操作成功),可以使用分段锁来改进。比如将 100 个库存分为 10 份,大家分别抢这 10 份。

 

对于以上这种场景,其实不用判断 stock 是否变化,可以直接判断 stock > 0,从而保障成功率。

boolean success = seckillVoucherService.update()
          .setSql("stock= stock -1")
          .eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0

一人一单

优惠券或者秒杀活动的目的是为了吸引新用户,因此不能让一个用户把所有东西都抢走了。所以需要额外判断用户当前下单数是否 > 0。

这一步操作在多线程场景下依然会出现问题:新用户第一次进来同时抢 10 次,结果判断下单数都是 0,然后就都抢成功了。

所以还是需要加锁,因为订单是新创建的数据,所以无法使用乐观锁,使用悲观锁实现。

单机实现

单机部署后端服务器时,可以使用 Java 自带的 Synchronized 关键字作为悲观锁。 要注意以下几个细节问题:

1)synchronized 锁的范围不能太大,不能锁住整个对象,会严重影响性能。因为是一人一单,所以可以每个用户一把独立的锁。 注意,锁住对象时要用 toString.intern,保证同 id 的用户始终是同一个对象:

synchonized(userId.toString().intern()) {
   xxx
}

2)synchonized 必须在使用 @Transactional 注解的方法外层使用,因为 @Transactional 是使用动态代理,在方法执行结束后才提交事务。如果把 synchronized 写在事务方法内,提交事务前锁已经释放,但此时数据还未更新,其他线程依然能获取锁并顺利执行。 3)调用事务方法时不能用 this 对象,因为 @Transactional 注解实际上是调用 Spring 生成的代理对象的方法,如果调用 this 对象的方法会无法使用事务功能,所以要获取代理对象并调用。

初步代码:增加一人一单逻辑

@Override
public Result seckillVoucher(Long voucherId) {
   // 1.查询优惠券
   SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
   // 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.一人一单逻辑
   // 5.1.用户id
   Long userId = UserHolder.getUser().getId();
   int 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).update();
   if (!success) {
       //扣减库存
       return Result.fail("库存不足!");
  }
   //7.创建订单
   VoucherOrder voucherOrder = new VoucherOrder();
   // 7.1.订单id
   long orderId = redisIdWorker.nextId("order");
   voucherOrder.setId(orderId);
   voucherOrder.setUserId(userId);
   // 7.3.代金券id
   voucherOrder.setVoucherId(voucherId);
   save(voucherOrder);
   return Result.ok(orderId);
}

存在问题:现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作

注意:在这里提到了非常多的问题,我们需要慢慢的来思考,首先我们的初始方案是封装了一个createVoucherOrder方法,同时为了确保他线程安全,在方法上添加了一把synchronized 锁

@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
        // 5.1.查询订单
       int 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") // set stock = stock - 1
              .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 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);
       // 7.返回订单id
       return Result.ok(orderId);
}

,但是这样添加锁,锁的粒度太粗了,在使用锁过程中,控制锁粒度 是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的粒度,以下这段代码需要修改为: intern() 这个方法是从常量池中拿到数据,如果我们直接使用userId.toString() 他拿到的对象实际上是不同的对象,new出来的对象,我们使用锁必须保证锁必须是同一把,所以我们需要使用intern()方法

@Transactional
public  Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
        // 5.1.查询订单
       int 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") // set stock = stock - 1
              .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 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);
       // 7.返回订单id
       return Result.ok(orderId);
  }
}

但是以上代码还是存在问题,问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:如下:

在seckillVoucher 方法中,添加以下逻辑,这样就能保证事务的特性,同时也控制了锁的粒度

image-20231008150649381

但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务

       Long userId = UserHolder.getUser().getId();
       synchronized (userId.toString().intern()) {
           IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
           return proxy.createVoucherOrder(voucherId);
      }

分布式实现

Synchronized 关键字只对单个 JVM 有效,多机部署时还是可能会同时有多个不同 JVM 的线程访问已加锁的方法。

如下图:

image-20231008150926964

 

因此,我们不能把锁存储到单个服务器上,而是应该使用一个集中的存储来管理锁,所有进程都能读到它。 这就需要分布式锁。

本文作者:万事胜意k

本文链接:https://www.cnblogs.com/ysk0904/p/17749131.html

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   万事胜意k  阅读(86)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· C#/.NET/.NET Core技术前沿周刊 | 第 29 期(2025年3.1-3.9)
· 从HTTP原因短语缺失研究HTTP/2和HTTP/3的设计差异
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起