点评项目——优惠卷秒杀

2023.12.8

        本章将用redis实现优惠劵秒杀下单的功能。

构建全局唯一ID

        我们都有在店铺中抢过优惠券,优惠券也是一种商品,当用户抢购时,就会生成订单并保存到数据库对应的表中,而订单表如果使用数据库自增ID就存在一些问题:

  • ID的规律性太明显:如果简单地使用数据库自增ID,很容易被人看出规律,比如今天ID是10,明天ID是110,那么就可以猜出这一天的订单量是100,这明显不合适。
  • 受单表数据量的限制:随着订单量的增加,一张表终究是存不下那么多订单的,需要将数据库的表拆分成多张表,但是这几张表的id不能重复,因为用户可能需要凭着订单id查询售后相关的业务,所以这里id还需要保证唯一性。

        这里就引出要介绍的全局ID生成器了。全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具,满足唯一性、高可用、高性能、递增性、安全性的特点。

        这里我们使用 redis自增+拼接其他信息 的策略来生成全局唯一ID,即使用一个64bit的二进制数来充当全局ID,这64bit分为以下三部分:

  • 符号位:1bit,永远为0,代表ID为正值。
  • 时间戳:31bit,以秒为单位,可以使用69年。(2的31次方秒大概有68年多)
  • 序列号:32bit,秒内的计数器,最大可以支持每秒产生2^32个不同ID,就算每秒全中国人一起生成id也是足够的。

下面根据该策略来生成全局唯一ID:

public class RedisIdWorker {
    //开始时间戳
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    //序列号的位数
    private static final int COUNT_BITS = 32;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    public long nextId(String keyPrefix){
        //1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestemp = 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 timestemp << COUNT_BITS | count;
    }

}

        因为时间戳返回值是long,所以最后拼接是用位运算拼接的,不能简单的用字符串拼接。

另外全局ID生成策略还有:UUID、雪花算法等等... 等有时间再去补。

实现秒杀下单

        实现秒杀下单时需要考虑两个点:

  • 秒杀活动是否开始或者结束,如果不在秒杀活动范围期间则无法下单。
  • 秒杀券是否有库存,没库存了也不允许下单。

        下面看一下整个代码的流程图:

        即先判断一下满不满足下单要求,满足则扣减库存并创建订单,代码如下:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private RedisIdWorker redisIdWorker;
    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @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代金券
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        //7.返回订单id
        return Result.ok(orderId);
    }
}

超卖问题及解决办法

        上述秒杀下单存在线程安全问题,在高并发场景下,可能会有多个线程同时对临界资源进行操作,这里的临界资源就是秒杀券的库存,这里使用jmeter来模拟一下高并发的场景:

        首先秒杀券的库存为100,我们定义200个线程进行秒杀券的下单:

jmeter启动! 观察一下秒杀券的库存,发现是-9,这就是超卖问题。

        这就是并发场景存在的安全问题,多个线程同时对临界资源进行访问就会存在这种问题,所以我们可以对临界资源加锁来解决此线程安全问题,锁又可以分为两种锁:

  • 悲观锁:悲观锁比较悲观,认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。常见的悲观锁有Synchronized、Lock等。
  • 乐观锁:乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候判断一下有没有其他线程对数据进行了修改,如果没有修改的话自己才能操作。

        悲观锁比较简单粗暴,但是性能比乐观锁要差,这里我们只实现乐观锁。

乐观锁典型的实现方案:乐观锁会维护一个版本号字段,每次操作数据都会对版本号+1,再提交回数据时,会去校验版本号是否比之前大1 ,如果大1 说明除了自己没有其他人操作数据,则操作成功。否则就是其他人也在修改数据,操作失败。

        在本项目中,可以直接使用stock(库存)充当版本号字段,只要stock发生改变了就相当于有其他线程在操作数据。

        在jmeter的并发场景验证过程中,发现库存还有残余,并且大量线程的请求操作都失败了,这就是这种方案的弊端:成功率太低。  于是我们可以进一步的优化代码:只判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作。只需要改动库存扣减的代码:

        //5.满足条件,扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).gt("stock",0)//乐观锁,查询一下stock有无变化,变化了就不能做修改操作
                .update();

        这下就能完美解决超卖问题了。

一人一单

这一节信息量有点大,有点难顶。

        实际情况抢秒杀券的时候,通常是希望同一个用户对同一种秒杀券只能抢一次的,抢很多次的话那大概率就是黄牛了,所以我们需要限制一个用户只能下一单

        策略就是在判断库存充足的情况下:根据券id和用户id查询订单,如果订单存在,就需要限制该用户下单;不存在则可以下单。流程图更改为下图:

 在判断库存充足之后添加一人一单的代码:

        //一人只能下一单
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//查询订单数目
        if(count > 0){
            //用户已经购买过该秒杀券
            return Result.fail("用户已经购买过一次!");
        }

        上述存在线程安全问题:由于一人一单代码和扣减库存代码之间是有间隙的,如果黄牛开多线程抢优惠券,可能有多个线程同时通过一人一单的代码,那么同一用户依然可以抢多张优惠券,这显然不能解决问题。

        这里可以将一人一单代码和扣减库存代码提取到一个新方法createVoucherOrder中,然后使用悲观锁synchronized将其锁住确保这段方法一次只能有一个线程执行。

        createVoucherOrder代码为:

    @Transactional
    public synchronized Result createVoucherOrder(Long voucherId) {
        //一人只能下一单
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//查询订单数目
        if(count > 0){
            //用户已经购买过该秒杀券
            return Result.fail("用户已经购买过一次!");
        }

        //5.满足条件,扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).gt("stock",0)//乐观锁,查询一下stock有无变化,变化了就不能做修改操作
                .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
        voucherOrder.setUserId(userId);
        //6.3代金券
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        //7.返回订单id
        return Result.ok(orderId);
    }

        此时,这个锁的粒度太粗了,相当于所有线程都是串行执行,效率太低。我们希望的是锁住相同用户即可,不同用户没必要被锁住。因此我们可以使用用户id来加锁,减小加锁的范围:

    @Transactional
    public  Result createVoucherOrder(Long voucherId) {
        //一人只能下一单
        Long userId = UserHolder.getUser().getId();

        synchronized (userId.toString()) {
            int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//查询订单数目
            if (count > 0) {
                //用户已经购买过该秒杀券
                return Result.fail("用户已经购买过一次!");
            }

            //5.满足条件,扣减库存
            boolean success = seckillVoucherService.update()
                    .setSql("stock = stock - 1")
                    .eq("voucher_id", voucherId).gt("stock", 0)//乐观锁,查询一下stock有无变化,变化了就不能做修改操作
                    .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
            voucherOrder.setUserId(userId);
            //6.3代金券
            voucherOrder.setVoucherId(voucherId);
            save(voucherOrder);

            //7.返回订单id
            return Result.ok(orderId);
        }
    }

       此处有个小细节:我们希望同一用户id才加锁,但toString()函数底层其实是新new了一个对象的,也就是说就算两个用户id是一样的,tostring之后也是不同的对象,因此没法对其加锁。

        为了解决这个问题,可以使用字符串的一个方法:intern,它能够返回字符串对象的规范表示,它会去字符串常量池里寻找值相同的字符串,确保能够锁住相同的用户id:

    @Transactional
    public  Result createVoucherOrder(Long voucherId) {
        //一人只能下一单
        Long userId = UserHolder.getUser().getId();

        synchronized (userId.toString().intern()) {
            int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//查询订单数目
            if (count > 0) {
                //用户已经购买过该秒杀券
                return Result.fail("用户已经购买过一次!");
            }

            //5.满足条件,扣减库存
            boolean success = seckillVoucherService.update()
                    .setSql("stock = stock - 1")
                    .eq("voucher_id", voucherId).gt("stock", 0)//乐观锁,查询一下stock有无变化,变化了就不能做修改操作
                    .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
            voucherOrder.setUserId(userId);
            //6.3代金券
            voucherOrder.setVoucherId(voucherId);
            save(voucherOrder);

            //7.返回订单id
            return Result.ok(orderId);
        }
    }

        这里我们将锁定义在了方法内部,又会出并发问题:此处事务是在方法结束时提交,而锁在synchronized结束之后就释放了,无法保证在这短暂的时间里面不会有线程窜进来,此时由于事务还未提交,该线程查询订单数量依然为0,依然可以下单

        所以我们应该将整个函数锁起来:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private RedisIdWorker redisIdWorker;
    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @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("库存不足");
        }

        //此处需要将整个函数锁起来
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            return createVoucherOrder(voucherId);
        }
    }

    @Transactional
    public  Result createVoucherOrder(Long voucherId) {
        //一人只能下一单
        Long userId = UserHolder.getUser().getId();

        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//查询订单数目
        if (count > 0) {
            //用户已经购买过该秒杀券
            return Result.fail("用户已经购买过一次!");
        }

        //5.满足条件,扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).gt("stock", 0)//乐观锁,查询一下stock有无变化,变化了就不能做修改操作
                .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
        voucherOrder.setUserId(userId);
        //6.3代金券
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        //7.返回订单id
        return Result.ok(orderId);

    }
}

        这样子就能保证锁一定是在事务提交之后才释放。

        但还是有个小问题,这里调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务,这里可以使用AopContext.currentProxy()来获取当前对象的代理对象,然后再用代理对象调用方法,需要更改的代码如下:

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

        这里注意需要去IVoucherOrderService中创建createVoucherOrder方法,pom文件加入相关依赖,启动类加入相关注解,就不一一实现了。

        最后使用jmeter来测试一下,黄牛还能不能使用多线程抢到多张优惠券了:

异常率高达99.5,说明黄牛的大量下单请求都失效了,再来看看数据库:

库存只少了一张优惠券,问题基本得到了解决。

        你以为这就结束了吗?并没有,这里还存在集群条件下的线程安全问题,需要使用分布式锁来解决,这部分留到下一章继续学习。

  • 21
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值