0


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

文章目录

全局唯一ID

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

在这里插入图片描述

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

UUID

返回的是16进制的

Redis自增

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

@ComponentpublicclassRedisIdWorker{/**
     * 开始时间戳
     */privatestaticfinallongBEGIN_TIMESTAMP=1704067200L;/**
     * 序列号位数
     */privatestaticfinallongCOUNT_BITS=32;privateStringRedisTemplate stringRedisTemplate;publicRedisIdWorker(StringRedisTemplate stringRedisTemplate){this.stringRedisTemplate = stringRedisTemplate;}publiclongnextId(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;}}

单元测试:

@AutowiredprivateRedisIdWorker redisIdWorker;privateExecutorService es =Executors.newFixedThreadPool(500);@TestvoidtestIdWorker()throwsInterruptedException{// 使用 CountDownLatch 同步 300 个异步任务。// 确保所有任务完成后,计算并输出总的执行时间。// 通过这种方式,可以准确地测量所有任务的总执行时间,而不会因为异步执行导致时间计算不准确。CountDownLatch latch =newCountDownLatch(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@TransactionalpublicResultseckillVoucher(Long voucherId){// 1. 查询优惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 2. 判断秒杀是否开始if(voucher.getBeginTime().isAfter(LocalDateTime.now())){// 尚未开始returnResult.fail("秒杀尚未开始!");}// 2/ 判断秒杀是否结束if(voucher.getEndTime().isBefore(LocalDateTime.now())){// 已经结束returnResult.fail("秒杀已经结束!");}// 4. 判断库存是否充足if(voucher.getStock()<1){// 库存不足returnResult.fail("库存不足!");}// 5.扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();if(!success){returnResult.fail("库存不足!");}// 6.创建订单VoucherOrder voucherOrder =newVoucherOrder();// 6.1 订单idlong orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);// 6.2 用户idLong userId =UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);// 6.3 代金券id
        voucherOrder.setVoucherId(voucherId);save(voucherOrder);// 7. 返回订单idreturnResult.ok(orderId);}

超卖问题

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

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

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

乐观锁——版本号法

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

乐观锁——CAS法

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

@Override@TransactionalpublicResultseckillVoucher(Long voucherId){// 1. 查询优惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 2. 判断秒杀是否开始if(voucher.getBeginTime().isAfter(LocalDateTime.now())){// 尚未开始returnResult.fail("秒杀尚未开始!");}// 2/ 判断秒杀是否结束if(voucher.getEndTime().isBefore(LocalDateTime.now())){// 已经结束returnResult.fail("秒杀已经结束!");}// 4. 判断库存是否充足if(voucher.getStock()<1){// 库存不足returnResult.fail("库存不足!");}// 5.扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).eq("stock",voucher.getStock())// CAS锁.update();if(!success){returnResult.fail("库存不足!");}// 6.创建订单VoucherOrder voucherOrder =newVoucherOrder();// 6.1 订单idlong orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);// 6.2 用户idLong userId =UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);// 6.3 代金券id
        voucherOrder.setVoucherId(voucherId);save(voucherOrder);// 7. 返回订单idreturnResult.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@TransactionalpublicResultseckillVoucher(Long voucherId){// 1. 查询优惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 2. 判断秒杀是否开始if(voucher.getBeginTime().isAfter(LocalDateTime.now())){// 尚未开始returnResult.fail("秒杀尚未开始!");}// 2/ 判断秒杀是否结束if(voucher.getEndTime().isBefore(LocalDateTime.now())){// 已经结束returnResult.fail("秒杀已经结束!");}// 4. 判断库存是否充足if(voucher.getStock()<1){// 库存不足returnResult.fail("库存不足!");}// 5.扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock",0)// 把判断条件改成库存大于0就可以避免乐观锁的弊端.update();if(!success){returnResult.fail("库存不足!");}// 6.创建订单VoucherOrder voucherOrder =newVoucherOrder();// 6.1 订单idlong orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);// 6.2 用户idLong userId =UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);// 6.3 代金券id
        voucherOrder.setVoucherId(voucherId);save(voucherOrder);// 7. 返回订单idreturnResult.ok(orderId);}

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

总结

在这里插入图片描述

一人一单

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

@Override@TransactionalpublicResultseckillVoucher(Long voucherId){// 1. 查询优惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 2. 判断秒杀是否开始if(voucher.getBeginTime().isAfter(LocalDateTime.now())){// 尚未开始returnResult.fail("秒杀尚未开始!");}// 2/ 判断秒杀是否结束if(voucher.getEndTime().isBefore(LocalDateTime.now())){// 已经结束returnResult.fail("秒杀已经结束!");}// 4. 判断库存是否充足if(voucher.getStock()<1){// 库存不足returnResult.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){returnResult.fail("您已经购买过一次了!");}// 6.扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock",0)// 把判断条件改成库存大于0就可以避免乐观锁的弊端.update();if(!success){returnResult.fail("库存不足!");}// 7.创建订单VoucherOrder voucherOrder =newVoucherOrder();// 7.1 订单idlong orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);// 7.2 用户id
        voucherOrder.setUserId(userId);// 7.3 代金券id
        voucherOrder.setVoucherId(voucherId);save(voucherOrder);// 8. 返回订单idreturnResult.ok(orderId);}

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

@OverridepublicResultseckillVoucher(Long voucherId){// 1. 查询优惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 2. 判断秒杀是否开始if(voucher.getBeginTime().isAfter(LocalDateTime.now())){// 尚未开始returnResult.fail("秒杀尚未开始!");}// 2/ 判断秒杀是否结束if(voucher.getEndTime().isBefore(LocalDateTime.now())){// 已经结束returnResult.fail("秒杀已经结束!");}// 4. 判断库存是否充足if(voucher.getStock()<1){// 库存不足returnResult.fail("库存不足!");}returncreateVoucherOrder(voucherId);}@TransactionalpublicsynchronizedResultcreateVoucherOrder(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){returnResult.fail("您已经购买过一次了!");}// 6.扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock",0)// 把判断条件改成库存大于0就可以避免乐观锁的弊端.update();if(!success){returnResult.fail("库存不足!");}// 7.创建订单VoucherOrder voucherOrder =newVoucherOrder();// 7.1 订单idlong orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);// 7.2 用户id
        voucherOrder.setUserId(userId);// 7.3 代金券id
        voucherOrder.setVoucherId(voucherId);save(voucherOrder);// 8. 返回订单idreturnResult.ok(orderId);}

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

  1. 释放锁的时机:我们的锁也不能加载createVoucherOrder方法里面,因为锁要是加载createVoucherOrder方法里面,会出现spring的事务还没提交就释放锁的问题。
  2. 事务失效问题 我们在createVoucherOrder函数加了事务,没有给seckillVoucher函数加事务,而seckillVoucher函数调用的时候使用this调用的,事务失效,具体解释看代码里
@OverridepublicResultseckillVoucher(Long voucherId){// 1. 查询优惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 2. 判断秒杀是否开始if(voucher.getBeginTime().isAfter(LocalDateTime.now())){// 尚未开始returnResult.fail("秒杀尚未开始!");}// 2/ 判断秒杀是否结束if(voucher.getEndTime().isBefore(LocalDateTime.now())){// 已经结束returnResult.fail("秒杀已经结束!");}// 4. 判断库存是否充足if(voucher.getStock()<1){// 库存不足returnResult.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)暴露代理对象
     */@TransactionalpublicResultcreateVoucherOrder(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){returnResult.fail("您已经购买过一次了!");}// 6.扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock",0)// 把判断条件改成库存大于0就可以避免乐观锁的弊端.update();if(!success){returnResult.fail("库存不足!");}// 7.创建订单VoucherOrder voucherOrder =newVoucherOrder();// 7.1 订单idlong orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);// 7.2 用户id
        voucherOrder.setUserId(userId);// 7.3 代金券id
        voucherOrder.setVoucherId(voucherId);save(voucherOrder);// 8. 返回订单idreturnResult.ok(orderId);}

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

一人一单的并发安全问题

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

标签: java redis

本文转载自: https://blog.csdn.net/shall_zhao/article/details/141754233
版权归原作者 shall_zhao 所有, 如有侵权,请联系我们删除。

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

还没有评论