优惠券秒杀

1.全局唯一ID

1.1 全局ID生成器

每个店铺都可以发布优惠券

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

  • id的规律太明显
  • 受单表数据量的限制

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

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息:

ID的组成部分:

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

1.2 全局ID生成器(代码实现)

@Component
public class RedisIdWorker {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    private final long BEGIN_TIMESTAMP = 1672531200L;
    private final long COUNT_BITS = 32;
    public long nextId(String keyPrefix){
        //1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond-BEGIN_TIMESTAMP;
        //2.生成序列号
        //2.1获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //2.2自增长
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":"+date);
        //3.拼接并返回
        return timestamp << COUNT_BITS | count;
    }
}

1.3 Redis自增ID策略

  • 每天一个key,方便统计订单量
  • ID构造是 时间戳+计时器

2.添加优惠券

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,特价券需要秒杀抢购:

表关系如下:

  • tb_voucher:优惠券的基本信息,优惠金额、使用规则等
  • tb_seckill_voucher:优惠券的库存、开始抢购时间、结束抢购时间。特价优惠券才需要填写这些信息
    数据库创建:
    平价券
    特价券

Controller层

@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
    voucherService.save(voucher);
    return Result.ok(voucher.getId());
}
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_voucher")
public class Voucher implements Serializable {
    private static final long serialVersionUID = 1L;
    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    /**
     * 商铺id
     */
    private Long shopId;
    /**
     * 代金券标题
     */
    private String title;

    /**
     * 副标题
     */
    private String subTitle;
    /**
     * 使用规则
     */
    private String rules;
    /**
     * 支付金额
     */
    private Long payValue;
    /**
     * 抵扣金额
     */
    private Long actualValue;
    /**
     * 优惠券类型
     */
    private Integer type;
    /**
     * 优惠券类型
     */
    private Integer status;
    /**
     * 库存
     */
    @TableField(exist = false)
    private Integer stock;
    /**
     * 生效时间
     */
    @TableField(exist = false)
    private LocalDateTime beginTime;
    /**
     * 失效时间
     */
    @TableField(exist = false)
    private LocalDateTime endTime;
    /**
     * 创建时间
     */
    private LocalDateTime createTime;
    /**
     * 更新时间
     */
    private LocalDateTime updateTime;
}

postman测试

3.实现秒杀下单

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足则无法下单
    @Service
    public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
        @Resource
        ISeckillVoucherService seckillVoucherService;
        @Resource
        RedisIdWorker redisIdWorker;
        @Override
        @Transactional
        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);
        }
    }

4.库存超卖问题


超卖现象:在线程1扣减库存操作之前,有无数个线程来进行查询,查询到的还是扣减之前的操作,会产生库存为负的情况

4.1 解决方案

超卖问题是典型的多线程安全问题,针对这一问题的常见问题就加锁;
两种锁:

  • 悲观锁:
    认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行.
    • 例如:Sysnchronized,Lock都属于悲观锁
  • 乐观锁:
    认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其他线程对数据做了修改的。
    • 如果没有修改则认为是安全的,自己才更新数据。
    • 如果已经被其他线程修改说明发生了安全问题,此时可以重试或异常。

乐观锁的关键就是判断之前查询的数据是否有被修改过,常见的方式有两种:

  • 版本号法:
    数据结构
    流程图
  • CAS法:(先比较再SET)
    数据结构
    流程图

总结:\

  1. 悲观锁: 添加同步锁,让线程串行执行
  • 优点: 简单粗暴
  • 缺点: 性能一般
  1. 乐观锁: 不加锁,在更新时判断是否有其他线程在修改
  • 优点: 性能好
  • 缺点: 存在成功率低的问题

5. 一人一单

需求: 修改秒杀业务,要求同一个优惠券,一个用户只能下一单
流程图
悲观锁(synchronized)实现:

5.1 方式一(synchronized方法)

@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
    //6.一人一单
    Long userId = UserHolder.getUser().getId();
    //6.1 查询订单
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    //6.2 判断是否存在
    if (count > 0) {
        //用户已经购买过
        return Result.fail("用户已经购买过一次");
    }
    //5.扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock -1")
            .eq("voucher_id", voucherId).gt("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);
    return Result.ok(orderId);
}

将synchronized加载到方法的声明中,synchronized默认使用this添加锁,会导致任何用户来了都会加锁,导致整个方法变成串行执行,使得性能变差,目的只需要给同一个用户加锁,解决同一个用户的并发问题,则引申出第二种加锁的方式优化。

5.1 方式二(synchronized关键字-方法内部)

@Transactional
public Result createVoucherOrder(Long voucherId) {
    //6.一人一单
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
        //6.1 查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        //6.2 判断是否存在
        if (count > 0) {
            //用户已经购买过
            return Result.fail("用户已经购买过一次");
        }
        //5.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId).gt("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);
        return Result.ok(orderId);
    }
}

方法内部加锁,先释放锁才会提交事务;因为事务是由Spring管理的,执行过程是在方法执行完成以后才由Spring做的提交,当锁释放以后,未提交事务之前,会有其他的线程进入,如果此时查询订单可能会出现并发安全问题。

补充:

userId.toString().intern()

intern() 方法返回字符串对象的规范化表示形式。

它遵循以下规则:对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。

取自常量池可以保证锁获取的用户是唯一的

5.1 方式三(synchronized关键字-引入函数外侧)

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("库存不足!");
    }
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
        //获取代理对象(事务)
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId);
    }
}

 @Transactional
public Result createVoucherOrder(Long voucherId) {
    //6.一人一单
    Long userId = UserHolder.getUser().getId();
    //6.1 查询订单
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    //6.2 判断是否存在
    if (count > 0) {
        //用户已经购买过
        return Result.fail("用户已经购买过一次");
    }
    //5.扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock -1")
            .eq("voucher_id", voucherId).gt("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);
    return Result.ok(orderId);
}

初始方法:

synchronized (userId.toString().intern()) {
          //获取代理对象(事务)
          return this.createVoucherOrder(voucherId);
      }

调用会有事务失效问题,Spring事务生效是因为对当前类使用了动态代理,拿到了当前类的代理对象,使用代理对象做了事务代理。而现在使用的是非代理对象(目标对象),是没有事务功能的

解决方案:

synchronized (userId.toString().intern()) {
          //获取代理对象(事务)
          IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
          return proxy.createVoucherOrder(voucherId);
      }

6. 一人一单的并发安全问题

通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。

  1. 我们将服务启动分为两部分,端口号分别为8081和8082
  2. 然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载平衡
    现在,用户请求会在这个节点上负载均衡,再次测试下是否存在线程安全问题。

同一个用户通过两个服务器访问同一家店铺的同一个优惠券,发现都请求成功,一个用户购买到了两个优惠券,说明锁没有锁住。

6.1 问题产生原因


在集群模式下或分布式系统下,有多个jvm的存在,每个jvm下都有自己的锁,导致每一个锁都可以有一个线程获取,于是就会出现并行运行,可能会出现安全问题
解决方案:让多个jvm使用同一把锁(跨jvm锁-分布式锁)

优惠券秒杀优化