点评项目——优惠卷秒杀

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,说明黄牛的大量下单请求都失效了,再来看看数据库:

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

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/232262.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

二叉树的锯齿形层序遍历[中等]

优质博文&#xff1a;IT-BLOG-CN 一、题目 给你二叉树的根节点 root &#xff0c;返回其节点值的 锯齿形层序遍历 。&#xff08;即先从左往右&#xff0c;再从右往左进行下一层遍历&#xff0c;以此类推&#xff0c;层与层之间交替进行&#xff09;。 示例 1&#xff1a; 输…

【Java 基础】27 XML 解析

文章目录 1.SAX 解析器1&#xff09;什么是 SAX2&#xff09;SAX 工作流程初始化实现事件处理类解析 3&#xff09;示例代码 2.DOM 解析器1&#xff09;什么是 DOM2&#xff09;DOM 工作流程初始化解析 XML 文档操作 DOM 树 3&#xff09;示例代码 总结 在项目开发中&#xff0…

阿里云(云服务器)上搭建项目部署环境

目录 安装docker docker安装MySQL5.7.37 安装MySQL 方式一&#xff1a;docker中MySQL时区调整 方式二&#xff1a;docker中MySQL时区调整 docker安装MySQL8.0.27 docker安装redis5.0.14 云服务器上安装jdk1.8 安装docker 1、先卸载docker&#xff0c;因为有一些服务器…

Grad-CAM原理

这篇是我对哔哩哔哩up主 霹雳吧啦Wz 的视频的文字版学习笔记 感谢他对知识的分享 只要大家一提到深度学习 缺乏一定的解释性 比如说在我们之前讲的分类网络当中 网络它为什么要这么预测 它针对每个类别所关注的点在哪里呢 在great cam这篇论文当中呢 就完美的解决了在cam这篇论…

SpringSecurity6 | 自定义登录页面

✅作者简介&#xff1a;大家好&#xff0c;我是Leo&#xff0c;热爱Java后端开发者&#xff0c;一个想要与大家共同进步的男人&#x1f609;&#x1f609; &#x1f34e;个人主页&#xff1a;Leo的博客 &#x1f49e;当前专栏&#xff1a; Java从入门到精通 ✨特色专栏&#xf…

基于Vue框架的电子商城购物平台小程序的设计与开发

基于JavaWebSSMVue电子商城购物平台小程序系统的设计和实现 源码获取入口KaiTi 报告/Ren务书Lun文目录前言主要技术系统设计功能截图订阅经典源码专栏Java项目精品实战案例《500套》 源码获取 源码获取入口 KaiTi 报告/Ren务书 一、选题的目的和意义 自从微信推出了微信小程序…

1.cloud-微服务架构编码构建

1.微服务cloud整体聚合父工程 1.1 New Project 1.2 Maven选版本 1.3 字符编码 1.4 注解生效激活 主要为lombok中的Data 1.5 java编译版本选8 1.6 File Type过滤 *.hprof;*.idea;*.iml;*.pyc;*.pyo;*.rbc;*.yarb;*~;.DS_Store;.git;.hg;.svn;CVS;__pycache__;_svn;vssver.scc;v…

Web 开发的 20 个实用网站

Web 开发的 20 个实用网站 作为一名前端开发工程师&#xff0c;我们一定使用过很多工具来提高自己的工作效率。它们可以是网站、文档或 JavaScript 库。 本文将分享30个有趣的网站。 JavaScript正则表达式可视化工具 https://jex.im/regulex/#!flags&re%5E(a%7Cb)*%3F%…

金南瓜SECS/GEM C# SDK 快速使用指南

本文对如何使用金南瓜SECS/GEM C# SDK 快速创建一个满足SECS/GEM通信要求的应用程序&#xff0c;只需简单3步完成。 第一步&#xff1a;创建C# .NET程序 示例使用Visual Studio 2010&#xff0c;使用者可以选择更高级版本 Visual Studio 第二步&#xff1a;添加DLL库引用&am…

力扣37. 解数独(java回溯解法)

Problem: 37. 解数独 文章目录 题目描述思路解题方法复杂度Code 题目描述 思路 该题可以使用回溯来模拟穷举。回溯问题通常涉及到可选列表&#xff0c;决策阶段&#xff0c;决策路径&#xff0c;而对于本题目我们选择将棋盘的每一个格子作为决策阶段&#xff0c;为此我们应该解…

短视频ai剪辑分发矩阵系统源码3年技术团队开发搭建打磨

如果您需要搭建这样的系统&#xff0c;建议您寻求专业的技术支持&#xff0c;以确保系统的稳定性和安全性。 在搭建短视频AI剪辑分发矩阵系统时&#xff0c;您需要考虑以下几个方面&#xff1a; 1. 技术实现&#xff1a;您需要选择适合您的需求和预算的技术栈&#xff0c;例如使…

STM32 配置TIM定时中断常用库函数

单片机学习&#xff01; 目录 ​编辑 1. 函数TIM_DeInit 2. 函数TIM_TimeBaseInit 配置时基单元 3. 函数TIM_TimeBaseStructInit 4. 函数TIM_Cmd 运行控制 5. 函数TIM_ITConfig 中断输出控制 6. 时基单元的时钟选择函数 6.1 函数TIM_InternalClockConfig 6.2 函数 TIM…

【图论笔记】克鲁斯卡尔算法(Kruskal)求最小生成树

【图论笔记】克鲁斯卡尔算法&#xff08;Kruskal&#xff09;求最小生成树 适用于 克鲁斯卡尔适合用来求边比较稀疏的图的最小生成树 简记&#xff1a; 将边按照升序排序&#xff0c;选取n-1条边&#xff0c;连通n个顶点。 添加一条边的时候&#xff0c;如何判断能不能添加…

python数据分析小案例:天猫订单数据综合分析

嗨喽~大家好呀&#xff0c;这里是魔王呐 ❤ ~! python更多源码/资料/解答/教程等 点击此处跳转文末名片免费获取 本数据集共收集了发生在一个月内的28010条数据&#xff0c;包含以下&#xff1a; 7个字段说明 订单编号&#xff1a;订单编号 总金额&#xff1a;订单总金额 买…

if语句和switch语句来确定金额之下的优惠折扣

一、优惠规则 输入相应的金额&#xff0c;可以获得规则之下&#xff0c;金额相应的享受的折扣&#xff0c;需要先定义金额&#xff0c;然后就是使用if语句进行判断&#xff0c;使用switch语句选择判断规则之下对应的优惠折扣。 二、相关代码 public class DiscountPrice {p…

Dockerfile文件

什么是dockerfile? Dockerfile是一个包含用于组合映像的命令的文本文档。可以使用在命令行中调用任何命令。 Docker通过读取Dockerfile中的指令自动生成映像。 docker build命令用于从Dockerfile构建映像。可以在docker build命令中使用-f标志指向文件系统中任何位置的Docke…

ConvNeXt V2: Co-designing and Scaling ConvNets with Masked Autoencoders

1.关于稀疏卷积的解释&#xff1a;https://zhuanlan.zhihu.com/p/382365889 2. 答案&#xff1a; 在深度学习领域&#xff0c;尤其是计算机视觉任务中&#xff0c;遮蔽图像建模&#xff08;Masked Image Modeling, MIM&#xff09;是一种自监督学习策略&#xff0c;其基本思想…

rpc原理与应用

IPC和RPC&#xff1f; RPC 而RPC&#xff08;Remote Procedure Call&#xff09;&#xff0c;又叫做远程过程调用。它本身并不是一个具体的协议&#xff0c;而是一种调用方式。 gRPC 是 Google 最近公布的开源软件&#xff0c;基于最新的 HTTP2.0 协议&#xff0c;并支持常见…

【计算机网络】HTTPS协议原理

目录 一. HTTPS的基础概念 二. 概念准备 1. 密码学 2. 为什么要加密 三. 常见加密方式 1. 对称加密 2. 非对称加密 四. HTTPS原理探究 五. CA认证 1. 数据指纹&&数据摘要 2. 证书 3. 签名与验证 4. 琐碎知识点 5. 总结——完整流程 结束语 一. HTTPS的基…

开发猿的平平淡淡周末---2023/12/9

上周回顾 完成了遗留的开发任务&#xff0c;基本全部完成进一步了解了系统当时设计的原理熟悉了代码的重构 2023.12.9 天气晴 温度适宜 前言 小伙伴们大家好&#xff0c;时间很快&#xff0c;又来到了周末&#xff0c;也是一个平平淡淡的周末。上周只更了一篇博客...原…
最新文章