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

全局唯一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@Overridepublic 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();//订单idlong orderId = redisIdWorker.nextId("order");//用户idLong 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@Overridepublic 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();//订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setUserId(userId);voucherOrder.setVoucherId(voucherId);voucherOrder.setId(orderId);save(voucherOrder);return Result.ok(orderId);}

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

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

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

最终实现

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

 @Autowiredprivate ISeckillVoucherService seckillVoucherService;@Autowiredprivate RedisIdWorker redisIdWorker;@Autowiredprivate IVoucherOrderService voucherOrderService;@Overridepublic 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);}}@Transactionalpublic 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();//订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setUserId(userId);voucherOrder.setVoucherId(voucherId);voucherOrder.setId(orderId);save(voucherOrder);return Result.ok(orderId);}

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

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

JavaScript关于函数的小挑战

题目 回到两个体操队&#xff0c;即海豚队和考拉队! 有一个新的体操项目&#xff0c;它的工作方式不同。 每队比赛3次&#xff0c;然后计算3次得分的平均值&#xff08;所以每队有一个平均分&#xff09;。 只有当一个团队的平均分至少是另一个团队的两倍时才会获胜。否则&…

排盘程序算法探寻举例(陆先生八字)

算法实现&#xff1a; 1.庚生未月&#xff0c;燥土不能生金&#xff0c;日支申金为日主墙根&#xff0c;月干辛金比劫透出傍身&#xff0c;月干强。年干甲木自做寅木强根&#xff0c;又得月支乙木中气&#xff0c;甲木强旺有力&#xff0c;时干丙火七杀得未土余气&#xff0c;…

Spring 中存取 Bean 的相关注解

目录 一、五大类注解 1、五大类注解存储Bean对象 1.1Controller(控制器储存) 1.2Service(服务存储) 1.3Repository(仓库存储) 1.4Component(组件存储) 1.5Configuration(配置存储) 2、五大类注解小结 2.1为什么要这么多类注解 2.2 五大类注解之间的关系 二、方法注解 1.方法注…

linux编程第一部分总结

C多线程安全原则 对象析构很复杂&#xff0c;我们采用shared_ptr和weak_ptr来做 enable_shared_from_this<>是用来做回调的&#xff0c;因为多线程中可能对象的生命周期比传出去的this指针短&#xff0c;同时为了不延长对象的生命周期&#xff0c;我们把shared_ptr转成we…

c++ vs2019 cpp20规范的STL库的map与multimap源码分析

map就是一个红黑树。 标准平衡二叉树&#xff0c;要求左右子树的高度差不超过1 。红黑树只要求左右子树的高度差不超过一倍即可。兼顾了树平衡与效率。避免了AVL树的频繁调整树平衡。 b站 的“可雷曼土”大师&#xff0c;讲红黑树的理论讲的很透彻&#xff0c;再结合看代码&…

Linux-安装redis6.2.1及主备复制模式(replication)

Linux-安装redis6.2.1 下载redis6.2.1资源上传至安装目录解压及编译解压修改名称编译 修改配置文件主节点从节点 启动及测试启动主节点从节点 测试 下载redis6.2.1资源 地址》https://redis.io/download/ 上传至安装目录 例&#xff1a;/data/replication/ 解压及编译 解…

二进制安全虚拟机Protostar靶场 安装,基础知识讲解,破解STACK ZERO

简介 pwn是ctf比赛的方向之一&#xff0c;也是门槛最高的&#xff0c;学pwn前需要很多知识&#xff0c;这里建议先去在某宝上买一本汇编语言第四版&#xff0c;看完之后学一下python和c语言&#xff0c;python推荐看油管FreeCodeCamp的教程&#xff0c;c语言也是 pwn题目大部…

Jupyter lab 配置

切换jupyterlab的默认工作目录 在终端中输入以下命令 PS C:\Users\Administrator> jupyter-lab --generate-config Writing default config to: C:\Users\Administrator\.jupyter\jupyter_lab_config.py它就会生成JupyterLab的配置文件&#xff08;如果之前有这个文件的话…

(笔记五)利用opencv进行图像几何转换

参考网站&#xff1a;https://docs.opencv.org/4.1.1/da/d6e/tutorial_py_geometric_transformations.html &#xff08;1&#xff09;读取原始图像和标记图像 import cv2 as cv import numpy as np from matplotlib import pyplot as pltpath r"D:\data\flower.jpg&qu…

【数据结构】 二叉树面试题讲解->叁

文章目录 &#x1f30f;引言&#x1f332;[根据二叉树创建字符串](https://leetcode.cn/problems/construct-string-from-binary-tree/submissions/)&#x1f431;‍&#x1f464;题目描述&#xff1a;&#x1f431;‍&#x1f409;示例&#xff1a;&#x1f4cc;示例一&#x…

Flutter小功能实现-咖啡店

1 导航栏实现 效果图&#xff1a; 1.Package google_nav_bar: ^5.0.6 使用文档&#xff1a; google_nav_bar | Flutter Package 2.Code //MyBottomNavBar class MyBottomNavBar extends StatelessWidget {void Function(int)? onTabChange;MyBottomNavBar({super.key, …

iOS swift5 扫描二维码

文章目录 1.生成二维码图片2.扫描二维码&#xff08;含上下扫描动画&#xff09;2.1 记得在info.plist中添加相机权限描述 1.生成二维码图片 import UIKit import CoreImagefunc generateQRCode(from string: String) -> UIImage? {let data string.data(using: String.En…

若依 vue中el-radio无法默认选中

网上看了很多方法都不管用, 即便是element官方示例方法也不行 解决方法: html <el-form-item label"是否公开" prop"isOpen"><el-radio-group v-model"form.isOpen"><el-radio :label"0">不公开</el-radio>…

跳出Lambda表达式forEach()循环解决思路

背景 在一次需求开发时&#xff0c;发现使用Lambda的forEach()跳不出循环。如下示例代码&#xff0c;想在遍历满足条件时跳出循环。 public static void main(String[] args) {List<Integer> list Arrays.asList(1, 4, 5, 7, 9, 11);list.forEach(e -> {if (e % 2 …

Leetcode Top 100 Liked Questions(序号236~347)

236. Lowest Common Ancestor of a Binary Tree 题意&#xff1a;二叉树&#xff0c;求最近公共祖先&#xff0c;All Node.val are unique. 我的思路 首先把每个节点的深度得到&#xff0c;之后不停向上&#xff0c;直到val相同&#xff0c;存深度就用map存吧 但是它没有向…

1分钟实现 CLIP + Annoy + Gradio 文搜图+图搜图 系统

多模态图文搜索系统 CLIP 进行 Text 和 Image 的语义EmbeddingAnnoy 向量数据库实现树状结构索引来加速最近邻搜索Gradio 轻量级的机器学习 Web 前端搭建 文搜图 图搜图 CLIP图像语义提取功能&#xff01;

ip route get ip地址 应用案例

应用场景 在做虚拟化实验用的虚拟机和实际的ECS云主机一般都会有多个网卡&#xff0c;网络的联通性是经常碰到的问题。比如在一个VM上有3个网卡&#xff0c;分别为ens160(和寄主机进行桥接的网卡10.0.0.128)、ens224&#xff08;连接仅主机网络10.0.0.0/24的网卡10.0.0.128&…

react17:生命周期函数

挂载时更新时 setState触发更新、父组件重新渲染时触发更新forceUpdate触发更新卸载时 react&#xff08;v17.0.2&#xff09;的生命周期图谱如下。 相较于16版本&#xff0c;17版本生命周期函数有如下变化&#xff1a; componentWillMount() componentWillUpdate() compone…

【C++】学习STL中的stack和queue

❤️前言 今天这篇博客的内容主要关于STL中的stack、queue和priority_queue三种容器。 正文 stack和queue的使用方式非常简单&#xff0c;我们只要根据之前学习数据结构的经验和文档介绍就可以轻松上手。于是我们直接开始对它们的模拟实现。 stack和queue的模拟实现 stack和q…

内部类总结

内部类 1、内部类介绍&#xff1a; 外 2、成员内部类&#xff1a; 3、静态内部类 4、局部内部类&#xff1a; 5、匿名内部类&#xff1a;