redis实战-实现优惠券秒杀解决超卖问题

news2024/11/29 12:43:12

全局唯一ID

唯一ID的必要性

每个店铺都可以发布优惠券:

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

  • id的规律性太明显,容易被用户根据id的间隔来猜测到销量等商业信息,不够保密

  • 受单表数据量的限制,mysql的id自增长有数值约束,且数据量大的情况下会进行分库分表,表不同自增长id可能相同,在分布式系统中是不允许的

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

Redis恰好满足以上特性,为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:

ID的组成部分:符号位:1bit,永远为0

时间戳:31bit,以秒为单位,可以使用69年

序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID,这个序列号足够大,几乎不可能到达极限

redis实现全局唯一ID

获取当前时间戳的秒数

LocalDateTime time = LocalDateTime.of(2023, 9, 2, 0, 0, 0);
        long of = time.toEpochSecond(ZoneOffset.UTC);

生成序列号,自增长的key为了防止一直使用该key,最后导致达到redis的上限,故需要拼接上日期,既防止达到上限又能方便统计同一天的下单量

 //开始时间戳秒数
    private static final long BEGIN_TIMESTAMP = 1693612800L;
    //序列号位数
    private static final int COUNT_BITS = 32;
    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public long nextId(String keyPrefix) {
        //生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;
        //利用redis的自增生成序列号
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        Long increment = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        //拼接

        return timestamp << COUNT_BITS | increment;
    }

添加优惠券

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:

平价卷由于优惠力度并不是很大,所以是可以任意领取

而代金券由于优惠力度大,所以像第二种卷,就得限制数量,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段

添加特价券

{"shopId":1,
"title":"100元代金券",
"subTitle":"周一至周五均可使用",
"rules":"全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
"payValue":8000,
"actualValue":10000,
"type":1,
"stock":100,
"beginTime":"2023-09-02T10:09:17",
"endTime":"2023-09-26T12:09:04"
}

由于没有后台管理系统,故使用postman进行post请求添加,需要关闭拦截器,同时设置有效的开始时间和结束时间,优惠券才会显示

实现秒杀下单

下单核心思路:当我们点击抢购时,会触发右侧的请求,我们只需要编写对应的controller即可,service层编写对应的代码操作数据库即可

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单

  • 库存是否充足,不足则无法下单

下单核心逻辑分析:

当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件

比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束

代码实现

由于涉及到优惠券表和优惠券订单表两张表的dml操作,需要加上@Transactional声明事务

@Transactional
    @Override
    public Result seckillVoucher(Long voucherId) {
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        //判断是否开始,开始时间如果在当前时间之后就是尚未开始
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始");
        }
        //判断是否结束,结束时间如果在当前时间之前就是已经结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }
        //判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("库存不足");
        }
        boolean success = seckillVoucherService.update()
                .setSql("stock= stock -1")
                .eq("voucher_id", voucherId).update();
        if (!success) {
            return Result.fail("库存不足");
        }
        VoucherOrder voucherOrder = new VoucherOrder();
        //订单id
        long orderId = redisIdWorker.nextId("order");
        //用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setId(orderId);
        save(voucherOrder);
        return Result.ok(orderId);
    }

超卖问题

模拟实现

使用jmeter模拟实现,注意带上请求头authorization,值为登录时的token的key

从数据库的库存中我们可以看到已经出现了超卖现象,库存出现了负数

超卖原因

我们原有的代码是这么写的

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("库存不足!");
    }

假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。

解决方案

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案:见下图:

悲观锁:

悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等

乐观锁:

乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas,即查值进行比对,发现值没有被修改,认为线程安全进行修改值,解决线程安全问题

解决方案实现

采用乐观锁方案,对于优惠券库存我们并需要设置版本号,因为查询到的库存和最后修改数据时再查第二遍库存后,我们只需要将这两次库存量进行比较,就能知道库存是否被修改过即线程是否安全,且为了性能,我们会将修改数据时设置的条件,并不需要两次库存完全相同,只需要在进行修改时,加上库存大于0的条件即可,上面代码只需要修改此处即可

boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).gt("stock",0).update();

开了两百个线程之后,异常率达到完美的50%,同时数据库数据正常

一人一单

优惠卷是为了引流,但是目前的情况是,一个人可以无限制的抢这个优惠卷,所以我们应当增加一层逻辑,让一个用户只能下一个单,而不是让一个用户下多个单

具体操作逻辑如下:比如时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单

初步实现,在扣减库存前查询订单表,该用户是否已经下过单

 @Transactional
    @Override
    public Result seckillVoucher(Long voucherId) {
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        //判断是否开始,开始时间如果在当前时间之后就是尚未开始
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始");
        }
        //判断是否结束,结束时间如果在当前时间之前就是已经结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }
        //判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("库存不足");
        }
        Long userId = UserHolder.getUser().getId();
        // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }
        //扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock= stock -1")
                .eq("voucher_id", voucherId).gt("stock",0).update();
        if (!success) {
            return Result.fail("库存不足");
        }
        VoucherOrder voucherOrder = new VoucherOrder();
        //订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setId(orderId);
        save(voucherOrder);
        return Result.ok(orderId);
    }

 还是出现了一人多张优惠券订单的情况

存在问题:现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作,可以直接在方法上直接加上synchronized 锁来解决

这样添加锁,锁的粒度太粗了,在使用锁过程中,控制锁粒度 是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的粒度,可以将用户下单的代码封装成一个方法,对该业务进行上锁,将锁的范围缩小,同时由于spring的事务必须等到锁释放之后才会提交,如果锁释放之后,有别的线程进入下单业务,而此时spring事务尚未提交,这就会造成订单尚未写入数据库,该线程仍会查到无订单,继续进行下单操作无法解决线程安全问题,所以我们要先提交事务才能释放锁,就能避免该问题。

最终实现

由于createVoucherOrder()要受事务控制,要注入IVoucherOrderService拿到代理对象,通过该代理对象调用该方法,事务才能生效,为了使事务提交在释放锁之前,可以将锁直接锁死事务方法。

 @Autowired
    private ISeckillVoucherService seckillVoucherService;
    @Autowired
    private RedisIdWorker redisIdWorker;
    @Autowired
    private IVoucherOrderService voucherOrderService;

    @Override
    public Result seckillVoucher(Long voucherId) {
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        //判断是否开始,开始时间如果在当前时间之后就是尚未开始
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始");
        }
        //判断是否结束,结束时间如果在当前时间之前就是已经结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束");
        }
        //判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("库存不足");
        }
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            return voucherOrderService.createVoucherOrder(voucherId);
        }
    }

    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        Long userId = UserHolder.getUser().getId();
        // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }
        //扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock= stock -1")
                .eq("voucher_id", voucherId).gt("stock", 0).update();
        if (!success) {
            return Result.fail("库存不足");
        }
        VoucherOrder voucherOrder = new VoucherOrder();
        //订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setId(orderId);
        save(voucherOrder);
        return Result.ok(orderId);
    }

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

不同路径【动态规划】

不同路径 一个机器人位于一个 m x n 网格的左上角 &#xff08;起始点在下图中标记为 “Start” &#xff09;。 机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角&#xff08;在下图中标记为 “Finish” &#xff09;。 问总共有多少条不同的路径&#xff1f;…

python 美国总统身高统计与分析

美国总统身高统计与分析 1.安装依赖2.下载数据集3.数据处理4.结果展示 1.安装依赖 pip install pandas pip install numpy pip install matplotlib2.下载数据集 链接&#xff1a;https://pan.baidu.com/s/1aZLtkLyvQvRLb9tJ-B1krA 提取码&#xff1a;thms –来自百度网盘超级…

Spring Cloud 系列之OpenFeign:(8)链路追踪续

传送门 Spring Cloud Alibaba系列之nacos&#xff1a;(1)安装 Spring Cloud Alibaba系列之nacos&#xff1a;(2)单机模式支持mysql Spring Cloud Alibaba系列之nacos&#xff1a;(3)服务注册发现 Spring Cloud 系列之OpenFeign&#xff1a;(4)集成OpenFeign Spring Cloud …

无涯教程-JavaScript - CUBEMEMBERPROPERTY函数

描述 CUBEMEMBERPROPERTY函数从多维数据集返回成员属性的值。使用此函数可以验证多维数据集中是否存在成员名称,并返回该成员的指定属性。 语法 CUBEMEMBERPROPERTY (connection, member_expression, property)争论 Argument描述Required/OptionalconnectionName of the co…

Glide的使用及源码分析

前言 依赖 implementation com.github.bumptech.glide:glide:4.16.0 github: GitHub - bumptech/glide: An image loading and caching library for Android focused on smooth scrolling 基本使用 //加载url Glide.with(this) .load(url) .placeholder(R.drawable.placehol…

Python 没有 pip 包问题解决

最近需要搞一个干净的Python,从官网上直接下载解压可用的绿色版&#xff0c;发现无法正常使用PiP 一 官网下载Python https://www.python.org/downloads/ 选择 embeddable package,这种是免安装的包&#xff0c;解压后可以直接使用。 二 配置环境变量 添加环境变量&#xff1a…

Cortex-A7 架构

参考《 Cortex-A7 Technical ReferenceManua.pdf 》和《 ARM Cortex-A(armV7) 编程手 册 V4.0.pdf 》 【 正点原子】I.MX6U嵌入式Linux驱动开发指南V1.6学习 1.Cortex-A7 MPCore 简介 I.MX6UL 使用的是 Cortex-A7 架构&#xff0c;Cortex-A7 MPcore 处理器支持 1~4 核&#…

【云原生进阶之PaaS中间件】第一章Redis-1.3Redis配置

1 Redis配置概述 Redis支持采用其内置默认配置的方式来进行启动&#xff0c;而不需要提前配置任何文件&#xff0c;但是这种启动方式只推荐在测试和开发环境中使用&#xff0c;但更好的方式是通过提供一个Redis的配置文件来对Redis进行配置&#xff0c; 这个配置文件一般命名为…

常用的msvcp140.dll丢失的解决方法,msvcp140.dll丢失的原因

自从电脑出现故障&#xff0c;我的生活变得一团糟。他每天都需要使用电脑处理工作&#xff0c;可是突然有一天&#xff0c;他发现许多软件和游戏都无法正常运行。错误提示显示“找不到msvcp140.dll”&#xff0c;这让他感到非常困扰。今天想和大家分享一个在计算机使用过程中经…

Docker进阶:mysql 主从复制、redis集群3主3从【扩缩容案例】

Docker进阶&#xff1a;mysql 主从复制、redis集群3主3从【扩缩容案例】 一、Docker常规软件安装1.1 docker 安装 tomcat&#xff08;默认最新版&#xff09;1.2 docker 指定安装 tomcat8.01.3 docker 安装 mysql 5.7&#xff08;数据卷配置&#xff09;1.4 演示--删除mysql容器…

【计算机组成 课程笔记】4.2 除法器的硬件实现

课程链接&#xff1a; 计算机组成_北京大学_中国大学MOOC(慕课) 4 - 5 - 405-除法的运算过程&#xff08;09-43--&#xff09;_哔哩哔哩_bilibili 在加减乘除这样的基本算数运算中&#xff0c;除法是最为复杂的&#xff0c;因此我们想要实现硬件的除法器&#xff0c;还是从最简…

yolov2相较于yolov1的改进

目录 前言 BN层取代了Dropout 使用了高分辨率分类器 K-means选定先验框的尺寸 网络结构—darknet19 细粒度的特征 前言 yolov2是在yolov1的基础上进行改进的&#xff0c;主要解决了yolov1定位不准确以及检测重叠的物体极差的情况&#xff0c;总的来说&#xff0c;它有以下…

使用pip命令安装python第三方库有效方法合集

本文摘要&#xff1a;本文提出了4种实用的pip命令安装python第三方库有效方法合集 &#x1f60e; 作者介绍&#xff1a;我是程序员洲洲&#xff0c;一个热爱写作的非著名程序员。CSDN全栈优质领域创作者、华为云博客社区云享专家、阿里云博客社区专家博主、前后端开发、人工智…

【代码技巧】深度学习参数管理方案(1)

方法概述 利用argparse工具包进行参数管理 创建BaseOptions类进行基础参数的管理&#xff0c;在建立TrainOptions和TestOpetions继承BaseOptions的基础参数&#xff0c;然后可以再添train或者test阶段的新的参数。 文件结构 创建三个文件如图&#xff0c;分别管理BaseOption…

机器学习之 Jupyter Notebook 使用

&#x1f388; 作者&#xff1a;Linux猿 &#x1f388; 简介&#xff1a;CSDN博客专家&#x1f3c6;&#xff0c;华为云享专家&#x1f3c6;&#xff0c;Linux、C/C、云计算、物联网、面试、刷题、算法尽管咨询我&#xff0c;关注我&#xff0c;有问题私聊&#xff01; &…

msvcr120.dll文件丢失的解决方法,四种快速解决发方法分享

你是否曾经在使用电脑时遭遇过 msvcr120.dll 文件丢失的困扰&#xff1f;如果你对此感到茫然无措&#xff0c;那么请跟随我的脚步&#xff0c;让我们一起探索这个问题的根源。当我一如既往地打开电脑&#xff0c;准备开始一天的工作时&#xff0c;突然发现许多应用程序无法正常…

数学建模:回归分析

&#x1f506; 文章首发于我的个人博客&#xff1a;欢迎大佬们来逛逛 数学建模&#xff1a;回归分析 文章目录 数学建模&#xff1a;回归分析回归分析多元线性回归案例 多项式回归一元多项式回归多元二项式回归 非线性回归逐步回归 回归分析 多元线性回归 案例 首先进行回归分…

用C语言,编写一个这样的程序,满足五日均线,十日均线,二十日均线,六十天六日均线调头向上的选股代码

用C语言&#xff0c;编写一个这样的程序&#xff0c;满足五日均线&#xff0c;十日均线&#xff0c;二十日均线&#xff0c;六十天六日均线调头向上的选股代码&#xff0c;我来做个案例。 #include <stdio.h> #include <stdlib.h> #define MAX_ROWS 1000 // 假设数…

【小吉送书—第一期】Kali Linux高级渗透测试

文章目录 &#x1f354;前言&#x1f6f8;读者对象&#x1f388;本书资源&#x1f384;彩蛋 &#x1f354;前言 对于企业网络安全建设工作的质量保障&#xff0c;业界普遍遵循PDCA&#xff08;计划&#xff08;Plan&#xff09;、实施&#xff08;Do&#xff09;、检查&#x…

【Kubernetes理论篇】2023年最新CKA考题+解析

文章目录 第一题&#xff1a;RBAC授权访问控制第二题&#xff1a;Node节点维护第三题&#xff1a;K8S集群版本升级第四题&#xff1a;ETCD数据库备份恢复第五题&#xff1a;NetworkPolicy网络策略第六题&#xff1a;Service四层负载第七题&#xff1a;Ingress七层负载第八题&am…