【Redis】高并发场景下秒杀业务的实现思路(单机模式)

一、超卖问题

业务要求:

实现一个对优惠券限时抢购的功能。

你可能会想,啥抢购啊,说白了不就是买东西吗,先判断一下是否在抢购时间,然后在买的时候判断一下是否还有库存不就行了。如果有,说明优惠券还没抢完,则让订单生效,并让库存减一;如果没有,说明已经抢完了,直接返回异常提示就好了。很容易就能得到下图中的流程:

这业务逻辑感觉好像没啥毛病,接着就可以根据这个流程编写代码了:

java 复制代码
    @Transactional  //事务
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断秒杀是否开始
        LocalDateTime beginTime = voucher.getBeginTime();
        if(LocalDateTime.now().isBefore(beginTime)){
            //秒杀还未开始,返回提示信息
            return Result.fail("秒杀还为开始!");
        }
        //3.判断秒杀是否结束
        LocalDateTime endTime = voucher.getEndTime();
        if(LocalDateTime.now().isAfter(endTime)){
            //秒杀已经结束,返回提示信息
            return Result.fail("秒杀已经结束!");
        }
        //4.判断库存是否充足
        Integer stock = voucher.getStock();
        if(stock<=0){
            //优惠券已经抢完,没有库存了
            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);
        //7.返回订单id

        return Result.ok(orderId);
    }

好像已经把业务要求成功实现了,是不是万事大吉了?然而,现实场景中,由于秒杀的限时限量的特性,往往会吸引大量用户参与,就会给系统带来并发压力。让我们用jmeter模拟一下高并发场景下的运行情况:

设置200个线程发送秒杀请求,而数据库中存放了100张优惠券。正常情况下,最后应该有100个线程抢券成功,另一百个线程则返回异常信息,则异常率应该为50%。

然而我们可以看到,实际上测试的异常率却是45.50%,这时为什么呢?

再次查看数据库,我们发现数据库中的库存竟然是负数。也就是说实际上卖出了109单,多卖出了9单。这就是所谓的超卖问题。

为什么会这样呢?还是老生常谈的线程安全问题。由于查询库存与与扣减库存的操作不是原子的,当线程1查完数据库发现库存为1,正准备去扣减库存但还没有完成扣减操作时,线程2插了进来,进行了查库操作,也读取到了还未被扣减的库存值1,于是也会去继续进行库存扣减操作。这就导致了多个线程重复地去扣减库存,也就造成了超卖问题。

为了解决这一问题,很自然的想法就是给查询扣减两个操作加上锁。这里就会引申两种不同的加锁策略 乐观锁 与 悲观锁

乐观锁与悲观锁

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

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

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

  • 优点:性能好
  • 缺点:存在成功率低的问题

权衡之下,其实该业务场景中锁冲突发生的概率也不是很高。如果选用悲观锁就会导致浪费很多不必要的性能。使用乐观锁其实就能满足我们的需求了。

而实现乐观锁的关键就在于在进行扣减库存操作时,要判断之前查询得到的数据是否被修改过。常见的判断方式有以下两种:

1、版本号法:

额外维护一个版本号version字段。在每次执行成功扣减库存操作时,就让版本号递增。这样,只需要在要扣减库存时判断当前当前版本号与查询库存时查到的版本号是否一致,若一致就认为期间数据未被修改,可以执行扣减库存操作。否则则扣减失败,返回错误信息。

不过不过,由于需要数据库额外维护一个字段,操作起来会比较麻烦一些。

2、CAS法

其实仔细分析我们会发现,只有在库存为0的时候会发生线程安全问题。事实上当库存大于零时,即使操作不是原子的,最后从结果上来看也没啥问题,因为数据库的操作时原子的。

我们只需要在真正执行扣减库存操作时,判断此时库存是否为零即可。若大于零,则成功扣减库存;否则就说明此时没有库存了,返回错误信息即可。

这种方法本质上就是把服务端的并发问题给抛给数据库了,利用数据库自身的抗高并发特性来解决问题。不过也是有一定弊端的,如果并发量过大的话,对数据库的压力就会非常大了。这就得自行做出取舍了。

具体代码实现:

java 复制代码
@Transactional  //事务
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断秒杀是否开始
        LocalDateTime beginTime = voucher.getBeginTime();
        if(LocalDateTime.now().isBefore(beginTime)){
            //秒杀还未开始,返回提示信息
            return Result.fail("秒杀还为开始!");
        }
        //3.判断秒杀是否结束
        LocalDateTime endTime = voucher.getEndTime();
        if(LocalDateTime.now().isAfter(endTime)){
            //秒杀已经结束,返回提示信息
            return Result.fail("秒杀已经结束!");
        }
        //4.判断库存是否充足
        Integer stock = voucher.getStock();
        if(stock<=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("优惠券已被抢完!");
        }
        //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);
        //7.返回订单id

        return Result.ok(orderId);
    }

可以看到现在就不会出现线程安全问题了。

二、一人一单

我们仔细观察一下上述代码执行后生产的优惠券信息,会发现所有的优惠券的使用者都是同一个用户:

然而实际场景中,商家发放优惠券其实就是想起到一个引流的作用,肯定希望越多用户参与越好。如果所有优惠券都被一个人抢到了,宣传效果就会大打折扣了。如果做到一人一单,那参与人数肯定是最多的,宣传效果也会比较好。

因此,我们还需要再进行扣减库存操作之前,先判断该用户之前是否抢过优惠券,如果之前没有产生过订单,才继续执行扣减库存操作,并创建订单;否则直接返回异常信息即可:

那是不是说我们就在扣减库存前加入以下判断代码就好了:

java 复制代码
    //一人一单
    Long userId=UserHolder.getUser().getId();
    //查询订单
    Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    //判断订单是否存在
    if(count>0){
        //用户已经购买过了
        return Result.fail("用户已经购买过一次了!");
    }

让我们测试一下看看,此时让同一个用户同时发送200个请求,库存100个优惠券:

如果是一人一单,那200个请求应该只有一个能成功,结果却少了十张券,这是为什么?

因为这个查询订单的操作与后面漏检数据库的操作依然不是原子的,因此难免会发生线程安全问题。同样的,我们海狮得要对他们进行加锁。为了方便编写,我们将这两个操作封装成一个方法,并为其添加事务:

java 复制代码
    @Transactional
    public Result createVoucherOrder(Long voucherId){
        //一人一单
        Long userId=UserHolder.getUser().getId();
        //查询订单
        Integer 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)//判断此时库存是否大于零
                .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.代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7.返回订单id
        return Result.ok(orderId);
    }

注意:这里锁应该加在事务之外。如果锁加在事务内,可能会出现锁已释放且其它线程抢到了锁,而此时事务还未提交的情况,这时新增的订单还未写入数据库,就可能导致发生线程安全问题了

因此我们需要再这个方法外部加锁,最简单的方式就是在调用该方法时加锁:

需要注意的是,由于我们给createVoucherOrder方法添加了事务,则spring会通过动态代理获取当前类的代理对象,用代理对象来进行事务处理。而此时我们没有为seckillVoucher方法添加事务,如果直接在该方法中调用createVoucherOrder方法,相当于用非代理对象进行调用,而非代理对象是没有事务功能的,就会导致事务失效。

我们可以通过AopContext.currentProxy()方法获取当前类的代理对象,用代理对象进行调用就能实现事务功能了。

使用该方法需要添加以下依赖:

在启动类上添加注解暴露代理对象:

java 复制代码
//暴露代理对象
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {

    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }

}

最终代码

java 复制代码
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断秒杀是否开始
        LocalDateTime beginTime = voucher.getBeginTime();
        if(LocalDateTime.now().isBefore(beginTime)){
            //秒杀还未开始,返回提示信息
            return Result.fail("秒杀还为开始!");
        }
        //3.判断秒杀是否结束
        LocalDateTime endTime = voucher.getEndTime();
        if(LocalDateTime.now().isAfter(endTime)){
            //秒杀已经结束,返回提示信息
            return Result.fail("秒杀已经结束!");
        }
        //4.判断库存是否充足
        Integer stock = voucher.getStock();
        if(stock<=0){
            //优惠券已经抢完,没有库存了
            return Result.fail("优惠券已被抢完!");
        }
        Long userId = UserHolder.getUser().getId();
        //              intern:返回字符串的规范表示
        synchronized (userId.toString().intern()){
            //获取当前对象的代理对象
            IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }
    @Transactional
    public Result createVoucherOrder(Long voucherId){
        //一人一单
        Long userId=UserHolder.getUser().getId();
        //查询订单
        Integer 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)//判断此时库存是否大于零
                .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.代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7.返回订单id
        return Result.ok(orderId);
    }

初始时数据库中有200个优惠券:

让我们测试一下看看,让同十个用户同时发送200个请求:

此时数据库中还剩下90张券:

说明既没有出现超卖问题,也实现了一人一单。


那么本篇文章就到此为止了,如果觉得这篇文章对你有帮助的话,可以点一下关注和点赞来支持作者哦。如果有什么讲的不对的地方欢迎在评论区指出,希望能够和你们一起进步✊

相关推荐
Mercury_@2210 分钟前
功能篇:JAVA后端实现跨域配置
java·spring boot·后端
Cchengzu20 分钟前
百度23届秋招研发岗A卷
开发语言·数据库·c++
编程修仙21 分钟前
MySQL子查询
数据库·mysql
@黄色海岸25 分钟前
【数据分析】表结构数据特征、获取、使用
数据库·数据挖掘·数据分析
AI人H哥会Java26 分钟前
【JAVA】Java项目实战—Java EE项目:客户关系管理(CRM)系统
java·开发语言·spring boot·mysql
吴冰_hogan1 小时前
MySQL事务隔离
数据库·mysql
练小杰1 小时前
我在广州学 Mysql 系列之 数据“表”的基本操作
android·数据库·学习·mysql·adb
呆呆在发呆.1 小时前
计组实验-Bomb Lab -2
java·服务器·前端
gsforget3211 小时前
ORACLE RAC ADG备库报错ORA-04021: timeout occurred while waiting to lock object
数据库·oracle·oracle adg
Yvemil71 小时前
数据库镜像(Database Mirroring):高可用性与灾难恢复技术
数据库·oracle