基于秒杀系统解决超卖、限流、Redis限时抢购等问题

完整项目请见: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);
    
  1. 创建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
      
  2. 创建实体类

    【实体类】

    • /**
       * @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;    //版本号
      }
      
  3. 创建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>
      
  4. 创建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
              }
          }
      }
      
  5. 创建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();
              }
          }
      
      }
      
  6. 访问项目地址 http://localhost:8989/ms/stock/kill/1进行测试

    【第一次下单,可以看到一套事务完成,对应订单信息也随着创建】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4zqWt1m6-1648280445703)(img\QQ截图20220324092806.png)]

    【当我们连续购买多次,商品库存达到了极限后,提示我们库存不足!此时订单是无法创建的!】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zUBZQKzM-1648280445704)(img\QQ截图20220324092837.png)]

以上只是可以在非高并发场景下可以使用,如果说大量的请求涌向我们的接口,可能会出现问题,下面我们测试一下高并发场景下会出现什么问题!

四、高并发测试工具 JMeter

  1. 打开我们的测试工具JMeter,创建线程组设置请求数

    【设置1000个线程足够!】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dwFs4DvN-1648280445705)(img\QQ截图20220324085302.png)]
  2. 配置访问的基础路径配置

    【ip、端口、请求类型、访问路径】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NMWZdAG4-1648280445705)(img\QQ截图20220324085319.png)]
  3. 创建监听,监听线程执行的输出

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MvAnCpTW-1648280445705)(img\QQ截图20220324085355.png)]
  4. 配置完毕!启动我们的测试工具 高并发访问系统

    【清空数据库数据】

    • #清空数据库数据
      truncate table stock;
      truncate table stock_order;
      
      insert stock value('0','iPhone 13 Pro',15,0,0);
      select * from stock_order;
      

    【启动压力测试工具】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TuHg4Hbk-1648280445706)(img\QQ截图20220324094009.png)]

    【查看监听输出】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6cTzvsqK-1648280445706)(img\QQ截图20220324094035.png)]

    【查看数据库订单表,发现已经严重超卖,高并发场景下是无法抵御的!】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8tLFYPra-1648280445706)(img\QQ截图20220324085423.png)]

五、解决商品超卖问题

1、使用悲观锁解决商品超卖

上述章节我们可以看到,高并发场景下我们的商品已经严重超卖了,公司中是严重不允许出现该问题的,下面我们看一下如何解决该问题!

【使用 synchronized 悲观锁】

  • 顾名思义十分悲观,它总是认为会出问题,无论干什么都会上锁!再去操作!
  • synchronized中文意思是同步,也被称之为”同步锁“。
  • synchronized的作用是保证在同一时刻, 被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。
  • synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8w5dUHG9-1648280445707)(img\QQ截图20220324095118.png)]

  1. 我们使用synchronized 修饰我们的业务代码

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-59dXxPKI-1648280445707)(img\QQ截图20220324094705.png)]

    • /**
       * @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;
      
  2. 继续使用压力测试工具进行测试

    【测试后发现确实可以帮我们解决大量超卖问题,但是仔细观察还是存在超卖现象】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3VIYnIDj-1648280445707)(img\QQ截图20220324102326.png)]

注意:这里存在一个坑!

  • 由于我们的业务实体类中我们添加了事务 @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();
            }
        }
    
    }
    

    【测试结果(前提继续清空数据库)】

  • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mZZhm2f2-1648280445708)(img\QQ截图20220324104049.png)]

【总结:】

  • synchronized 要在控制层中添加!
  • 注意事务 和 synchronized 存在的问题!

【悲观锁缺点:】

  • 会造成线程阻塞,线程排队问题
  • 对用户的体验不是很好

注意:我们不推荐使用 悲观锁 解决该问题,因为使用悲观锁会出现线程排队问题!下面介绍乐观锁方式

2、使用乐观锁解决商品超卖 推荐乐观锁

上述章节我们使用悲观锁解决了商品超卖问题,但是存在一定的缺陷,就是会出现线程一个个排队问题,会造成线程阻塞,给用户体验也不是很好!我们可以使用乐观锁解决该问题

【使用乐观锁】

  • 顾名思义十分乐观,它总是认为不会出现问题,无论干什么都不去上锁!如果出现问题,再次更新值测试
  • 乐观锁相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。
  • 乐观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z37LARjY-1648280445708)(img\乐观锁.png)]

【代码重构】

  1. 我们将服务层代码重构,将一个个业务抽取成方法

    • /**
       * @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
          }
      }
      
  2. 控制层

    • /**
       * @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();
              }
          }
      
      }
      
  3. 启动项目测试下确保无问题!

【使用乐观锁解决商品超卖】

  1. 修改我们扣除库存的方法,通过版本号控制抵挡高并发涌入

    • /**
       * 扣除库存
       * @param stock
       */
      public void updateSale(Stock stock){
          //在sql层面完成销量+1 和 版本号 +1 并且根据商品id和版本号同时查询更新的商品
          Integer updRows = stockMapper.updateSale(stock);   //更新信息
          if(updRows == 0){   //代表没有拿到版本号
              throw new RuntimeException("抢购失败,请重试!");
          }
      }
      
  2. 修改mapper文件中的映射,我们修改 销量的同时 也修改 版本号 并且需要根据 id + 版本号进行修改

    • <!-- 根据商品id扣除库存 -->
      <update id="updateSale" parameterType="Stock" >
          update stock set sale = sale + 1,version = version + 1 where id = #{id} and version = #{version}
      </update>
      
  3. 其余代码我们没有任何改动,启动项目进行测试

    【依旧清空数据库数据】

    • #清空数据库数据
      truncate table stock;
      truncate table stock_order;
      
      insert stock value('0','iPhone 13 Pro',15,0,0);
      select * from stock_order;
      

    【启动压力测试工具,高并发访问我们的请求,发现无任何问题】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-N5xxfWVe-1648280445708)(img\QQ截图20220324121601.png)]

    【每秒杀一件商品都会修改一次版本号】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iyiuARLc-1648280445708)(img\QQ截图20220324121650.png)]

总结:

  • 相对悲观锁而言乐观锁保证了一定的效率,而不像悲观锁那样会造成线程阻塞
  • 使用乐观锁需要使用版本号,在操作数据的时候要对版本号进行更新

商品超卖总体流程

  • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8Si1g3JK-1648280445709)(img\秒杀流程.png)]

以上已经解决了我们商品超卖的问题,我们还需要解决前端请求限流的问题,我们目前只是1000个请求访问,如果说有更大的请求过来 我们的后端绝对是承受不住的 我们需要在请求做一定的限流处理!

六、使用令牌桶+乐观锁限流

1、什么是令牌桶

最初来源于计算机网络,在网络传输时,为了限制网络的拥塞,而限制网络的流量,使流量比较均匀的速度向外发送。

令牌桶允许请求的突发,它是以恒定的速度向桶中生成令牌,就比如我们向桶中生产100个令牌,它会以一定的速度生产令牌。当请求过来的时候会先向桶中拿取令牌,拿到了令牌才能进行业务处理,没有拿到令牌的请求可以暂时等待,直到令牌生产完毕后再拿到令牌去做业务处理,另外一种方式就是给请求一定的时间,在一定时间内拿到令牌就进行业务处理,拿不到的话就抛弃请求。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GheM6UWi-1648280445709)(img\QQ截图20220324185448.png)]

2、测试令牌桶

  1. 导入依赖

    • <dependency>
          <groupId>com.google.guava</groupId>
          <artifactId>guava</artifactId>
          <version>28.2-jre</version>
      </dependency>
      
  2. 创建令牌桶实例,编写测试访问接口

    • /**
       * @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 "抢购成功!";
          }
          
          ...
      
      }
      
  3. 启动项目测试,使用压力测试工具访问

    【我们设置2000个线程进行访问,可以发现每个请求逐个等待执行,令牌桶会没隔断时间发放令牌,只有得到令牌才能访问业务】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-me3IM1HD-1648280445709)(img\QQ截图20220324191053.png)]

    【修改代码,设置线程需要在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 "抢购成功!";
      }
      

    【启动线程组测试,可以看到大部分请求均被抛弃】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pgcBadwg-1648280445710)(img\QQ截图20220324191330.png)]

3、使用令牌桶+乐观锁限流

开发步骤
  1. 在控制器中添加业务代码

    • /**
       * @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();
              }
          }
      
      }
      
  2. 清空数据库

    • #清空数据库数据
      truncate table stock;
      truncate table stock_order;
      
      insert stock value('0','iPhone 13 Pro',15,0,0);
      select * from stock_order;
      
  3. 启动项目测试,通过压力测试工具测试 2000个线程

    【订单已经生成15个,2秒内未拿到令牌的线程会被抛弃】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dQa5hsgg-1648280445710)(img\QQ截图20220324191855.png)]

七、其它问题

在上面章节中,我们完成了防止商品超卖以及限流,以及可以防止高并发情况下服务器宕机了,本章节中我们会继续完成一些问题

  • 秒杀都是在一个限定的时间内可以秒杀的,而不是每时每刻用户都可以秒杀,我们需要对秒杀系统加入限时处理!限时抢购
  • **如果说黑客通过抓包的方式获取到了我们秒杀接口的地址,通过脚本抢购我们的地址怎么办? **隐藏接口
  • 秒杀开始之后 一个用户请求频率过高 如何单位时间内限制访问次数? 单用户限制频率

1、Redis限时抢购

使用Redis完成商品秒杀时间限制,商品只有有效时间内可以秒杀

# 设置redis键存在时间
set Key Value EX 有效时间

# 我们设置商品的过期时间
set kill商品编号 value Ex 有效时间
开发步骤
  1. 在项目中加入整合redis依赖

    • <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>
      
  2. 在配置文件中加入配置

    • # redis
      spring.redis.host=192.168.171.134
      spring.redis.port=6379
      spring.redis.database=0
      
  3. 在秒杀接口中添加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);
          }
      
         	 ....
      }
      
    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cnWnV9QS-1648280445710)(img\QQ截图20220324200651.png)]

  4. 启动redis

    • # 后台启动
      redis-server jconfig/redis.conf
      

    【设置一个商品有效秒杀时间】

    • # 单位为秒
      set kill1 1 EX 60
      
  5. 启动项目,通过压力测测试工具进行测试!

    【清空数据库数据】

    • #清空数据库数据
      truncate table stock;
      truncate table stock_order;
      
      insert stock value('0','iPhone 13 Pro',15,0,0);
      select * from stock_order;
      

    【在商品有效时间内秒杀商品,秒杀正常】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xAGKcRVi-1648280445710)(img\QQ截图20220324201331.png)]

    【清空数据库数据,在商品无效时间内秒杀商品,提示当前商品秒杀已经结束,数据库也不会创建对应订单信息】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gUiCaAjI-1648280445711)(img\QQ截图20220324201504.png)]

2、隐藏接口

在以上章节中,我们实现了Redis限时抢购等问题,虽然解决了限时抢购问题,但是我们系统还存在一定的问题,就比如我们的秒杀接口,如果说有不法人员利用某种技术手段获取到了我们的接口信息,在我们秒杀还没有开启的时候,不法人员提前进行对我们接口的连续访问,这样就对我们的用户不公平了,或许直接通过脚本访问接口 通过不进行按钮点击完成抢购,这样就成就了成千上万的薅羊毛军团!

我们需要对秒杀接口进行隐藏处理,抢购接口隐藏(接口加盐)的具体做法:

  • 每次点击秒杀接口,先从服务器获取一个秒杀验证值(接口内判断是否是秒杀时间)

  • Redis以缓存用户ID和商品的ID为Key(MD5-用户id-商品id),秒杀地址为 redis中存的秒杀验证值

  • 用户请求秒杀的时候,需要带上秒杀验证进行校验

  • 具体流程:

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Iy4cyfZg-1648280445711)(img\隐藏接口.png)]
  • 即使黑客获取到了我们生成md5的接口,我们生成md5的时候为它指定一个随机盐,并且再进行md5的限时处理,这样就有效的防止了脚本

代码实现
  1. 在我们的数据库中添加新的表user

    • create table `user`(
      	uid int primary key auto_increment,
      	uname varchar(100),
      	upwd varchar(50)
      );
      insert `user` values('0','jiabin','123');
      
  2. 在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;
          }
      
          ...
      
      }
      
  3. 在业务层实现我们具体的业务

    【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;
          }
      
          ...
      }
      
  4. 创建对应的实体类以及校验用户接口

    【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>
      
  5. 这里可以启动测试一下是否可以获取到md5

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AhZAEgXZ-1648280445711)(img\获取md5.png)]
  6. 改造秒杀接口

    【复制秒杀方法,进行改造 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);
          }
      
  7. 启动项目进行测试

    【首先我们要生成md5,携带我们的md5进行访问,并且保证商品有一定的库存!】

    【携带正确md5情况下,可以秒掉的原因是因为我注释掉了令牌桶,不然抢不到令牌访问】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vb88EHke-1648280445711)(img\测试md5秒杀1.png)]

    【携带非法令牌情况下】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Zf6z4cd2-1648280445711)(img\测试md5秒杀2.png)]

    【解开令牌桶注释,进行压力工具测试,记得清空数据库数据保留库存以及令牌的持久性!】

    【携带正确md5情况下,一切秒杀成功!500个线程组】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GYGhDmaU-1648280445712)(img\QQ截图20220326140051.png)]

    【携带非法md5情况下,清空数据库保证库存数量,500个线程,秒杀失败!】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-m8EtCIEs-1648280445712)(img\QQ截图20220326140311.png)]

3、单用户限制频率

假设我们已经做好了接口隐藏,但是总会有一些无聊的人会再去写一个获取md5的脚本,先获取md5的加密值再去请求购买,如说过你的抢购按钮做的很差,需要在开启0.5秒后才能请求成功,那么脚本可能就会在用户请求之前就请求成功。

我们需要做一个保护措施,用来限制单用户的抢购频率,每个用户在一定时间内只能请求一定的次数!

实现思路:

  • 使用redis对每个用户进行访问统计,统计的字段名包含商品的id以及用户的id
  • 写一个对用户访问效率限制,在用户下单申请的时候,检查用户的访问次数是否超过了我们指定的次数,如果超过了就抛弃该请求
  • 具体流程:
    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QlEVp8l9-1648280445712)(C:\Users\JiaBin\Desktop\秒杀系统\img\单用户限制访问频率.png)]
开发步骤
  1. 开发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();
              }
          }
      	...    
      
      }
      
  2. 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 没超过
          }
      }
      
  3. 启动项目进行测试

    【这里我们注释掉了令牌桶,方便我们的测试】

    【第一次访问,秒杀成功,控制台输出对应信息 我们定义的第一次访问就会返回-1】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8fHWZtxd-1648280445712)(img\QQ截图20220326141624.png)]

    【当我们连续进行秒杀,访问超过10次后就会限制用户,控制台打印对应信息】

    • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1RHhcQt3-1648280445713)(img\QQ截图20220326141511.png)]

八、结语

至此,秒杀系统完结,江湖再见,后会有期!

gitee:https://gitee.com/JiaBin1

CSDN:嘉宾w
QQ:1650457693

  • 2
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值