• Redis解决优惠券秒杀


    虽然本文是针对黑马点评的优惠券秒杀业务的实现,但是是适用于各种抢购活动,保证线程安全。

    摘要:本文先讲了抢购问题,指出其中会出现的多线程问题,提出解决方案采用观锁和乐观锁两种方式进行实现,然后发现在抢购过程中容易出现一人多单现象,为保证优惠券不会被【黄牛】抢到,因此我们在保证多线程安全的情况下实现了一人一单业务,最后指出本文的实现在集群情况下的不足之处。在本专栏的另一篇文章中提出集群或者分布式系统的解决方案


    【前端页面】

     在代金券发放后,多个用户会进行优惠券抢购,在抢购时需要判断两点:

    下单时需要判断两点:

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

    下单核心逻辑分析:

    当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件

    比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。

    【逻辑图】

     【代码实现】

    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. // 3.判断秒杀是否已经结束
    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. //5,扣减库存
    21. boolean success = seckillVoucherService.update()
    22. .setSql("stock= stock -1")
    23. .eq("voucher_id", voucherId).update();
    24. if (!success) {
    25. //扣减库存
    26. return Result.fail("库存不足!");
    27. }
    28. //6.创建订单
    29. VoucherOrder voucherOrder = new VoucherOrder();
    30. // 6.1.订单id
    31. long orderId = redisIdWorker.nextId("order");
    32. voucherOrder.setId(orderId);
    33. // 6.2.用户id
    34. Long userId = UserHolder.getUser().getId();
    35. voucherOrder.setUserId(userId);
    36. // 6.3.代金券id
    37. voucherOrder.setVoucherId(voucherId);
    38. save(voucherOrder);
    39. return Result.ok(orderId);
    40. }

    【分析代码】

    • 从上述的逻辑图中我们可以知道,要扣减库存,并且要保存订单,因此需要事务业务
    • 在第4步判断库存是否充足处,会出现多线程问题。出现订单超卖现象

    多线程问题

    问题代码如下:

    1. if (voucher.getStock() < 1) {
    2. // 库存不足
    3. return Result.fail("库存不足!");
    4. }
    5. //5,扣减库存
    6. boolean success = seckillVoucherService.update()
    7. .setSql("stock= stock -1")
    8. .eq("voucher_id", voucherId).update();
    9. if (!success) {
    10. //扣减库存
    11. return Result.fail("库存不足!");
    12. }

     【采用锁】解决上述超卖问题。

    悲观锁:

    悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等

    乐观锁:

    乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas

    乐观锁的典型代表:就是cas,利用cas进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值

    其中do while 是为了在操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次。

    修改代码方案

    我们的乐观锁保证stock大于0 即可,如果查询逻辑stock不能保证大于0,则会出现 success为false我们在后文进行判断即可。

    1. boolean success = seckillVoucherService.update()
    2. .setSql("stock= stock -1")
    3. .eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0
    4. if (!success) {
    5. //扣减库存
    6. return Result.fail("库存不足!");
    7. }

    代码写到这里,我们就解决了多线程安全问题(优惠券超卖)


    一人一单

    但是我们在检查数据库数据时,我们发现一个人可以购买多个优惠券。

    因此我们可以在抢购前,判断该用户是否已经购买过该优惠券,如果购买过则直接返回。

    【逻辑图】红框内的是新增逻辑。

    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. // 3.判断秒杀是否已经结束
    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. // 5.一人一单逻辑
    21. // 5.1.用户id
    22. Long userId = UserHolder.getUser().getId();
    23. int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    24. // 5.2.判断是否存在
    25. if (count > 0) {
    26. // 用户已经购买过了
    27. return Result.fail("用户已经购买过一次!");
    28. }
    29. //6,扣减库存
    30. boolean success = seckillVoucherService.update()
    31. .setSql("stock= stock -1")
    32. .eq("voucher_id", voucherId).update();
    33. if (!success) {
    34. //扣减库存
    35. return Result.fail("库存不足!");
    36. }
    37. //7.创建订单
    38. VoucherOrder voucherOrder = new VoucherOrder();
    39. // 7.1.订单id
    40. long orderId = redisIdWorker.nextId("order");
    41. voucherOrder.setId(orderId);
    42. voucherOrder.setUserId(userId);
    43. // 7.3.代金券id
    44. voucherOrder.setVoucherId(voucherId);
    45. save(voucherOrder);
    46. return Result.ok(orderId);
    47. }

     【分析代码】---仍然会出现多线程问题。

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

    【注意事项】

    • 事务应该包含在锁的内部。
    • 锁的粒度,锁的对象应该是用户级别的,而不是整个抢购优惠券级别的,因此我们不会直接将synchronized加到方法上。
    • 锁对象的细节处理,使用userId.toString().intern()保证对象唯一。
    • 获取代理对象调用切入事务
    1. package com.hmdp.service.impl;
    2. import com.hmdp.dto.Result;
    3. import com.hmdp.entity.SeckillVoucher;
    4. import com.hmdp.entity.VoucherOrder;
    5. import com.hmdp.mapper.VoucherOrderMapper;
    6. import com.hmdp.service.ISeckillVoucherService;
    7. import com.hmdp.service.IVoucherOrderService;
    8. import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
    9. import com.hmdp.utils.RedisWorker;
    10. import com.hmdp.utils.UserHolder;
    11. import org.springframework.aop.framework.AopContext;
    12. import org.springframework.stereotype.Service;
    13. import org.springframework.transaction.annotation.Transactional;
    14. import javax.annotation.Resource;
    15. import java.time.LocalDateTime;
    16. /**
    17. *

    18. * 服务实现类
    19. *

    20. *
    21. * @author msf
    22. * @since 2022-10-29
    23. */
    24. @Service
    25. public class VoucherOrderServiceImpl extends ServiceImpl implements IVoucherOrderService {
    26. @Resource
    27. private ISeckillVoucherService seckillVoucherService;
    28. @Resource
    29. private RedisWorker redisWorker;
    30. @Override
    31. public Result seckillVoucher(Long voucherId) {
    32. // 1. 查询优惠券信息
    33. SeckillVoucher voucherOrder = seckillVoucherService.getById(voucherId);
    34. // 2.判断秒杀是否开始
    35. if (voucherOrder.getBeginTime().isAfter(LocalDateTime.now())) {
    36. return Result.fail("抢购尚未开始");
    37. }
    38. if (voucherOrder.getEndTime().isBefore(LocalDateTime.now())) {
    39. return Result.fail("抢购已经结束");
    40. }
    41. // 3.判断库存是否充足
    42. if (voucherOrder.getStock() < 1) {
    43. return Result.fail("您来晚了,票已被抢完");
    44. }
    45. Long userId = UserHolder.getUser().getId();
    46. // 事务应该在synchronized里面
    47. synchronized (userId.toString().intern()) {
    48. IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    49. return proxy.createVoucherOrder(voucherId,userId);
    50. }
    51. }
    52. @Transactional
    53. public Result createVoucherOrder(Long voucherId,Long userId) {
    54. // 4. 一人一单逻辑
    55. // 4.1 根据优惠券id和用户id查询订单
    56. Integer count = query().eq("user_id", userId)
    57. .eq("voucher_id", voucherId).count();
    58. // 4.2 订单存在,直接返回
    59. if (count > 0) {
    60. return Result.fail("用户已经购买一次");
    61. }
    62. // 5. 扣减库存
    63. boolean success = seckillVoucherService.update()
    64. .setSql("stock = stock - 1")
    65. .gt("stock", 0)
    66. .eq("voucher_id", voucherId).update();
    67. if (!success) {
    68. return Result.fail("库存不足");
    69. }
    70. // 6.创建订单
    71. VoucherOrder order = new VoucherOrder();
    72. // 6.1 设置id
    73. order.setId(redisWorker.nextId("order"));
    74. // 6.2 设置订单id
    75. order.setVoucherId(voucherId);
    76. // 6.3 设置用户id
    77. order.setUserId(userId);
    78. save(order);
    79. // 7. 返回订单id
    80. return Result.ok(order);
    81. }
    82. }

    展望

    虽然我们利用锁和事务解决单体系统下的秒杀功能,但是现在的业务一般是在集群和分布式系统协作完成,因此我们在测试系统在集群部署时,仍会出现一人多单问题,稍后我们将更新文章,分析问题出现原因,并利用分布式锁的方式解决该问题。

  • 相关阅读:
    Jest + React 单元测试最佳实践
    登录怎么实现的,密码加密了嘛?使用明文还是暗文,知道怎么加密嘛?
    Codesys 数据结构:1.2.4 扩展数据类型之联合体(UNION) 类型详解
    Transformers库总体介绍
    【软件设计师 - 一周通关】1.考试介绍
    【K8S系列】深入解析k8s网络插件—Cilium
    Navicat 蝉联 DBTA 三项大奖 | 最佳数据库管理软件公司、引领潮流产品以及读者选择奖
    c#winform根据邮箱地址和密码一键发送email
    传言称 iPhone 16 Pro 将支持 40W 快速充电和 20W MagSafe
    java计算时间差 (日时分秒)
  • 原文地址:https://blog.csdn.net/abc123mma/article/details/127590738