概述
完整项目请见:https://gitee.com/JiaBin1
一、什么是秒杀
秒杀最直观的定义:在高并发场景下而下单某一个商品,这个过程就叫秒杀
【秒杀场景】
- 火车票抢票
- 双十一限购商品
- 热度高的明星演唱会门票
- …
二、为什么使用秒杀
早起的12306购票,刚被开发出来使用的时候,12306会经常出现 超卖 这种现象,也就是说车票只剩10张了,却被20个人买到了,这种现象就是超卖!
还有在高并发的情况下,如果说没有一定的保护措施,系统会被这种高流量造成宕机
【为什么使用秒杀】
- 严格防止超卖
- 库存100件 你卖了120件 等着辞职吧!
- 防止黑客
- 假如我们网站想下发优惠给群众,但是被黑客利用技术将下发给群众的利益收入囊中
- 保证用户体验
- 高并发场景下,网页不能打不开、订单不能支付 要保证网站的使用!
三、非并发情况下秒杀
创建数据库
-
create database stockdb; use stockdb; create table stock( id int primary key auto_increment, `name` varchar(50), `count` int, sale int, `version` int ); create table stock_order( id int primary key auto_increment, sid int, `name` varchar(50), `create_time` timestamp ); insert stock value('0','iPhone 13 Pro',15,0,0);
-
创建SpringBoot项目,添加以下依赖
-
<dependencies> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.6</version> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.48</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>
【配置文件】
-
server.port=8989 server.servlet.context-path=/ms spring.datasource.type=com.alibaba.druid.pool.DruidDataSource spring.datasource.driver-class-name=com.mysql.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/stockdb spring.datasource.username=root spring.datasource.password=ok mybatis.mapper-locations=classpath:mapper/*.xml mybatis.type-aliases-package=com.jiabin.pojo logging.level.root=info logging.level.com.jiabin.mapper:debug
-
-
创建实体类
【实体类】
-
/** * @Author 嘉宾 * @Data 2022/3/23 20:06 * @Version 1.0 * @Desc 订单 */ @Data @AllArgsConstructor @NoArgsConstructor @ToString @Accessors(chain = true) public class Order { private Integer id; private Integer sid; private String name; private Date createDate; }
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:57 * @Version 1.0 * @Desc 商品 */ @Data @AllArgsConstructor @NoArgsConstructor @ToString @Accessors(chain = true) public class Stock { private Integer id; private String name; private Integer count; //库存 private Integer sale; //已售 private Integer version; //版本号 }
-
-
创建mapper层,根据业务编写接口
【mapper】
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:56 * @Version 1.0 * @Desc 商品 */ @Mapper public interface StockMapper { /** * 根据商品id查询库存数量 */ Stock checkStock(Integer id); /** * 根据商品id减少库存 */ void updateSale(Stock stock); }
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.jiabin.mapper.StockMapper"> <!-- 根据商品id查询库存数量 --> <select id="checkStock" resultType="Stock" parameterType="int"> select * from stock where id = #{id} </select> <!-- 根据商品id扣除库存 --> <update id="updateSale" parameterType="Stock" > update stock set sale = #{sale} where id = #{id} </update> </mapper>
-
/** * @Author 嘉宾 * @Data 2022/3/23 20:07 * @Version 1.0 * @Desc 订单 */ @Mapper public interface OrderMapper { /** * 创建订单 */ void createOrder(Order order); }
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.jiabin.mapper.OrderMapper"> <!-- 创建订单 --> <insert id="createOrder" parameterType="Order" useGeneratedKeys="true" keyProperty="id"> insert stock_order value(#{id},#{sid},#{name},#{createDate}); </insert> </mapper>
-
-
创建service,添加订单信息
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:51 * @Version 1.0 */ public interface OrderService { /** * 处理秒杀下单方法,返回订单id * @param id * @return */ Integer kill(Integer id); }
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:53 * @Version 1.0 */ @Service @Transactional //控制事务 public class OrderServiceImpl implements OrderService { @Autowired private StockMapper stockMapper; @Autowired private OrderMapper orderMapper; //在非并发情况下无问题 @Override public Integer kill(Integer id) { //根据商品id校验库存是否还存在 Stock stock = stockMapper.checkStock(id); //当已售和库存相等就库存不足了 if(stock.getSale().equals(stock.getCount())){ throw new RuntimeException("库存不足!"); }else{ //扣除库存 (已售数量+1) stock.setSale(stock.getSale()+1); stockMapper.updateSale(stock); //更新信息 //创建订单 Order order = new Order(); order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date()); orderMapper.createOrder(order); //创建订单 return order.getId(); //mybatis主键生成策略 直接返回创建的id } } }
-
-
创建StockController 提供访问路径
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:49 * @Version 1.0 */ @RestController @RequestMapping("/stock") public class StockController { @Autowired private OrderService orderService; //开发秒杀方法 @GetMapping("/kill/{id}") public String kill(@PathVariable("id") Integer id){ System.out.println("秒杀商品的ID=====================>"+id); try { //根据秒杀商品id调用秒杀业务 Integer orderId = orderService.kill(id); return "秒杀成功,订单ID为:"+String.valueOf(orderId); }catch (Exception e){ e.printStackTrace(); return e.getMessage(); } } }
-
-
访问项目地址
http://localhost:8989/ms/stock/kill/1
进行测试【第一次下单,可以看到一套事务完成,对应订单信息也随着创建】
【当我们连续购买多次,商品库存达到了极限后,提示我们库存不足!此时订单是无法创建的!】
以上只是可以在非高并发场景下可以使用,如果说大量的请求涌向我们的接口,可能会出现问题,下面我们测试一下高并发场景下会出现什么问题!
四、高并发测试工具 JMeter
-
打开我们的测试工具
JMeter
,创建线程组设置请求数【设置1000个线程足够!】
-
配置访问的基础路径配置
【ip、端口、请求类型、访问路径】
-
创建监听,监听线程执行的输出
-
配置完毕!启动我们的测试工具 高并发访问系统
【清空数据库数据】
-
#清空数据库数据 truncate table stock; truncate table stock_order; insert stock value('0','iPhone 13 Pro',15,0,0); select * from stock_order;
【启动压力测试工具】
【查看监听输出】
【查看数据库订单表,发现已经严重超卖,高并发场景下是无法抵御的!】
-
五、解决商品超卖问题
1、使用悲观锁解决商品超卖
上述章节我们可以看到,高并发场景下我们的商品已经严重超卖了,公司中是严重不允许出现该问题的,下面我们看一下如何解决该问题!
【使用 synchronized 悲观锁】
- 顾名思义十分悲观,它总是认为会出问题,无论干什么都会上锁!再去操作!
- synchronized中文意思是同步,也被称之为”同步锁“。
- synchronized的作用是保证在同一时刻, 被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。
- synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。
-
我们使用synchronized 修饰我们的业务代码
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:53 * @Version 1.0 */ @Service @Transactional //控制事务 public class OrderServiceImpl implements OrderService { @Autowired private StockMapper stockMapper; @Autowired private OrderMapper orderMapper; /** * 秒杀商品 **/ @Override public synchronized Integer kill(Integer id) { //根据商品id校验库存是否还存在 Stock stock = stockMapper.checkStock(id); //当已售和库存相等就库存不足了 if(stock.getSale().equals(stock.getCount())){ throw new RuntimeException("库存不足!"); }else{ //扣除库存 (已售数量+1) stock.setSale(stock.getSale()+1); stockMapper.updateSale(stock); //更新信息 //创建订单 Order order = new Order(); order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date()); orderMapper.createOrder(order); //创建订单 return order.getId(); //mybatis主键生成策略 直接返回创建的id } } }
【继续清空数据库数据】
-
#清空数据库数据 truncate table stock; truncate table stock_order; insert stock value('0','iPhone 13 Pro',15,0,0); select * from stock_order;
-
继续使用压力测试工具进行测试
【测试后发现确实可以帮我们解决大量超卖问题,但是仔细观察还是存在超卖现象】
注意:这里存在一个坑!
- 由于我们的业务实体类中我们添加了事务 @Transactional ,添加了事务后会导致我们的事务也存在 线程同步,而事务的线程同步要比我们 synchronized 的线程同步 范围更大
- 我们的 synchronized 代码块确实可以帮我们实现线程同步,但是当我们代码块流程结束后事务可能还没有结束,就例如当前线程A的锁已经释放了 而事务还没有提交, 此时下一个线程B来了,来了之后事务开始提交,当线程B开始执行,线程B一执行数据库也跟着提交,这样可能会出现多提交这种问题!
- 所以说在这里添加 synchronized 也会出现超卖问题!以后不要在业务方法上添加 synchronized !
【解决办法,在控制层中添加 synchronized 】
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:49 * @Version 1.0 */ @RestController @RequestMapping("/stock") public class StockController { @Autowired private OrderService orderService; //开发秒杀方法 @GetMapping("/kill/{id}") public String kill(@PathVariable("id") Integer id){ System.out.println("秒杀商品的ID=====================>"+id); try { //使用悲观锁 synchronized (this){ //根据秒杀商品id调用秒杀业务 Integer orderId = orderService.kill(id); return "秒杀成功,订单ID为:"+String.valueOf(orderId); } }catch (Exception e){ e.printStackTrace(); return e.getMessage(); } } }
【测试结果(前提继续清空数据库)】
【总结:】
- synchronized 要在控制层中添加!
- 注意事务 和 synchronized 存在的问题!
【悲观锁缺点:】
- 会造成线程阻塞,线程排队问题
- 对用户的体验不是很好
注意:我们不推荐使用 悲观锁
解决该问题,因为使用悲观锁
会出现线程排队问题!下面介绍乐观锁方式
2、使用乐观锁解决商品超卖 推荐乐观锁
上述章节我们使用悲观锁
解决了商品超卖问题,但是存在一定的缺陷,就是会出现线程一个个排队问题,会造成线程阻塞,给用户体验也不是很好!我们可以使用乐观锁解决该问题
【使用乐观锁】
- 顾名思义十分乐观,它总是认为不会出现问题,无论干什么都不去上锁!如果出现问题,再次更新值测试
- 乐观锁相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。
- 乐观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性
【代码重构】
-
我们将服务层代码重构,将一个个业务抽取成方法
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:53 * @Version 1.0 */ @Service @Transactional //控制事务 public class OrderServiceImpl implements OrderService { @Autowired private StockMapper stockMapper; @Autowired private OrderMapper orderMapper; // 秒杀 @Override public Integer kill(Integer id) { // 校验库存 Stock stock = checkStock(id); // 存在扣住库存 未抛出异常则满足! updateSale(stock); // 创建订单 return createOrder(stock); } /** * 校验库存 * @param id * @return */ public Stock checkStock(Integer id){ //根据商品id校验库存是否还存在 Stock stock = stockMapper.checkStock(id); //当已售和库存相等就库存不足了 if(stock.getSale().equals(stock.getCount())){ throw new RuntimeException("库存不足!"); } return stock; //满足情况下返回商品信息 } /** * 扣除库存 * @param stock */ public void updateSale(Stock stock){ //扣除库存 (已售数量+1) stock.setSale(stock.getSale()+1); stockMapper.updateSale(stock); //更新信息 } /** * 创建订单 * @param stock * @return */ public Integer createOrder(Stock stock){ //创建订单 Order order = new Order(); order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date()); orderMapper.createOrder(order); //创建订单 return order.getId(); //mybatis主键生成策略 直接返回创建的id } }
-
-
控制层
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:49 * @Version 1.0 */ @RestController @RequestMapping("/stock") public class StockController { @Autowired private OrderService orderService; //开发秒杀方法 @GetMapping("/kill/{id}") public String kill(@PathVariable("id") Integer id){ System.out.println("秒杀商品的ID=====================>"+id); try { //根据秒杀商品id调用秒杀业务 Integer orderId = orderService.kill(id); return "秒杀成功,订单ID为:"+String.valueOf(orderId); }catch (Exception e){ e.printStackTrace(); return e.getMessage(); } } }
-
-
启动项目测试下确保无问题!
【使用乐观锁解决商品超卖】
-
修改我们扣除库存的方法,通过版本号控制抵挡高并发涌入
-
/** * 扣除库存 * @param stock */ public void updateSale(Stock stock){ //在sql层面完成销量+1 和 版本号 +1 并且根据商品id和版本号同时查询更新的商品 Integer updRows = stockMapper.updateSale(stock); //更新信息 if(updRows == 0){ //代表没有拿到版本号 throw new RuntimeException("抢购失败,请重试!"); } }
-
-
修改mapper文件中的映射,我们修改 销量的同时 也修改 版本号 并且需要根据 id + 版本号进行修改
-
<!-- 根据商品id扣除库存 --> <update id="updateSale" parameterType="Stock" > update stock set sale = sale + 1,version = version + 1 where id = #{id} and version = #{version} </update>
-
-
其余代码我们没有任何改动,启动项目进行测试
【依旧清空数据库数据】
-
#清空数据库数据 truncate table stock; truncate table stock_order; insert stock value('0','iPhone 13 Pro',15,0,0); select * from stock_order;
【启动压力测试工具,高并发访问我们的请求,发现无任何问题】
【每秒杀一件商品都会修改一次版本号】
-
总结:
- 相对悲观锁而言乐观锁保证了一定的效率,而不像悲观锁那样会造成线程阻塞
- 使用乐观锁需要使用版本号,在操作数据的时候要对版本号进行更新
商品超卖总体流程
以上已经解决了我们商品超卖的问题,我们还需要解决前端请求限流的问题,我们目前只是1000个请求访问,如果说有更大的请求过来 我们的后端绝对是承受不住的 我们需要在请求做一定的限流处理!
六、使用令牌桶+乐观锁限流
1、什么是令牌桶
最初来源于计算机网络,在网络传输时,为了限制网络的拥塞,而限制网络的流量,使流量比较均匀的速度向外发送。
令牌桶允许请求的突发,它是以恒定的速度向桶中生成令牌,就比如我们向桶中生产100个令牌,它会以一定的速度生产令牌。当请求过来的时候会先向桶中拿取令牌,拿到了令牌才能进行业务处理,没有拿到令牌的请求可以暂时等待,直到令牌生产完毕后再拿到令牌去做业务处理,另外一种方式就是给请求一定的时间,在一定时间内拿到令牌就进行业务处理,拿不到的话就抛弃请求。
2、测试令牌桶
-
导入依赖
-
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>28.2-jre</version> </dependency>
-
-
创建令牌桶实例,编写测试访问接口
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:49 * @Version 1.0 */ @RestController @RequestMapping("/stock") @Slf4j public class StockController { @Autowired private OrderService orderService; //创建令牌桶实例 private RateLimiter rateLimiter = RateLimiter.create(20); //放行20个请求 /** * 基础令牌桶demo * @param id * @return */ @GetMapping("/sale/{id}") public String sale(@PathVariable("id") Integer id){ //1、没有获取到令牌的阻塞,直到获取到令牌token log.info("等待的时间:"+rateLimiter.acquire()); System.out.println("========================>处理业务!"); return "抢购成功!"; } ... }
-
-
启动项目测试,使用压力测试工具访问
【我们设置2000个线程进行访问,可以发现每个请求逐个等待执行,令牌桶会没隔断时间发放令牌,只有得到令牌才能访问业务】
【修改代码,设置线程需要在5秒内获取令牌,若获取不到就抛弃该请求】
-
/** * 基础令牌桶demo * @param id * @return */ @GetMapping("/sale/{id}") public String sale(@PathVariable("id") Integer id){ //1、没有获取到令牌的阻塞,直到获取到令牌token //log.info("等待的时间:"+rateLimiter.acquire()); ///2、设置超时时间,如果在等待时间内获取到了令牌则处理业务,如果在等待时间外没有获取到令牌 则抛弃请求 if(!rateLimiter.tryAcquire(5, TimeUnit.SECONDS)){ //5秒内能获取 log.info("当期请求被限流,被抛弃了,无法调用后续秒杀业务!"); return "抢购失败!"; } System.out.println("========================>处理业务!"); return "抢购成功!"; }
【启动线程组测试,可以看到大部分请求均被抛弃】
-
3、使用令牌桶+乐观锁限流
开发步骤
-
在控制器中添加业务代码
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:49 * @Version 1.0 */ @RestController @RequestMapping("/stock") @Slf4j public class StockController { @Autowired private OrderService orderService; //创建令牌桶实例 private RateLimiter rateLimiter = RateLimiter.create(20); //放行20个请求 20个令牌 /** * 乐观锁 + 令牌桶算法限流 version2.0 * @param id * @return */ @GetMapping("/killtoken/{id}") public String killtoken(@PathVariable("id") Integer id){ System.out.println("秒杀商品的ID=====================>"+id); //加入令牌桶的限流措施 if(rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){ System.out.println("抢购失败,当前秒杀活动过于火爆,请重试!"); return "抢购失败,当前秒杀活动过于火爆,请重试!"; } try { //2秒内能拿到令牌才能进入 //根据秒杀商品id调用秒杀业务 Integer orderId = orderService.kill(id); return "秒杀成功,订单ID为:"+String.valueOf(orderId); }catch (Exception e){ e.printStackTrace(); return e.getMessage(); } } }
-
-
清空数据库
-
#清空数据库数据 truncate table stock; truncate table stock_order; insert stock value('0','iPhone 13 Pro',15,0,0); select * from stock_order;
-
-
启动项目测试,通过压力测试工具测试 2000个线程
【订单已经生成15个,2秒内未拿到令牌的线程会被抛弃】
七、其它问题
在上面章节中,我们完成了防止商品超卖以及限流,以及可以防止高并发情况下服务器宕机了,本章节中我们会继续完成一些问题
- 秒杀都是在一个限定的时间内可以秒杀的,而不是每时每刻用户都可以秒杀,我们需要对秒杀系统加入限时处理!限时抢购
- **如果说黑客通过抓包的方式获取到了我们秒杀接口的地址,通过脚本抢购我们的地址怎么办? **隐藏接口
- 秒杀开始之后 一个用户请求频率过高 如何单位时间内限制访问次数? 单用户限制频率
1、Redis限时抢购
使用Redis完成商品秒杀时间限制,商品只有有效时间内可以秒杀
# 设置redis键存在时间
set Key Value EX 有效时间
# 我们设置商品的过期时间
set kill商品编号 value Ex 有效时间
开发步骤
-
在项目中加入整合redis依赖
-
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
-
-
在配置文件中加入配置
-
# redis spring.redis.host=192.168.171.134 spring.redis.port=6379 spring.redis.database=0
-
-
在秒杀接口中添加redis响应代码
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:53 * @Version 1.0 */ @Service @Transactional //控制事务 public class OrderServiceImpl implements OrderService { @Autowired private StockMapper stockMapper; @Autowired private OrderMapper orderMapper; @Autowired private StringRedisTemplate stringRedisTemplate; //秒杀! @Override public Integer kill(Integer id) { // 验证redis中的秒杀商品是否超时 if(!stringRedisTemplate.hasKey("kill"+id)){ //当redis不存在秒杀的商品 当我们设置的限时字段过期了就不存在了,不存在就代表秒杀结束了! throw new RuntimeException("当前商品的抢购活动已经结束了!"); } // 校验库存 Stock stock = checkStock(id); // 存在扣住库存 未抛出异常则满足! updateSale(stock); // 创建订单 return createOrder(stock); } .... }
-
-
启动redis
-
# 后台启动 redis-server jconfig/redis.conf
【设置一个商品有效秒杀时间】
-
# 单位为秒 set kill1 1 EX 60
-
-
启动项目,通过压力测测试工具进行测试!
【清空数据库数据】
-
#清空数据库数据 truncate table stock; truncate table stock_order; insert stock value('0','iPhone 13 Pro',15,0,0); select * from stock_order;
【在商品有效时间内秒杀商品,秒杀正常】
【清空数据库数据,在商品无效时间内秒杀商品,提示当前商品秒杀已经结束,数据库也不会创建对应订单信息】
-
2、隐藏接口
在以上章节中,我们实现了Redis限时抢购等问题,虽然解决了限时抢购问题,但是我们系统还存在一定的问题,就比如我们的秒杀接口,如果说有不法人员利用某种技术手段获取到了我们的接口信息,在我们秒杀还没有开启的时候,不法人员提前进行对我们接口的连续访问,这样就对我们的用户不公平了,或许直接通过脚本访问接口 通过不进行按钮点击完成抢购,这样就成就了成千上万的薅羊毛军团!
我们需要对秒杀接口进行隐藏处理,抢购接口隐藏(接口加盐)的具体做法:
-
每次点击秒杀接口,先从服务器获取一个秒杀验证值(接口内判断是否是秒杀时间)
-
Redis以缓存用户ID和商品的ID为Key(MD5-用户id-商品id),秒杀地址为 redis中存的秒杀验证值
-
用户请求秒杀的时候,需要带上秒杀验证进行校验
-
具体流程:
-
即使黑客获取到了我们生成md5的接口,我们生成md5的时候为它指定一个随机盐,并且再进行md5的限时处理,这样就有效的防止了脚本
代码实现
-
在我们的数据库中添加新的表
user
-
create table `user`( uid int primary key auto_increment, uname varchar(100), upwd varchar(50) ); insert `user` values('0','jiabin','123');
-
-
在controller中创建对应生成md5的方法
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:49 * @Version 1.0 */ @RestController @RequestMapping("/stock") @Slf4j public class StockController { @Autowired private OrderService orderService; @Autowired private UserService userService; //创建令牌桶实例 private RateLimiter rateLimiter = RateLimiter.create(20); //放行20个请求 /** * 生成MD5 * @param id * @param uid * @return */ @GetMapping("/md5/{id}/{uid}") public String getMD5(@PathVariable("id")Integer id,@PathVariable("uid") Integer uid){ String md5; try { md5 = orderService.getMD5(id,uid); }catch (Exception e){ e.printStackTrace(); return "获取MD5失败:"+e.getMessage(); } return "获取到的MD5信息为:"+md5; } ... }
-
-
在业务层实现我们具体的业务
【OrderServiceImpl】
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:53 * @Version 1.0 */ @Service @Transactional //控制事务 public class OrderServiceImpl implements OrderService { @Autowired private StockMapper stockMapper; @Autowired private OrderMapper orderMapper; @Autowired private UserMapper userMapper; @Autowired private StringRedisTemplate stringRedisTemplate; /** * 根据商品ID与用户ID生成MD5 * @param id * @param uid * @return */ @Override public String getMD5(Integer id, Integer uid) { // 验证uid 用户是否存在 User user = userMapper.findUserById(uid); if(null == user) throw new RuntimeException("用户信息不存在!"); // 验证id 商品是否存在 Stock stock = stockMapper.checkStock(id); if(null == stock) throw new RuntimeException("商品信息不存在!"); // 生成MD5存入Redis String hashKey = "KEY_"+uid+"_"+id; // 生成MD5 !JiaBin16是一个盐 String key = DigestUtils.md5DigestAsHex((uid+id+"!JiaBin16").getBytes()); // 存入redis key value 时间 stringRedisTemplate.opsForValue().set(hashKey,key,120, TimeUnit.SECONDS); return key; } ... }
-
-
创建对应的实体类以及校验用户接口
【User】
-
/** * @Author 嘉宾 * @Data 2022/3/25 19:25 * @Version 1.0 * @Description */ @Data @AllArgsConstructor @NoArgsConstructor @Accessors(chain = true) @ToString public class User { private Integer uid; private String uname; private String upwd; }
【UserMapper】
-
/** * @Author 嘉宾 * @Data 2022/3/25 19:25 * @Version 1.0 * @Description */ @Mapper public interface UserMapper { /** * 根据用户id查询用户 */ User findUserById(Integer uid); } <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.jiabin.mapper.UserMapper"> <!-- 根据用户id查询用户 --> <select id="findUserById" resultType="User" parameterType="int"> select * from user where uid = #{uid} </select> </mapper>
-
-
这里可以启动测试一下是否可以获取到md5
-
改造秒杀接口
【复制秒杀方法,进行改造 controller】
-
/** * 乐观锁 + 令牌桶算法限流 + MD5签名 version3.0 * @param id * @return */ @GetMapping("/killtokenMD5/{id}/{uid}/{md5}") public String killtokenMD5(@PathVariable("id") Integer id,@PathVariable("uid")Integer uid,@PathVariable("md5")String md5){ System.out.println("秒杀商品的ID=====================>"+id); //加入令牌桶的限流措施 if(rateLimiter.tryAcquire(5,TimeUnit.SECONDS)){ System.out.println("抢购失败,当前秒杀活动过于火爆,请重试!"); return "抢购失败,当前秒杀活动过于火爆,请重试!"; } try { //2秒内能拿到产品才能进入 //根据秒杀商品id调用秒杀业务 Integer orderId = orderService.killMD5(id,uid,md5); return "秒杀成功,订单ID为:"+String.valueOf(orderId); }catch (Exception e){ e.printStackTrace(); return e.getMessage(); } }
【复制秒杀方法,进行改造 service-----------这里我们将redis限时注释掉了 利于我们测试】
-
/** * 用来处理秒杀下单方法 返回订单id 加入md5签名 (接口隐藏) * @param id * @param uid * @param md5 * @return */ @Override public Integer killMD5(Integer id, Integer uid, String md5) { // 验证redis中的秒杀商品是否超时 // if(!stringRedisTemplate.hasKey("kill"+id)){ //当redis不存在秒杀的商品 当我们设置的限时字段过期了就不存在了,不存在就代表秒杀结束了! // throw new RuntimeException("当前商品的抢购活动已经结束了!"); // } // 验证签名 String hashKey = "KEY_"+uid+"_"+id; // 通过redis String md5DB = stringRedisTemplate.opsForValue().get(hashKey); if(null == md5DB){ throw new RuntimeException("没有携带签名,请求不合法!"); } if(!md5DB.equals(md5)){ //请求的md5与数据库中的md5作比较 throw new RuntimeException("当前请求数据不合法,请稍后再试!"); } // 校验库存 Stock stock = checkStock(id); // 存在扣住库存 未抛出异常则满足! updateSale(stock); // 创建订单 return createOrder(stock); }
-
-
启动项目进行测试
【首先我们要生成md5,携带我们的md5进行访问,并且保证商品有一定的库存!】
【携带正确md5情况下,可以秒掉的原因是因为我注释掉了令牌桶,不然抢不到令牌访问】
【携带非法令牌情况下】
【解开令牌桶注释,进行压力工具测试,记得清空数据库数据保留库存以及令牌的持久性!】
【携带正确md5情况下,一切秒杀成功!500个线程组】
【携带非法md5情况下,清空数据库保证库存数量,500个线程,秒杀失败!】
3、单用户限制频率
假设我们已经做好了接口隐藏,但是总会有一些无聊的人会再去写一个获取md5的脚本,先获取md5的加密值再去请求购买,如说过你的抢购按钮做的很差,需要在开启0.5秒后才能请求成功,那么脚本可能就会在用户请求之前就请求成功。
我们需要做一个保护措施,用来限制单用户的抢购频率,每个用户在一定时间内只能请求一定的次数!
实现思路:
- 使用redis对每个用户进行访问统计,统计的字段名包含商品的id以及用户的id
- 写一个对用户访问效率限制,在用户下单申请的时候,检查用户的访问次数是否超过了我们指定的次数,如果超过了就抛弃该请求
- 具体流程:
开发步骤
-
开发controller代码
-
/** * @Author 嘉宾 * @Data 2022/3/23 19:49 * @Version 1.0 */ @RestController @RequestMapping("/stock") @Slf4j public class StockController { @Autowired private OrderService orderService; @Autowired private UserService userService; //创建令牌桶实例 private RateLimiter rateLimiter = RateLimiter.create(20); //放行20个请求 /** * 乐观锁 + 令牌桶算法限流 + MD5签名 + 单用户访问频率限制 version4.0 * @param id * @return */ @GetMapping("/killtokenMD5limit/{id}/{uid}/{md5}") public String killtokenMD5limit(@PathVariable("id") Integer id,@PathVariable("uid")Integer uid,@PathVariable("md5")String md5){ //加入令牌桶的限流措施 // if(rateLimiter.tryAcquire(5,TimeUnit.SECONDS)){ // System.out.println("抢购失败,当前秒杀活动过于火爆,请重试!"); // return "抢购失败,当前秒杀活动过于火爆,请重试!"; // } try { //2秒内能拿到产品才能进入 //单用户调用接口频率限制 Integer readCount = userService.addUserReadCount(uid); log.info("===>当前该用户"+uid+"访问次数:"+readCount); Boolean isBannd = userService.getUserCount(uid); if (isBannd){ //判断是否超过指定访问次数 log.info("购买失败,您当前超过了频率限制!"); return "购买失败,您当前超过了频率限制!"; } //根据秒杀商品id调用秒杀业务 Integer orderId = orderService.killMD5(id,uid,md5); return "秒杀成功,订单ID为:"+String.valueOf(orderId); }catch (Exception e){ e.printStackTrace(); return e.getMessage(); } } ... }
-
-
Service代码
【UserService】
-
/** * @Author 嘉宾 * @Data 2022/3/25 19:33 * @Version 1.0 * @Description */ public interface UserService { /** * 向reids中写入访问次数 */ Integer addUserReadCount(Integer uid); /** * 判断单位时间调用次数 */ Boolean getUserCount(Integer uid); }
【UserServiceImpl】
-
/** * @Author 嘉宾 * @Data 2022/3/25 19:34 * @Version 1.0 * @Description */ @Service @Slf4j public class UserServiceImpl implements UserService { @Autowired private StringRedisTemplate stringRedisTemplate; @Override public Integer addUserReadCount(Integer uid) { // 根据不同uid生成调用次数的key String readKey = "READ_"+uid; // 获取redis中key的调用次数 String readCount = stringRedisTemplate.opsForValue().get(readKey); int read = -1; if(readCount == null){ // 第一次调用 // 存入redis中设置0 System.out.println("=======================>第一次调用"); stringRedisTemplate.opsForValue().set(readKey,"0",3600, TimeUnit.SECONDS); }else{ // 不是第一次 // 每次调用+1 System.out.println("=======================>不是第一次调用"); read = Integer.parseInt(readCount) + 1; stringRedisTemplate.opsForValue().set(readKey,String.valueOf(read),3600,TimeUnit.SECONDS); } return read; //返回调用次数 (如果返回-1就代表没有调用过) } @Override public Boolean getUserCount(Integer uid) { // 根据用户id生成key String readKey = "READ_"+uid; // 根据当前key获取用户的调用次数 String readCount = stringRedisTemplate.opsForValue().get(readKey); if(readCount==null){ // 基本不会出现 //为空直接抛弃说明key出现异常 log.error("该用户没有访问申请验证值记录,疑似异常!"); return true; } return Integer.parseInt(readCount)>10; //大于10代表超过:true超过 false 没超过 } }
-
-
启动项目进行测试
【这里我们注释掉了令牌桶,方便我们的测试】
【第一次访问,秒杀成功,控制台输出对应信息 我们定义的第一次访问就会返回-1】
【当我们连续进行秒杀,访问超过10次后就会限制用户,控制台打印对应信息】
八、结语
至此,秒杀系统完结,江湖再见,后会有期!
gitee:https://gitee.com/JiaBin1
CSDN:嘉宾w
QQ:1650457693
最后
以上就是舒服乐曲为你收集整理的基于秒杀系统解决超卖、限流、Redis限时抢购等问题一、什么是秒杀二、为什么使用秒杀三、非并发情况下秒杀四、高并发测试工具 JMeter五、解决商品超卖问题六、使用令牌桶+乐观锁限流七、其它问题八、结语的全部内容,希望文章能够帮你解决基于秒杀系统解决超卖、限流、Redis限时抢购等问题一、什么是秒杀二、为什么使用秒杀三、非并发情况下秒杀四、高并发测试工具 JMeter五、解决商品超卖问题六、使用令牌桶+乐观锁限流七、其它问题八、结语所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复