day05-优惠券秒杀01

打印 上一主题 下一主题

主题 513|帖子 513|积分 1539

功能03-优惠券秒杀01

4.功能03-优惠券秒杀

4.1全局唯一ID

4.1.1全局ID生成器

每个店铺都可以发布优惠券:
当用户抢购时,就会生成订单,并保存到tb_voucher_order这张表中。订单表如果使用数据库的自增id就存在一些问题:

  • id的规律性太明显:用户可以根据id猜测一些信息,从而非法得到数据
  • 受单表数据量的限制:由于单张表的数据限制,需要进行分表,而如果每张表都采取自增长,容易出现id重复,会影响订单之后的业务,比如说售后服务(因为售后服务一般是根据订单id来进行的)
解决方案:使用全局ID生成器。
(1)全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具(也称为分布式唯一ID),一般要满足下列特性:

  • 唯一性
  • 高可用
  • 高性能
  • 递增性
  • 安全性
(2)全局唯一ID生成策略:

  • UUID
  • Redis自增
  • snowflake算法
  • 数据库自增
(3)我们这里使用redis作为全局唯一生成器的实现方案,原因如下:

  • redis是独立于数据库之外的,它只有一个,当所有人都来访问redis时,它的自增一定是唯一的(唯一性)
  • 使用redis的集群、主从方案、哨兵功能,可以维持它的高可用性(高可用)
  • redis具有高性能(高性能)
  • 可以使用redis的String类型,具有自增性(如:incr命令)(自增性)
    Redis Incr 命令将 key 中储存的数字值增一
    如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作

  • 为了增加id的安全性,我们不会直接使用自增redis自增的id,而是拼接一些其他信息:(安全性)
    ID构造:时间戳+计数器(使用long类型,共八字节,64bit)

    • 符号位:1bit,永远为0
    • 时间戳:31bit,以秒为单位,可以使用约69年
    • 序列号:32bit,秒内的计数器,这样可以支持每秒产生2^32个不同的ID


4.2Redis实现全局唯一ID

(1)创建全局ID生成器RedisIdWorker
  1. package com.hmdp.utils;
  2. import org.springframework.data.redis.core.StringRedisTemplate;
  3. import org.springframework.stereotype.Component;
  4. import javax.annotation.Resource;
  5. import java.time.LocalDateTime;
  6. import java.time.ZoneOffset;
  7. import java.time.format.DateTimeFormatter;
  8. /**
  9. * @author 李
  10. * @version 1.0
  11. */
  12. @Component
  13. public class RedisIdWorker {
  14.     //开始时间戳(1970-01-01T00:00:00到2022-01-01T00:00:00的秒数)
  15.     private static final long BEGIN_TIMESTAMP = 1640995200L;
  16.     //序列号的位数
  17.     private static final int COUNT_BITS = 32;
  18.     @Resource
  19.     private StringRedisTemplate stringRedisTemplate;
  20.     //public static void main(String[] args) {
  21.     //    //开始时间
  22.     //    LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
  23.     //    //得到1970-01-01T00:00:00Z.到指定时间为止的具体秒数
  24.     //    long second = time.toEpochSecond(ZoneOffset.UTC);
  25.     //    System.out.println(second);//1640995200L
  26.     //}
  27.     public long nextId(String keyPrefix) {
  28.         //1.生成时间戳
  29.         LocalDateTime now = LocalDateTime.now();
  30.         long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
  31.         //开始时间到当前时间的 时间戳
  32.         long timeStamp = nowSecond - BEGIN_TIMESTAMP;
  33.         //2.生成序列号(keyPrefix代表业务前缀)
  34.         /*
  35.          * Redis的 Incr命令将 key 中储存的数字值增1,如果key不存在,那么key的值会先被初始化为0,然后再执行INCR操作。
  36.          * 根据这个特性,我们每一天拼接不同的日期,当做key。也就是说同一天下单采用相同的key,不同天下单采用不同的key
  37.          * 这种方法不仅可以防止订单号使用完(redis的的自增最多可以有2^64位,我们采取其中32位作计数器),
  38.          * 还可以根据不同的日期,统计该天的订单数量
  39.          */
  40.         //2.1获取当前的日期(精确到天)
  41.         String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
  42.         //2.2做自增长
  43.         Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
  44.         //3.拼接并返回
  45.         //将时间戳左移32位,空出来的右边32位使用count填充,共64位
  46.         return timeStamp << COUNT_BITS | count;
  47.     }
  48. }
复制代码
关于countdownlatch
countdownlatch名为信号枪:主要的作用是同步协调在多线程的等待于唤醒问题。如果没有CountDownLatch ,由于程序是异步的,当异步程序没有执行完时,主线程可能就已经执行完了。如果期望的是分线程全部走完之后,主线程再走,此时就需要使用到CountDownLatch。CountDownLatch 中有两个最重要的方法:1.countDown  2.await
await 方法是阻塞方法,使用await可以让main线程阻塞,当CountDownLatch 内部维护的变量变为0时,就不再阻塞,直接放行。那么什么时候CountDownLatch   维护的变量变为0 呢?我们只需要调用一次countDown ,内部变量就减少1。
根据这个性质,让分线程和变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。
测试结果:
查看redis中的数据:对应的key的自增值已经变为30000,说明生成了3w个id
4.2.1总结

全局唯一ID生成策略:

  • UUID
  • Redis自增
  • snowflake算法
  • 数据库自增(使用一张表来单独记录id)
Redis自增ID策略:

  • 每天一个key,方便统计订单量
  • ID结构:时间戳+计数器
4.2实现优惠券秒杀下单

4.2.1需求分析&业务流程

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:
这两张券对应的数据库表结构如下:

  • tb_voucher:(优惠券表)优惠券的基本信息、优惠金额、使用规则等(包括平价券和秒杀券)

  • tb_seckill_voucher:(秒杀优惠券表)优惠券的库存、开始抢购时间、结束抢购时间。秒杀优惠券才需要填写这些信息。

要求在店铺详情中实现下单购买秒杀券:
下单时需要判断两点:

  • 秒杀是否开始或者结束,如果尚未开始或者已经结束则无法下单
  • 秒杀券的库存是否充足,不足则无法下单
优惠券订单表结构:
业务流程分析:
4.2.2代码实现

(1)优惠券订单实体:VoucherOrder.java
  1. @Resource
  2. private RedisIdWorker redisIdWorker;
  3. private ExecutorService es = Executors.newFixedThreadPool(500);
  4. @Test
  5. public void testIdWorker() throws InterruptedException {
  6.     CountDownLatch latch = new CountDownLatch(300);
  7.     //线程,生成100个id
  8.     Runnable task = () -> {
  9.         for (int i = 0; i < 100; i++) {
  10.             long id = redisIdWorker.nextId("order");
  11.             System.out.println("id" + id);
  12.         }
  13.         latch.countDown();
  14.     };
  15.     long start = System.currentTimeMillis();
  16.     //共执行300次任务
  17.     for (int i = 0; i < 300; i++) {
  18.         es.submit(task);
  19.     }
  20.     //让所有线程执行完才计时
  21.     latch.await();
  22.     long end = System.currentTimeMillis();
  23.     System.out.println("共用时=" + (end - start));
  24. }
复制代码
(2)mapper接口
  1. package com.hmdp.entity;
  2. import com.baomidou.mybatisplus.annotation.IdType;
  3. import com.baomidou.mybatisplus.annotation.TableId;
  4. import com.baomidou.mybatisplus.annotation.TableName;
  5. import lombok.Data;
  6. import lombok.EqualsAndHashCode;
  7. import lombok.experimental.Accessors;
  8. import java.io.Serializable;
  9. import java.time.LocalDateTime;
  10. /**
  11. * 优惠券订单实体
  12. *
  13. * @author 李
  14. * @version 1.0
  15. */
  16. @Data
  17. @EqualsAndHashCode(callSuper = false)
  18. @Accessors(chain = true)
  19. @TableName("tb_voucher_order")
  20. public class VoucherOrder implements Serializable {
  21.     private static final long serialVersionUID = 1L;
  22.     //主键
  23.     @TableId(value = "id", type = IdType.INPUT)
  24.     private Long id;
  25.     //下单的用户id
  26.     private Long userId;
  27.     //购买的代金券id
  28.     private Long voucherId;
  29.     //支付方式 1:余额支付;2:支付宝;3:微信
  30.     private Integer payType;
  31.     //订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款
  32.     private Integer status;
  33.     //下单时间
  34.     private LocalDateTime createTime;
  35.     //支付时间
  36.     private LocalDateTime payTime;
  37.     //核销时间
  38.     private LocalDateTime useTime;
  39.     //退款时间
  40.     private LocalDateTime refundTime;
  41.     //更新时间
  42.     private LocalDateTime updateTime;
  43. }
复制代码
(3)IVoucherOrderService 服务类
  1. package com.hmdp.mapper;
  2. import com.hmdp.entity.VoucherOrder;
  3. import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4. /**
  5. *  Mapper 接口
  6. *
  7. * @author 李
  8. * @version 1.0
  9. */
  10. public interface VoucherOrderMapper extends BaseMapper<VoucherOrder> {
  11. }
复制代码
(4)VoucherOrderServiceImpl 服务实现类
  1. package com.hmdp.service;
  2. import com.hmdp.dto.Result;
  3. import com.hmdp.entity.VoucherOrder;
  4. import com.baomidou.mybatisplus.extension.service.IService;
  5. /**
  6. *  服务类
  7. *
  8. * @author 李
  9. * @version 1.0
  10. */
  11. public interface IVoucherOrderService extends IService<VoucherOrder> {
  12.     Result seckillVoucher(Long voucherId);
  13. }
复制代码
(5)控制器 VoucherOrderController
  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.RedisIdWorker;
  10. import com.hmdp.utils.UserHolder;
  11. import org.springframework.stereotype.Service;
  12. import org.springframework.transaction.annotation.Transactional;
  13. import javax.annotation.Resource;
  14. import java.time.LocalDateTime;
  15. /**
  16. * 服务实现类
  17. *
  18. * @author 李
  19. * @version 1.0
  20. */
  21. @Service
  22. public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
  23.     @Resource
  24.     private ISeckillVoucherService seckillVoucherService;
  25.     @Resource
  26.     private RedisIdWorker redisIdWorker;
  27.     @Override
  28.     @Transactional
  29.     public Result seckillVoucher(Long voucherId) {
  30.         //根据id查询优惠券信息
  31.         SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
  32.         if (voucher == null) {
  33.             return Result.fail("该优惠券不存在,请刷新!");
  34.         }
  35.         //判断秒杀券是否在有效时间内
  36.         //若不在有效期,则返回异常结果
  37.         if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
  38.             return Result.fail("秒杀尚未开始!");
  39.         }
  40.         if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
  41.             return Result.fail("秒杀已经结束!");
  42.         }
  43.         //若在有效期,判断库存是否充足
  44.         if (voucher.getStock() < 1) {//库存不足
  45.             return Result.fail("秒杀券库存不足!");
  46.         }
  47.         //库存充足,则扣减库存(操作秒杀券表)
  48.         boolean success = seckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).update();
  49.         if (!success) {//操作失败
  50.             return Result.fail("秒杀券库存不足!");
  51.         }
  52.         //扣减库存成功,则创建订单,返回订单id
  53.         VoucherOrder voucherOrder = new VoucherOrder();
  54.         //设置订单id
  55.         long orderId = redisIdWorker.nextId("order");
  56.         voucherOrder.setId(orderId);
  57.         //设置用户id
  58.         Long userId = UserHolder.getUser().getId();
  59.         voucherOrder.setUserId(userId);
  60.         //设置代金券id
  61.         voucherOrder.setVoucherId(voucherId);
  62.         //将订单写入数据库(操作优惠券订单表)
  63.         this.save(voucherOrder);
  64.         //返回订单id
  65.         return Result.ok(orderId);
  66.     }
  67. }
复制代码
(6)测试,在前端页面点击购买,显示抢购成功,订单号如下:
优惠券订单表tb_voucher_order成功插入一条数据:
对应的秒杀券的库存减一:
4.3超卖问题

4.3.1问题分析

4.2的代码并没有考虑到并发的问题:当有多个用户同时对一个秒杀券进行抢购,并发会让系统出现超卖问题:即卖出的秒杀券数量>实际的秒杀券库存
我们使用jemeter测试:
运行上述设置,测试结果如下:

  • 秒杀券表中,id=2的秒杀券库存出现了负数:

  • 订单表中,对应的数量为104单,但是对应的秒杀券的库存最多只有100张。也就是说:出现了超卖问题

出现超卖问题的原因:
4.2的代码只是简单地进行库存判断,并没有考虑到线程并发。当有多个线程同时去判断库存时,如果当前库存大于0,则这些线程都会去进行库存扣减,从而发生并发安全问题:
4.3.2解决方案

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
这里使用乐观锁方案。乐观锁的关键是判断之前查询到的数据是否有被修改过:
常见的方式有两种:
(1)版本号法:
表中设置一个版本号字段,线程在修改表之前,先查询一次版本号。对数据库表操作时,再查询一次版本号,如果值和之前的一致,说明此时表的数据在两次查询之间没有被修改过,我们就可以进行业务操作,并设置新的版本号。
update语句会对当前修改的行进行锁定操作(数据库有行级锁,不用担心一行记录被同时修改)。
因此,进行表修改时,由于数据库行锁,其他线程会等待数据修改后再更新库存
sql执行是交给数据库的,如果开启了事务的话,就是两个事务的并发问题,此时将会启动两阶段封锁协议,保证事务并发安全

(2)CAS法:
这里为了简化,使用库存代替版本号,原理和方案1是一致的:线程在修改表之前,先查询一次库存的值。对数据库表操作时,再查询一次库存值,如果值和之前的一致,说明此时表的数据在两次查询之间没有被修改过,我们就可以进行业务操作。

CAS思想:Compare-And-Swap
CAS 有三个操作数:内存值 V、预期值 A、要修改的值 B。CAS 最核心的思路就是,仅当预期值 A 和当前的内存值 V 相同时,才将内存值修改为 B。
ABA问题
为了简便,这里使用方案2,但实际的业务还是建议使用版本法来避免其他问题。
4.3.3代码实现

(1)修改VoucherOrderServiceImpl,添加如下代码:
(2)测试:
清除之前的订单信息(tb_voucher_order):
还原tb_seckill_voucher表的测试数据:
然后使用jemeter进行测试:
测试结果:
券没有超卖,但是出现了新的问题:前几个请求中就出现了下单失败的情况,200个线程只有100-63=37个线程下单成功(理想情况下是100,即秒杀券全部卖出)
原因分析:这是因为,当有一个线程去修改数据时,其他很多的线程也来同时请求,它们都根据第一次查询的stock值去判断,发现stock值变化了,因此当第一个线程修改数据后,都没有去对数据进行操作),导致发生了库存充足,仍然抢不到券的情况(抢券失败率偏高)。
(3)改进:修改VoucherOrderServiceImpl,修改如下划线处:
分析:线程A获取stock值,通过业务判断,然后去对库存值进行update操作;因为update语句会对当前修改的行进行锁定操作,因此,进行表修改时,由于数据库行锁,其他线程会等待数据修改后再更新库存。当等待后获取锁,将where stock > 0作为update条件,这时,只要stock不小于0就仍可以售券。
update where 是先走where去拿锁,拿不到就阻塞,等拿到锁了再去执行update
再次对其测试:可以看到200个线程并发,100张秒杀券全部售完。并且没有出现超卖现象,同时解决了库存充足却抢不到券的问题。
4.3.4总结

超卖这样的线程安全问题,解决方案有哪些?

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

    • 优点:简答粗暴
    • 缺点:性能一般

  • 乐观锁:不加锁,在更新时判断是否有其他线程在修改

    • 优点:性能好
    • 缺点:成功率低

4.4一人一单

4.5分布式锁

4.6Redis优化秒杀

4.7Redis消息队列实现异步秒杀


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

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

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

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

本版积分规则

何小豆儿在此

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

标签云

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