黑马点评3——优惠券秒杀—全局唯一ID、秒杀下单、超卖问题(乐观锁)、一人一单问题(并发安全)

全局唯一ID

在这里插入图片描述
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足一下特性:

在这里插入图片描述

基本格式如下:
在这里插入图片描述

UUID

返回的是16进制的

Redis自增

根据上面图示的格式,我们实现一个Redis自增的全局ID,注册成bean,交给Spring管理


@Component
public class RedisIdWorker {
    
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1704067200L;
    /**
     * 序列号位数
     */
    private static final long COUNT_BITS = 32;
    
    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
    public long nextId(String keyPrefix){
        // 1. 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestep = nowSecond - BEGIN_TIMESTAMP;
        // 2. 生成序列号
        // 2.1 获取当前日期,精确到天,好处1: 避免超过2^32, 2:方便统计
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        long count = stringRedisTemplate.opsForValue().increment("icr:"+keyPrefix + ":" + date);
        // 3. 拼接并返回
        return timestep << COUNT_BITS | count;
    }
}

单元测试:


    @Autowired
    private RedisIdWorker redisIdWorker;

    private ExecutorService es = Executors.newFixedThreadPool(500);

    @Test
    void testIdWorker() throws InterruptedException {
        // 使用 CountDownLatch 同步 300 个异步任务。
        // 确保所有任务完成后,计算并输出总的执行时间。
        // 通过这种方式,可以准确地测量所有任务的总执行时间,而不会因为异步执行导致时间计算不准确。
        CountDownLatch latch = new CountDownLatch(300);   // 因为这里是异步执行的,所以统计时间的话不能使用普通的打印时间
        Runnable task = () ->{
            for (int i = 0; i < 100; i++) {
                long id = redisIdWorker.nextId("order");
                System.out.println("id = " + id);
            }
            latch.countDown();
        };

        long begin = System.currentTimeMillis();
        for(int i=0; i<300; i++){
            es.submit(task);  // 使用线程池 es 提交 300 个相同的任务。每个任务都会执行上面定义的操作。
        }
        latch.await();  // 调用 latch.await() 方法,使当前线程等待,直到计数器的值变为 0。这确保了所有 300 个任务都已完成。
        long end = System.currentTimeMillis();
        System.out.println("time = " + (end - begin));
    }

snowflake算法(雪花算法)

数据库自增

这里不是说使用数据库的自增字段,而是说单独使用一张表,专门用来做自增,订单表需要id,就从这张表里获取(redis自增的数据库版)

Redis自增id策略

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

实现优惠券秒杀下单

在这里插入图片描述
主要是针对特价券这种需要抢的,普通券就没必要了。

优惠券秒杀下单时要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足无法下单
    在这里插入图片描述
@Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        // 1. 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2. 判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        // 2/ 判断秒杀是否结束
        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);
        // 7. 返回订单id
        return Result.ok(orderId);
    }

超卖问题

正常情况下没问题:
在这里插入图片描述
高并发环境下就有问题,不同线程的动作会交叉
在这里插入图片描述
如果同一时刻有很多的线程同时来查询,就出现了这个并发安全问题

多个线程在操作共享的资源,并且操作资源的代码有好几行,这几行代码执行的中间,多个线程互相穿插,就出现了安全问题。

在这里插入图片描述
悲观锁很简单暴力,直接加锁就行,我们演示乐观锁:
乐观锁的关键是判断之前查询得到的数据是否有被修改过, 常见的方式有两种:

乐观锁——版本号法

在这里插入图片描述
线程2在扣减库存的时候发现版本不一致就无法更新了
那现在我们想,我们即用库存,又用版本,比较版本的变化,是不是可以使用库存的变化代替呢?当然可以,于是乎,有了新方案:cas

乐观锁——CAS法

在这里插入图片描述
我们只需要把刚刚代码在扣减的时候加上关于库存的比较

@Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        // 1. 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2. 判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        // 2/ 判断秒杀是否结束
        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)
                .eq("stock",voucher.getStock())   // CAS锁
                .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进行测试,设置库存100件,并发线程200个,测试结果如下:
在这里插入图片描述
错误率高达68.5%
啊。不应该啊。
在看看数据库的库存:
在这里插入图片描述
还剩下79件???
订单也只有21个,没有超卖啊,安全问题确实解决了,那为什么出现了很多失败的情况呢?
还没卖完就结束了?怎么回事?

乐观锁的弊端

在这里插入图片描述
太小心了,他认为只要有人修改了就不执行,失败率大大提高
其实只要库存大于0, 就没问题。
这就是乐观锁的问题——成功率太低
其实解决方案很简单,就是把扣减库存的时候的

  // 5.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .eq("stock",voucher.getStock())   // CAS锁
                .update();

这个严苛的判断条件改成只要库存大于0,就没必要大惊小怪的。

// 5.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)   // 把判断条件改成库存大于0就可以避免乐观锁的弊端
                .update();
@Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        // 1. 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2. 判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        // 2/ 判断秒杀是否结束
        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)
                .gt("stock",0)   // 把判断条件改成库存大于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);
    }

100的库存,200的线程并发访问,jmeter测试结果:
在这里插入图片描述
数据库也减少到0了
在这里插入图片描述
当然这个解决方案不是所有情况都行的,还有其他的解决方案
比如:如果有的问题中没有库存怎么办?
采用分批加锁的方案,或者是分段锁的方案,也就是说把数据库中的资源分成几份,比如说把数据分成十份,那用户在抢的时候可以去10张表里面分别去抢,这样一来成功率就提高了10倍。这种思想在ConcurrentHashMap中有应用。

总结

在这里插入图片描述

一人一单

以前我们的优惠券下单业务是这样的:
在这里插入图片描述
现在修改业务流程
在这里插入图片描述
修改我们的业务代码如下:


    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        // 1. 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2. 判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        // 2/ 判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            // 已经结束
            return Result.fail("秒杀已经结束!");
        }
        // 4. 判断库存是否充足
        if (voucher.getStock()<1) {
            // 库存不足
            return Result.fail("库存不足!");
        }

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

        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)   // 把判断条件改成库存大于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);
        // 8. 返回订单id
        return Result.ok(orderId);
    }

100的库存,200的线程并发访问,但是只配置一个用户的请求头,jmeter测试
理论上应该只能下一单才对,库存应该还剩下99个
看看数据库库存
在这里插入图片描述
订单表里面也有10个订单:
在这里插入图片描述
什么情况?里面的user_id和voucher_id竟然也是一样的!
说明我们做了一人一单的业务逻辑判断,但是并没有解决问题。
现在的问题就是因为先查询,在判断,在扣减,就是因为多个线程并发访问,多个线程一起查询count都是0,都说我可以扣减,那就出错了,那怎么解决呢?——加锁呀!
先暴力加个悲观锁,把查询订单和扣减库存的方法抽取出来:
Transactional注解也要换到扣减库存的方法上。

@Override
    public Result seckillVoucher(Long voucherId) {
        // 1. 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2. 判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        // 2/ 判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            // 已经结束
            return Result.fail("秒杀已经结束!");
        }
        // 4. 判断库存是否充足
        if (voucher.getStock()<1) {
            // 库存不足
            return Result.fail("库存不足!");
        }

        return createVoucherOrder(voucherId);
    }

    @Transactional
    public synchronized Result createVoucherOrder(Long voucherId){
        // 5. 一人一单
        Long userId = UserHolder.getUser().getId();
        // 5.1 查询订单
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2 判断是否存在
        if(count > 0){
            return Result.fail("您已经购买过一次了!");
        }

        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)   // 把判断条件改成库存大于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);
        // 8. 返回订单id
        return Result.ok(orderId);
    }

锁加在方法上肯定可以解决,但是不建议,因为synchronized加在方法上,就变成了锁整个方法,锁的对象是this,也就意味着不管任何一个用户来了,都要加这个锁,而且大家是同一把锁,整个方法就串行执行了。我们想要的是只有同一个用户来了在加锁,不同用户来了就不用管。各做各就行,应该对用户id加锁。缩小加锁的范围。
注意两点:

  1. 释放锁的时机:我们的锁也不能加载createVoucherOrder方法里面,因为锁要是加载createVoucherOrder方法里面,会出现spring的事务还没提交就释放锁的问题。
  2. 事务失效问题
    我们在createVoucherOrder函数加了事务,没有给seckillVoucher函数加事务,而seckillVoucher函数调用的时候使用this调用的,事务失效,具体解释看代码里
@Override
    public Result seckillVoucher(Long voucherId) {
        // 1. 查询优惠券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 2. 判断秒杀是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            // 尚未开始
            return Result.fail("秒杀尚未开始!");
        }
        // 2/ 判断秒杀是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            // 已经结束
            return Result.fail("秒杀已经结束!");
        }
        // 4. 判断库存是否充足
        if (voucher.getStock()<1) {
            // 库存不足
            return Result.fail("库存不足!");
        }

        Long userId = UserHolder.getUser().getId();
        /**
         *          每一个请求过来,这个id对象都是一个全新的id对象,因为要是对userId加锁的话,对象变了锁就变了,那不行
         *          我们希望id的值一样,所以用了toString(),但是toString()依旧不能保证是对对象的值加锁的
         *          toString底层是new 一个String数组,还是new了一个新对象,同一个用户id在不同的请求中过来,每次都new一个,还是不能把锁加载同一个用户上
         *          于是用intern() ,intern()方法可以去字符串常量池中找字符串值一样的引用返回
         *          这样一来,如果你的userId是5,不管你new了多少个字符串,只要值是一样的,返回的结果也一样。这样就可以锁住同一个用户
         *          不同的用户不会被锁住
         */

        synchronized (userId.toString().intern()) {
            // 获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();  // 拿到当前对象的代理对象,其实就是IVoucherOrderService这个接口的代理对象,返回的是Object,做个强转
            return proxy.createVoucherOrder(voucherId);  // 如果报错了是因为我们的接口中没有这个方法,那我们就在接口中创建一下这个方法就行
        }
    }

    /**
     * 事务加在这,就失效了,为什么呢?
     * 加载这是对createVoucherOrder函数加了事务,没有给seckillVoucher函数加事务,而seckillVoucher函数调用的时候
     * createVoucherOrder(voucherId);
     * 这样使用this调用的,这个this拿到的是当前的VoucherOrderServiceImpl对象
     * 而不是VoucherOrderServiceImpl的代理对象
     * 而事务要想生效,是spring对当前这个类做了动态代理,拿到代理对象做的事务处理
     * 而我们当前的this是非代理对象,这就是事务失效的几种可能性之一
     * 解决方法之一:
     * AopContext.currentProxy()拿到代理对象来调用createVoucherOrder
     *
     * 当然这样解决还得做两件事:
     * 1. 引入aspectj的依赖
     *    <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
            <version>1.9.7</version>
        </dependency>
     * 2. 启动类添加注解@EnableAspectJAutoProxy(exposeProxy = true)暴露代理对象
     */
    @Transactional
    public Result createVoucherOrder(Long voucherId){
        // 5. 一人一单
        Long userId = UserHolder.getUser().getId();
        // 5.1 查询订单
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2 判断是否存在
        if(count > 0){
            return Result.fail("您已经购买过一次了!");
        }

        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)   // 把判断条件改成库存大于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);
        // 8. 返回订单id
        return Result.ok(orderId);

    }

100的库存,200的线程并发访问,但是只配置一个用户的请求头,jmeter测试
理论上应该只能下一单才对,库存应该还剩下99个
看看数据库库存对的。YES!!!

一人一单的并发安全问题

上面的解决方案在单机模式下不会有问题,但是在集群模式下就有问题了,什么问题呢?我们来测试
在这里插入图片描述
我们用postman使用同一个用户发送两个请求,在锁后打上断点,发现两个集群下两个服务都进入断点了,这一个锁在集群模式下没有锁住,放开后也发现数据库的数据被同一个用户扣减了两个。为什么呢?
来捋一下:
之前是单体项目,正常情况下:
在这里插入图片描述
多线程并发下,要是没有加锁,会出现并发执行:
在这里插入图片描述
这就出现了线程安全问题。于是我们加了锁
在这里插入图片描述
在集群情况下就出问题了:
现在我们是多台JVM下,锁的原理是,在JVM内部维护一个锁监视器对象,这个监视器对象用的userId,userId在常量池中。在这个JVM内部维护了一个常量池,当userId相同的情况下,永远是同一个锁,也就是锁的监视器就能记录不同线程的情况。
但是当集群的时候,那就各自有各自的JVM,那各自的JVM都有各自的堆、栈、方法区之类的。JVM2也会有自己的常量池,JVM2 的锁监视器只能在当前的JVM内部见识线程,实现互斥。
在这里插入图片描述
这就有一次出现了并发安全问题,每一个JVM都有自己的锁,就导致并行运行下,就出现问题了,那就得让多个JVM只能使用同一把锁,但这样的锁不是JDK提供的,于是乎跨JVM,或者跨进程的锁就出现了——分布式锁

黑马rabbitMQ优惠卷秒杀是指利用RabbitMQ消息队列来实现优惠卷的秒杀活动。RabbitMQ是一个开源的消息队列中间件,它可以实现高效的消息传递和异步通信。在秒杀活动中,由于瞬间会有大量用户同时请求抢购,传统的同步处理方式无法满足高并发的需求,而使用RabbitMQ可以将请求异步化,提高系统的并发处理能力。 具体实现过程如下: 1. 创建一个消息队列:首先需要创建一个RabbitMQ消息队列,用于存储用户的秒杀请求。 2. 生成优惠卷:在秒杀活动开始前,需要提前生成一定数量的优惠卷,并将其存储在数据库中。 3. 用户抢购请求:用户在秒杀活动开始时,发送抢购请求到消息队列中。 4. 消费者处理请求:创建多个消费者来监听消息队列中的请求,并进行处理。当有新的请求进入队列时,消费者会从队列中获取请求,并进行相应的处理逻辑。 5. 校验优惠卷:消费者在处理请求时,会先校验用户是否有资格参与秒杀活动,并检查优惠卷的库存情况。 6. 分发优惠卷:如果用户符合条件并且优惠卷有库存,消费者会将优惠卷分发给用户,并更新数据库中的库存信息。 7. 返回结果:消费者处理完请求后,将处理结果返回给用户,告知用户是否成功抢购。 通过使用RabbitMQ消息队列,可以有效地解决高并发场景下的请求处理问题,提高系统的性能和稳定性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值