优惠卷秒杀(并发问题)

Redis实战篇 | Kyle's Blog (cyborg2077.github.io)

目录

一、Redis实现全局唯一id

二、添加优惠卷

 三、实现秒杀下单

 四、解决超卖问题(库存为负)

乐观锁解决超卖问题(CAS法)

五、实现一人一单

​编辑 悲观锁解决一人一单问题

六、集群环境下的并发问题(引出分布式锁)


一、Redis实现全局唯一id

  • 在各类购物App中,都会遇到商家发放的优惠券
  • 当用户抢购商品时,生成的订单会保存到tb_voucher_order表中,而订单表如果使用数据库自增ID就会存在一些问题
    1. id规律性太明显
    2. 受单表数据量的限制
  • 如果我们的订单id有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出我们的一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适
  • 随着我们商城的规模越来越大,MySQL的单表容量不宜超过500W,数据量过大之后,我们就要进行拆库拆表,拆分表了之后,他们从逻辑上讲,是同一张表,所以他们的id不能重复,于是乎我们就要保证id的唯一性
  • 那么这就引出我们的全局ID生成器
  • 为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息
  • ID组成部分
    • 符号位:1bit,永远为0
    • 时间戳:31bit,以秒为单位,可以使用69年(2^31秒约等于69年)
    • 序列号:32bit,秒内的计数器,支持每秒传输2^32个不同ID

全局唯一id: 

@Component
public class RedisIdWorker {/*** 开始时间戳*/private static final long BEGIN_TIMESTAMP = 1640995200L;/*** 序列号的位数*/private static final long COUNT_BITS = 32L;@Autowiredprivate StringRedisTemplate stringRedisTemplate;// 根据传进的参数区分不同的业务,生成唯一idpublic long nextId(String keyPrefix){// 1. 生成时间戳//获取当前时间 转换为秒, 当前时间减起始时间为时间戳LocalDateTime now = LocalDateTime.now();long nowSecond = now.toEpochSecond(ZoneOffset.UTC);long nowTimeStamp = nowSecond - BEGIN_TIMESTAMP;// 2. 生成序列号//获取当前日期,精确到天, 设置序列号自增长String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);// 3. 拼接并返回return nowTimeStamp << COUNT_BITS | count;}
}

二、添加优惠卷

由于这里并没有后台管理页面,所以我们只能用POSTMAN模拟发送请求来新增秒杀券,请求路径http://localhost:8081/voucher/seckill, 请求方式POST

新增普通券,也就只是将普通券的信息保存到表中

/*** 新增普通券* @param voucher 优惠券信息* @return 优惠券id*/
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {voucherService.save(voucher);return Result.ok(voucher.getId());
}

新增秒杀券主要看addSeckillVoucher中的业务逻辑 

/*** 新增秒杀券* @param voucher 优惠券信息,包含秒杀信息* @return 优惠券id*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {voucherService.addSeckillVoucher(voucher);return Result.ok(voucher.getId());
}

秒杀券可以看做是一种特殊的普通券,将普通券信息保存到普通券表中,同时将秒杀券的数据保存到秒杀券表中,通过券的ID进行关联

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {// 保存优惠券save(voucher);// 保存秒杀信息SeckillVoucher seckillVoucher = new SeckillVoucher();// 关联普通券idseckillVoucher.setVoucherId(voucher.getId());// 设置库存seckillVoucher.setStock(voucher.getStock());// 设置开始时间seckillVoucher.setBeginTime(voucher.getBeginTime());// 设置结束时间seckillVoucher.setEndTime(voucher.getEndTime());// 保存信息到秒杀券表中seckillVoucherService.save(seckillVoucher);
}

 三、实现秒杀下单

实现类 

@Service
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@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("库存不足");}// 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(全局唯一id)long orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 6.2.用户id (从拦截器中获取)Long userId = UserHolder.getUser().getId();voucherOrder.setUserId(userId);// 6.3.代金券idvoucherOrder.setVoucherId(voucherId);// 6.4.保存订单save(voucherOrder);// 7.返回订单idreturn Result.ok(orderId);}
}

接口

public interface IVoucherOrderService extends IService<VoucherOrder> {Result seckillVoucher(Long voucherId);
}

Controller 

@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {@Autowiredprivate IVoucherOrderService voucherOrderService;@PostMapping("seckill/{id}")public Result seckillVoucher(@PathVariable("id") Long voucherId) {return voucherOrderService.seckillVoucher(voucherId);}
}

 四、解决超卖问题(库存为负)

实现秒杀下单的代码其实是有问题的,当遇到高并发场景时,会出现超卖现象,我们可以用Jmeter开200个线程来模拟抢优惠券的场景。

测试完毕之后,查看数据库中的订单表,我们明明只设置了100张优惠券,却有166条数据,去优惠券表查看,库存为-66,超卖了66张 

  • 那么如何解决这个问题呢?先来看看我们的代码中是怎么写的
//4. 判断库存是否充足
if (seckillVoucher.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也过来查询库存,发现库存数也大于1,那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题

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

 

乐观锁解决超卖问题(CAS法)

以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败。因为我们还需要判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作 

修改“实现秒杀下单”的代码为:

@Service
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@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("库存不足");}// 5.库存充足 扣减库存// 乐观锁解决超卖问题boolean success = seckillVoucherService.update().setSql("stock = stock - 1")// set stock = stock - 1.eq("voucher_id", voucherId).gt("stock", 0)// where stock > 0 库存大于0就扣减库存.update();if (!success) {return Result.fail("库存不足");}// 6.创建订单VoucherOrder voucherOrder = new VoucherOrder();// 6.1.订单id(全局唯一id)long orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 6.2.用户id (从拦截器中获取)Long userId = UserHolder.getUser().getId();voucherOrder.setUserId(userId);// 6.3.代金券idvoucherOrder.setVoucherId(voucherId);// 6.4.保存订单save(voucherOrder);// 7.返回订单idreturn Result.ok(orderId);}
}

五、实现一人一单

  • 需求:修改秒杀业务,要求同一个优惠券,一个用户只能抢一张
  • 具体操作逻辑如下:我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在
    • 如果已存在,则不能下单,返回错误信息
    • 如果不存在,则继续下单,获取优惠券

 悲观锁解决一人一单问题

 实现类

@Service
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Overridepublic Result seckillVoucher(Long voucherId) {LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();//1. 查询优惠券queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);//2. 判断秒杀时间是否开始if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {return Result.fail("秒杀还未开始,请耐心等待");}//3. 判断秒杀时间是否结束if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {return Result.fail("秒杀已经结束!");}//4. 判断库存是否充足if (seckillVoucher.getStock() < 1) {return Result.fail("优惠券已被抢光了哦,下次记得手速快点");}Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {// 获取当前代理对象 确保下面的事务生效IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);}}@Transactionalpublic Result createVoucherOrder(Long voucherId) {// 一人一单逻辑Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();if (count > 0) {return Result.fail("你已经抢过优惠券了哦");}//5. 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();if (!success) {return Result.fail("库存不足");}//6. 创建订单VoucherOrder voucherOrder = new VoucherOrder();//6.1 设置订单idlong orderId = redisIdWorker.nextId("order");//6.2 设置用户idLong id = UserHolder.getUser().getId();//6.3 设置代金券idvoucherOrder.setVoucherId(voucherId);voucherOrder.setId(orderId);voucherOrder.setUserId(id);//7. 将订单数据保存到表中save(voucherOrder);//8. 返回订单idreturn Result.ok(orderId);}//执行到这里,锁已经被释放了,但是可能当前事务还未提交,如果此时有线程进来,不能确保事务不出问题}
}

启动类添加

@EnableAspectJAutoProxy(exposeProxy = true)

 依赖

        <dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId></dependency>

接口

public interface IVoucherOrderService extends IService<VoucherOrder> {Result seckillVoucher(Long voucherId);Result createVoucherOrder(Long voucherId);
}

六、集群环境下的并发问题(引出分布式锁)

  • 通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了

    1. 我们将服务启动两份,端口分别为8081和8082
    2. 然后修改nginx的config目录下的nginx.conf文件,配置反向代理和负载均衡(默认轮询就行)
  • 具体操作,我们使用POSTMAN发送两次请求,header携带同一用户的token,尝试用同一账号抢两张优惠券,发现是可行的。

  • 失败原因分析:由于我们部署了多个Tomcat,每个Tomcat都有一个属于自己的jvm,那么假设在服务器A的Tomcat内部,有两个线程,即线程1和线程2,这两个线程使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。但是如果在Tomcat的内部,又有两个线程,但是他们的锁对象虽然写的和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2互斥

  • 这就是集群环境下,syn锁失效的原因,在这种情况下,我们需要使用分布式锁来解决这个问题,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁(Redis)

1. 添加Tomcat 形成集群

 改端口

 

2. 修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡


worker_processes  1;events {worker_connections  1024;
}http {include       mime.types;default_type  application/json;sendfile        on;keepalive_timeout  65;server {listen       8080;server_name  localhost;# 指定前端项目所在的位置location / {root   html/hmdp;index  index.html index.htm;}error_page   500 502 503 504  /50x.html;location = /50x.html {root   html;}//反向代理location /api {  default_type  application/json;#internal;  keepalive_timeout   30s;  keepalive_requests  1000;  #支持keep-alive  proxy_http_version 1.1;  rewrite /api(/.*) $1 break;  proxy_pass_request_headers on;#more_clear_input_headers Accept-Encoding;  proxy_next_upstream error timeout;  #proxy_pass http://127.0.0.1:8081;proxy_pass http://backend;}}
//负载均衡(轮询)upstream backend {server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;}  
}

 启动nginx输入该命令重新配置nginx

nginx.exe -s reload

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

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

相关文章

自主可控的工业操作系统

supOS是蓝卓自主研发、拥有自主知识产权的工业操作系统&#xff0c;向下可以连接各种设备和信息系统&#xff0c;将所有数据汇聚到“统一的底座”&#xff0c;向上可以打通各种各样的应用系统&#xff0c;各类生态企业可以在系统之上打造出丰富的工业APP&#xff0c;让我们的工…

有没有屏幕悬浮翻译软件?打开窗口即可实时翻译

随着#高考结束该出发看世界了#这一话题的火热&#xff0c;对于不能远行的朋友&#xff0c;网上冲浪是了解世界的好方式。 然而&#xff0c;面对外语网页、资料或视频&#xff0c;英语不流利的小伙伴可能会有些困扰。别急&#xff0c;悬浮翻译软件能帮你将屏幕上的外语文字即时…

从WebM到MP3:利用Python和wxPython提取音乐的魔法

前言 有没有遇到过这样的问题&#xff1a;你有一个包含多首歌曲的WebM视频文件&#xff0c;但你只想提取其中的每一首歌曲&#xff0c;并将它们保存为单独的MP3文件&#xff1f;这听起来可能有些复杂&#xff0c;但借助Python和几个强大的库&#xff0c;这个任务变得异常简单。…

第二证券:港股、a股、美股的区别?存在以下七大区别

港股、a股、美股是三大重要的股票市场&#xff0c;其间它们之间存在以下差别&#xff1a; ​ 1、界说不同 A股&#xff0c;即公民币普 通股&#xff0c;是由中国境内公司发行供境内机构、组织或个人以公民 币认购和生意的普通股股票。 港股也叫H股&#xff0c;在香港联合生…

【WEB前端2024】3D智体编程:乔布斯3D纪念馆-第46课-使用json文件

【WEB前端2024】3D智体编程&#xff1a;乔布斯3D纪念馆-第45课-使用头像 使用dtns.network德塔世界&#xff08;开源的智体世界引擎&#xff09;&#xff0c;策划和设计《乔布斯超大型的开源3D纪念馆》的系列教程。dtns.network是一款主要由JavaScript编写的智体世界引擎&…

蓝牙耳机怎么连接手机?1篇文章教你配对,畅享音乐之旅

无论是在健身房锻炼、上下班通勤&#xff0c;还是在户外旅行时&#xff0c;蓝牙耳机都能为我们提供无线的音乐体验&#xff0c;让我们的生活更加便捷。 然而&#xff0c;对于一些初次使用蓝牙耳机的用户来说&#xff0c;如何将其与手机成功配对可能是一个令人头疼的问题。蓝牙…

Consul 如何删除不需要的服务

一、找到需要删除的id 二、打开postman 使用put请求 http://ip:port/v1/agent/service/deregister/mc-admin-192-168-0-182-8084三、区域如果要验证输入验证

Linux_理解进程地址空间和页表

目录 1、进程地址空间示意图 2、验证进程地址空间的结构 3、验证进程地址空间是虚拟地址 4、页表-虚拟地址与物理地址 5、什么是进程地址空间 6、进程地址空间和页表的存在意义 6.1 原因一&#xff08;效率性&#xff09; 6.2 原因二&#xff08;安全性&#xff09; …

WeiXin Live Broadcast 2024.06.21

WeiXin Live Broadcast 2024.06.21 微信直播流程 微信直播&#xff0c;如果需要展示主播以及现场画面&#xff0c;需要摄像头和电脑端的直播推流软件。 基础设备&#xff1a;1&#xff09;手机 2&#xff09;笔记本带摄像头 3&#xff09;手机数据线连接笔记本 4&#xff09…

“Docker之道:优雅管理容器数据的艺术“

目录 1. 容器数据卷 1.1 容器卷的概念 1.2 容器卷的使用 1.3 个人案例实现容器卷挂载 1.4 MySQL同步数据案例&#xff08;容器数据卷操作&#xff09; 1.4.1 获取 MySQL 镜像 1.4.2 运行 MySQL 容器并配置数据持久化 参数说明&#xff1a; 1.4.3 测试 MySQL 连接 1.4…

springboot相关的一些知识

SpringBoot可以同时处理多少请求 SpringBoot默认的内嵌容器是Tomcat&#xff0c;所以SpringBoot可以同时处理多少请求取决于Tomcat。 SpringBoot中处理请求数量相关的参数有四个&#xff1a; server.tomcat.thread.min-spare&#xff1a;最少的工作线程数&#xff0c;默认大小…

8小时出500杯,投诉三次辞退?Manner逼疯员工…?

一边歇斯底里的咆哮&#xff1a;「你投诉啊」&#xff01;一边将咖啡粉泼向顾客……一场大战要不是隔着岛台&#xff0c;就真的燃起来了……‍ 好巧不巧&#xff0c;同一天&#xff0c;另一段视频中的顾客就没那么好运了&#xff0c;男店员冲上去就给女顾客一个耳光……‍‍ 想…

HUSKY:一个优化大语言模型多步推理的新代理框架

推理被高度认可为生成人工智能的下一个前沿领域。通过推理&#xff0c;我们可以将任务分解为更小的子集并单独解决这些子集。例如以前的论文&#xff1a;思维链、思维树、思维骨架和反射&#xff0c;都是最近解决LLM推理能力的一些技术。此外推理还涉及一些外围功能&#xff0c…

GPT-4o目前暂无音频输出的能力

OpenAI的发布会惊艳的操作&#xff0c;近乎实时的语音对话&#xff0c;让很多人向往。 但实际上Chat对话时&#xff0c;尚无输出音频的能力&#xff0c;可能还未开放。 这是国外的一个开发小哥的交流帖子&#xff0c;可能还需要些时日才能用的上实时的音频输出。 不过当前Op…

OpenGL Super Bible 7th-Primitives, Pipelines, and Pixels图元、渲染管线与像素

简介 本文的原版为《OpenGL Super Bible 7th》,是同事给我的,翻译是原文+译文的形势。文章不属于机器直译,原因在于语言不存在一一对应的关系,我将尽可能的按照中国人看起来舒服的方式来翻译这些段子,如果段子让你感到身心愉悦,那还劳烦点个关注,追个更。如果我没有及时…

如何解决压缩软件无法打开文件的常见问题

压缩软件是我们日常生活和工作中常用的工具&#xff0c;无论是传输文件、节省存储空间还是组织数据&#xff0c;它们都能发挥重要作用。然而&#xff0c;偶尔也会遇到压缩软件无法打开文件的情况&#xff0c;这可能令人困惑和沮丧。本文将探讨几种常见原因&#xff0c;并提供解…

Ubuntu 20.04安装显卡驱动、CUDA和cuDNN(2024.06最新)

一、安装显卡驱动 1.1 查看显卡型号 lspci | grep -i nvidia我们发现输出的信息中有Device 2230&#xff0c;可以根据这个信息查询显卡型号 查询网址&#xff1a;https://admin.pci-ids.ucw.cz/mods/PC/10de?actionhelp?helppci 输入后点击Jump查询 我们发现显卡型号为RTX …

双阶段目标检测算法

双阶段目标检测算法 本文将系统的过一遍双阶段目标检测的经典算法&#xff0c;文献阅读顺序如下&#xff1a; R-CNN → \rightarrow → SPPnet → \rightarrow → Fast R-CNN → \rightarrow → Faster R-CNN → \rightarrow → Mask R-CNN R-CNN 一、研究背景 R-CNN可…

PHP转Go系列 | 条件循环的使用姿势

大家好&#xff0c;我是码农先森。 条件 在 PHP 语言中条件控制语句&#xff0c;主要有 if、elseif、else 和 switch 语句 // if、elseif、else 语句 $word "a"; if ($word "a") {echo "a"; } elseif ($word "b") {echo "b&…

手机怎么恢复回收站清空的照片?这里有找回相册的3个重要指南

生活里&#xff0c;从家庭聚会的美好瞬间到重要的工作文件&#xff0c;都在我们的手机相册中留下了痕迹。然而意外常常降临&#xff0c;这些数据也有可能突然消失不见&#xff0c;并且恢复回收站清空的照片失败的情况也不少见。请别着急&#xff0c;在这篇文章中&#xff0c;我…