点评项目——优惠卷秒杀

2023-12-10 03:28
文章标签 项目 秒杀 点评 优惠卷

本文主要是介绍点评项目——优惠卷秒杀,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

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;@Resourceprivate 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 {@Resourceprivate RedisIdWorker redisIdWorker;@Resourceprivate ISeckillVoucherService seckillVoucherService;@Override@Transactionalpublic 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订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);//6.2用户idLong userId = UserHolder.getUser().getId();voucherOrder.setUserId(userId);//6.3代金券voucherOrder.setVoucherId(voucherId);save(voucherOrder);//7.返回订单idreturn 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代码为:

    @Transactionalpublic 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订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);//6.2用户idvoucherOrder.setUserId(userId);//6.3代金券voucherOrder.setVoucherId(voucherId);save(voucherOrder);//7.返回订单idreturn Result.ok(orderId);}

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

    @Transactionalpublic  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订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);//6.2用户idvoucherOrder.setUserId(userId);//6.3代金券voucherOrder.setVoucherId(voucherId);save(voucherOrder);//7.返回订单idreturn Result.ok(orderId);}}

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

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

    @Transactionalpublic  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订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);//6.2用户idvoucherOrder.setUserId(userId);//6.3代金券voucherOrder.setVoucherId(voucherId);save(voucherOrder);//7.返回订单idreturn Result.ok(orderId);}}

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

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

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate RedisIdWorker redisIdWorker;@Resourceprivate ISeckillVoucherService seckillVoucherService;@Overridepublic 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);}}@Transactionalpublic  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订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);//6.2用户idvoucherOrder.setUserId(userId);//6.3代金券voucherOrder.setVoucherId(voucherId);save(voucherOrder);//7.返回订单idreturn 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,说明黄牛的大量下单请求都失效了,再来看看数据库:

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

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

这篇关于点评项目——优惠卷秒杀的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/475893

相关文章

Python开发文字版随机事件游戏的项目实例

《Python开发文字版随机事件游戏的项目实例》随机事件游戏是一种通过生成不可预测的事件来增强游戏体验的类型,在这篇博文中,我们将使用Python开发一款文字版随机事件游戏,通过这个项目,读者不仅能够... 目录项目概述2.1 游戏概念2.2 游戏特色2.3 目标玩家群体技术选择与环境准备3.1 开发环境3

SpringBoot项目中报错The field screenShot exceeds its maximum permitted size of 1048576 bytes.的问题及解决

《SpringBoot项目中报错ThefieldscreenShotexceedsitsmaximumpermittedsizeof1048576bytes.的问题及解决》这篇文章... 目录项目场景问题描述原因分析解决方案总结项目场景javascript提示:项目相关背景:项目场景:基于Spring

解决Maven项目idea找不到本地仓库jar包问题以及使用mvn install:install-file

《解决Maven项目idea找不到本地仓库jar包问题以及使用mvninstall:install-file》:本文主要介绍解决Maven项目idea找不到本地仓库jar包问题以及使用mvnin... 目录Maven项目idea找不到本地仓库jar包以及使用mvn install:install-file基

springboot项目如何开启https服务

《springboot项目如何开启https服务》:本文主要介绍springboot项目如何开启https服务方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录springboot项目开启https服务1. 生成SSL证书密钥库使用keytool生成自签名证书将

Redis消息队列实现异步秒杀功能

《Redis消息队列实现异步秒杀功能》在高并发场景下,为了提高秒杀业务的性能,可将部分工作交给Redis处理,并通过异步方式执行,Redis提供了多种数据结构来实现消息队列,总结三种,本文详细介绍Re... 目录1 Redis消息队列1.1 List 结构1.2 Pub/Sub 模式1.3 Stream 结

将Java项目提交到云服务器的流程步骤

《将Java项目提交到云服务器的流程步骤》所谓将项目提交到云服务器即将你的项目打成一个jar包然后提交到云服务器即可,因此我们需要准备服务器环境为:Linux+JDK+MariDB(MySQL)+Gi... 目录1. 安装 jdk1.1 查看 jdk 版本1.2 下载 jdk2. 安装 mariadb(my

Node.js 数据库 CRUD 项目示例详解(完美解决方案)

《Node.js数据库CRUD项目示例详解(完美解决方案)》:本文主要介绍Node.js数据库CRUD项目示例详解(完美解决方案),本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考... 目录项目结构1. 初始化项目2. 配置数据库连接 (config/db.js)3. 创建模型 (models/

springboot项目中常用的工具类和api详解

《springboot项目中常用的工具类和api详解》在SpringBoot项目中,开发者通常会依赖一些工具类和API来简化开发、提高效率,以下是一些常用的工具类及其典型应用场景,涵盖Spring原生... 目录1. Spring Framework 自带工具类(1) StringUtils(2) Coll

Spring Boot项目部署命令java -jar的各种参数及作用详解

《SpringBoot项目部署命令java-jar的各种参数及作用详解》:本文主要介绍SpringBoot项目部署命令java-jar的各种参数及作用的相关资料,包括设置内存大小、垃圾回收... 目录前言一、基础命令结构二、常见的 Java 命令参数1. 设置内存大小2. 配置垃圾回收器3. 配置线程栈大小

Spring Boot项目中结合MyBatis实现MySQL的自动主从切换功能

《SpringBoot项目中结合MyBatis实现MySQL的自动主从切换功能》:本文主要介绍SpringBoot项目中结合MyBatis实现MySQL的自动主从切换功能,本文分步骤给大家介绍的... 目录原理解析1. mysql主从复制(Master-Slave Replication)2. 读写分离3.