明湖山人 發表於 2025-5-29 10:57:00

秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis +RabbitMQ +MyBatis-Plus +Maven + Linux + Jmeter ) -03

<h1 id="秒杀高并发解决方案落地实现-技术栈-springbootmysql--redis-rabbitmq-mybatis-plus-maven--linux--jmeter---03">秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis +RabbitMQ +MyBatis-Plus +Maven + Linux + Jmeter ) -03</h1>
<h1 id="优化秒杀-redis-预减库存decrement">优化秒杀: Redis 预减库存+Decrement</h1>
<ul>
<li>Github:China-Rainbow-sea/seckill: 秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis + RabbitMQ +MyBatis-Plus + Maven + Linux + Jmeter )</li>
<li>Gitee:seckill: 秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis + RabbitMQ +MyBatis-Plus + Maven + Linux + Jmeter )</li>
</ul>
<ol>
<li>前面我们防止超卖 是通过到数据库查询和到数据库抢购,来完成的, 代码如下:</li>
</ol>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652803-1233824933.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653951-1065639181.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653401-748031737.png"></p>
<ol start="2">
<li>如果在短时间内,大量抢购冲击 DB, 造成洪峰, 容易压垮数据库</li>
<li>解决方案:使用 Redis 完成预减库存,如果没有库存了,直接返回,减小对 DB 的压力。</li>
<li>图示:</li>
</ol>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103651978-1939104175.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652104-1608725788.png"></p>
<p><strong>Redis 的预减,已经存在了原子性,就是一条一条执行的,不会存在,复购,多购的可能性。</strong></p>
<p>修改 SeckillController.java 实现 <code>InitializingBean</code> 接口,</p>
<p>InitializingBean 当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652453-46185649.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652042-500880296.png"></p>
<pre><code class="language-java">package com.rainbowsea.seckill.controller;


import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.rainbowsea.seckill.pojo.Order;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.annotation.Resource;
import java.util.List;

@Controller
@RequestMapping("/seckill")
// InitializingBean 当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
public class SeckillController implements InitializingBean {


    // 装配需要的组件/对象
    @Resource
    private GoodsService goodsService;

    @Resource
    private SeckillOrderService seckillOrderService;


    @Resource
    private OrderService orderService;


    @Resource
    private RedisTemplate redisTemplate;



    /**
   * 方法: 处理用户抢购请求/秒杀
   * 说明: 我们先完成一个 V3.0版本,
   * - 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】
   * - 使用 优化秒杀: Redis 预减库存+Decrement
   *
   * @param model   返回给模块的 model 信息
   * @param user    User 通过用户使用了,自定义参数解析器获取 User 对象,
   * @param goodsId 秒杀商品的 ID 信息
   * @return 返回到映射在 resources 下的 templates 下的页面
   */
    @RequestMapping(value = "/doSeckill")
    public String doSeckill(Model model, User user, Long goodsId) {
      System.out.println("秒杀 V 2.0 ");

      if (null == user) { //用户没有登录
            return "login";
      }

      // 登录了,则返回用户信息给下一个模板内容
      model.addAttribute("user", user);

      // 获取到 GoodsVo
      GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);

      // 判断库存
      if (goodsVo.getStockCount() &lt; 1) {// 没有库存,不可以购买
            model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
            return "secKillFail"; // 返回一个错误页面
      }

      // 判断用户是否复购-直接到 Redis 当中获取(因为我们抢购成功直接
      // 将表单信息存储到了Redis 当中了。 key表示:order:userId:goodsIdValue表示订单 seckillOrder),
      // 获取对应的秒杀订单,如果有,则说明该
      // 用户已经桥抢购了,每人限购一个
      SeckillOrder o = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" +
                goodsVo.getId()); // 因为我们在 Redis 当中的 value值就是 SeckillOrder 订单对象,所以这里可以直接强制类型转换
      if (null != o) { // 不为null,说明 Redis 存在该用户订单信息,说明该用户已经抢购了该商品
            model.addAttribute("errmsg", RespBeanEnum.REPEAT_ERROR.getMessage()); // 将错误信息返回给下一页的模板当中
            return "secKillFail"; // 返回一个错误页面
      }

      // Redis库存预减,如果在 Redis 中预减库存,发现秒杀商品已经没有了,就直接返回
      // 从面减少去执行 orderService.seckill()请求,防止线程堆积,优化秒杀/高并发
      // 提示: Redis 的 decrement是具有原子性的,已经存在了原子性,就是一条一条执行的,不会存在,复购,多购的可能性。
      // 注意:这里我们要操作的 key 的是:seckillGoods:商品Id,value:该商品的库存量
      Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
      if (decrement &lt; 0) {// 说明这个商品已经没有库存了,返回
            // 这里我们可以恢复库存为 0 ,因为后面可能会一直减下去,恢复为 0 让数据更好看一些
            redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
            model.addAttribute("errmsg", RespBeanEnum.REPEAT_ERROR.getMessage()); // 将错误信息返回给下一页的模板当中
            return "secKillFail"; // 返回一个错误页面
      }

      // 抢购
      Order order = orderService.seckill(user, goodsVo);
      if (order == null) { // 说明抢购失败了,由于什么原因
            model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
            return "secKillFail"; // 返回一个错误页面
      }

      // 走到这里,说明抢购成功了,将信息,通过 model 返回给页面
      model.addAttribute("order", order);
      model.addAttribute("goods", goodsVo);

      System.out.println("秒杀 V 2.0 ");

      return "orderDetail";// 进入到订单详情页


    }

    /**
   * InitializingBean 接口当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
   * 该方法是在类的所有属性,都是初始化后,自动执行的
   * 这里我们就可以将所有秒杀商品的库存量,加载到 Redis 当中
   *
   * @throws Exception
   */
    @Override
    public void afterPropertiesSet() throws Exception {
      // 获取所有可以秒杀的商品信息
      List&lt;GoodsVo&gt; list = goodsService.findGoodsVo();
      // 先判断是否为空
      if (CollectionUtils.isEmpty(list)) {
            return;
      }

      // 遍历 List,然后将秒杀商品的库存量,放入到 Redis
      // key:秒杀商品库存量对应 key:seckillGoods:商品Id,value:该商品的库存量
      list.forEach(
                goodsVo -&gt; {
                  redisTemplate.opsForValue()
                            .set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
                }
      );

    }
}

</code></pre>
<p>测试</p>
<p>启动项目测试时, 确保多用户的 userticket 已经正确的保存到 Redis 中</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652894-1474160584.png"></p>
<p>确保商品库存已经正确的加载/保存到 Redis 中</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103651975-1408430936.png"></p>
<p>启动线程组,进行测试</p>
<p>测试结果, 不在出现超卖和复购问题</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652118-1692370818.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653109-160881297.png"></p>
<p><strong>一个思考题:</strong></p>
<p>预减库存的代码, 能否放在 防止复购代码之前? 试分析可能出现什么情况?</p>
<p>预减库存的代码, 能否放在 防止复购代码之前? 试分析可能出现什么情况?</p>
<p>不可以,这样会导致,我们的预减,减了之后,发现该用户其实已经复购了</p>
<p>,则Redis 当中存储的库存信息减了,但是该用户却时不能购买的,就会</p>
<p>导致,有DB数据库,库存存在遗留问题,10w 用户抢购,只有 1w个商品</p>
<p>却还有遗留的问题。</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653461-813224933.png"></p>
<h1 id="优化秒杀-加入内存标记避免总到-redis-查询库存">优化秒杀: 加入内存标记,避免总到 Redis 查询库存</h1>
<ol>
<li>如果某个商品库存已经为空了, 我们仍然是到 Redis 去查询的, 还可以进行优化</li>
<li><strong>解决方案:</strong> 给商品进行内存标记(存储到我们自己的内存当中), 如果库存为空, 直接返回, 避免总是到 Redis 查询库存</li>
</ol>
<p>**使用map进行内存标记的设计思路: **</p>
<ol>
<li><strong>在本机JVM的 map 记录所有秒杀商品是否还有库存。</strong></li>
<li><strong>在执行预减库存,先到 map 去查询是否该秒杀商品还有库存。如果没有库存,则直接返回,如果有库存,则继续到 Redis 预减库存</strong></li>
<li><strong>操作本机 JVM内存,快于操作 Redis</strong></li>
</ol>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653704-2109690797.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652044-1400100654.png"></p>
<p>修改 SeckillController.java 添加上一个 Map 属性用于,如果某个商品库存已经为空,</p>
<p>则标记到 entryStockMap</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652925-369720742.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103655197-1539481342.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103655259-1163918800.png"></p>
<pre><code class="language-java">package com.rainbowsea.seckill.controller;


import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.rainbowsea.seckill.pojo.Order;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.annotation.Resource;
import java.util.HashMap;
import java.util.List;

@Controller
@RequestMapping("/seckill")
// InitializingBean 当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
public class SeckillController implements InitializingBean {


    // 装配需要的组件/对象
    @Resource
    private GoodsService goodsService;

    @Resource
    private SeckillOrderService seckillOrderService;


    @Resource
    private OrderService orderService;


    // 如果某个商品库存已经为空, 则标记到 entryStockMap
    @Resource
    private RedisTemplate redisTemplate;


    // 定义 map- 记录秒杀商品
    private HashMap&lt;Long, Boolean&gt; entryStockMap = new HashMap&lt;&gt;();

    /**
   * 方法: 处理用户抢购请求/秒杀
   * 说明: 我们先完成一个 V 4.0版本,
   * - 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】
   * - 使用 优化秒杀: Redis 预减库存+Decrement
   * - 优化秒杀: 加入内存标记,避免总到 Redis 查询库存
   *
   * @param model   返回给模块的 model 信息
   * @param user    User 通过用户使用了,自定义参数解析器获取 User 对象,
   * @param goodsId 秒杀商品的 ID 信息
   * @return 返回到映射在 resources 下的 templates 下的页面
   */
    @RequestMapping(value = "/doSeckill")
    public String doSeckill(Model model, User user, Long goodsId) {
      System.out.println("秒杀 V 4.0 ");

      // 定义 map - 记录秒杀商品是否还有库存

      if (null == user) { //用户没有登录
            return "login";
      }

      // 登录了,则返回用户信息给下一个模板内容
      model.addAttribute("user", user);

      // 获取到 GoodsVo
      GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);

      // 判断库存
      if (goodsVo.getStockCount() &lt; 1) {// 没有库存,不可以购买
            model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
            return "secKillFail"; // 返回一个错误页面
      }

      // 判断用户是否复购-直接到 Redis 当中获取(因为我们抢购成功直接
      // 将表单信息存储到了Redis 当中了。 key表示:order:userId:goodsIdValue表示订单 seckillOrder),
      // 获取对应的秒杀订单,如果有,则说明该
      // 用户已经桥抢购了,每人限购一个
      SeckillOrder o = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" +
                goodsVo.getId()); // 因为我们在 Redis 当中的 value值就是 SeckillOrder 订单对象,所以这里可以直接强制类型转换
      if (null != o) { // 不为null,说明 Redis 存在该用户订单信息,说明该用户已经抢购了该商品
            model.addAttribute("errmsg", RespBeanEnum.REPEAT_ERROR.getMessage()); // 将错误信息返回给下一页的模板当中
            return "secKillFail"; // 返回一个错误页面
      }


      // 对map进行判断[内存标记],如果商品在map 已经标记为没有库存,则直接返回,无需进行 Redis 预减
      if (entryStockMap.get(goodsId)) {
            model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
            return "secKillFail"; // 返回一个错误页面
      }

      // Redis库存预减,如果在 Redis 中预减库存,发现秒杀商品已经没有了,就直接返回
      // 从面减少去执行 orderService.seckill()请求,防止线程堆积,优化秒杀/高并发
      // 提示: Redis 的 decrement是具有原子性的,已经存在了原子性,就是一条一条执行的,不会存在,复购,多购的可能性。
      // 注意:这里我们要操作的 key 的是:seckillGoods:商品Id,value:该商品的库存量
      Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
      if (decrement &lt; 0) {// 说明这个商品已经没有库存了,返回

            // 说明当前秒杀的商品,已经没有库存
            entryStockMap.put(goodsId, true);

            // 这里我们可以恢复库存为 0 ,因为后面可能会一直减下去,恢复为 0 让数据更好看一些
            redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
            model.addAttribute("errmsg", RespBeanEnum.REPEAT_ERROR.getMessage()); // 将错误信息返回给下一页的模板当中
            return "secKillFail"; // 返回一个错误页面
      }

      // 抢购
      Order order = orderService.seckill(user, goodsVo);
      if (order == null) { // 说明抢购失败了,由于什么原因
            model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
            return "secKillFail"; // 返回一个错误页面
      }

      // 走到这里,说明抢购成功了,将信息,通过 model 返回给页面
      model.addAttribute("order", order);
      model.addAttribute("goods", goodsVo);

      System.out.println("秒杀 V 4.0 ");

      return "orderDetail";// 进入到订单详情页

    }


    /**
   * InitializingBean 接口当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
   * 该方法是在类的所有属性,都是初始化后,自动执行的
   * 这里我们就可以将所有秒杀商品的库存量,加载到 Redis 当中
   *
   * @throws Exception
   */
    @Override
    public void afterPropertiesSet() throws Exception {
      // 获取所有可以秒杀的商品信息
      List&lt;GoodsVo&gt; list = goodsService.findGoodsVo();
      // 先判断是否为空
      if (CollectionUtils.isEmpty(list)) {
            return;
      }

      // 遍历 List,然后将秒杀商品的库存量,放入到 Redis
      // key:秒杀商品库存量对应 key:seckillGoods:商品Id,value:该商品的库存量
      list.forEach(
                goodsVo -&gt; {
                  redisTemplate.opsForValue()
                            .set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
                  // 初始化 map
                  // 如果 goodsId: false 表示有库存
                  // 如果 goodsId: true 表示没有库存
                  entryStockMap.put(goodsVo.getId(), false);
                });

    }
}

</code></pre>
<p>测试</p>
<p>启动项目测试时, 确保多用户的 userticket 已经正确的保存到 Redis 中</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652051-1543785387.png"></p>
<p>确保商品库存已经正确的加载/保存到 Redis 中</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652040-1901800310.png"></p>
<p>启动线程组,进行测试</p>
<p>测试结果, 不在出现超卖和复购问题</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653176-1880417557.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653607-1724031633.png"></p>
<h1 id="优化秒杀-加入消息队列实现秒杀的异步请求">优化秒杀: 加入消息队列,实现秒杀的异步请求</h1>
<p>前面秒杀,没有实现异步机制,是完成下订单后,再返回的,当有大并发请求</p>
<p>加入消息队列,实现秒杀的异步请求下订单操作时,数据库来不及响应,容易造成线程堆积。</p>
<p><strong>解决方案:</strong></p>
<ul>
<li>加入消息队列,实现秒杀的异步请求。</li>
<li>接收到客户端秒杀请求后,服务器立即返回,正在秒杀中...,有利于流量削峰。</li>
<li>客户端进行轮询秒杀结果,接收到秒杀结果后,在客户端页面显示即可。</li>
<li>秒杀消息发送设计:SeckillMessage - String</li>
</ul>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653192-1567660717.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653213-338213564.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652019-1032143491.png"></p>
<p><strong>RabbitMQ 启动,配合 Spring Boot 配置</strong></p>
<p>引入:Spring Boot 当中相关的 RabbitMQ 的 jar 包</p>
<pre><code class="language-xml">      &lt;dependency&gt;
            &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
            &lt;artifactId&gt;spring-boot-starter-amqp&lt;/artifactId&gt;
      &lt;/dependency&gt;

</code></pre>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653402-1093305571.png"></p>
<pre><code class="language-yaml">spring:
rabbitmq:
    host: 192.168.76.156
    username: admin
    password: 123
    #虚拟主机
    virtual-host: /
    #端口
    port: 5672
    listener:
      simple:
      #消费者的最小数量
      concurrency: 10
      #消费者的最大数量
      max-concurrency: 10
      #限制消费者,每次只能处理一条消息,处理完才能继续下一条消息
      prefetch: 1
      #启动时,是否默认启动容器,默认true
      auto-startup: true
      #被拒绝后,重新进入队列
      default-requeue-rejected: true
    template:
      retry:
      #启用重试机制,默认false
      enabled: true
      #设置初始化的重试时间间隔
      initial-interval: 1000ms
      #重试最大次数,默认是3
      max-attempts: 3
      #重试最大时间间隔,默认是10s
      max-interval: 10000ms
      #重试时间间隔的乘数
      #比如配置是2 :第1次等 1s, 第2次等 2s,第3次等 4s..
      #比如配置是1 :第1次等 1s, 第2次等 1s,第3次等 1s..
      multiplier: 1
</code></pre>
<p>项目的完整 yaml 配置信息</p>
<pre><code class="language-yaml">spring:
thymeleaf:
    #关闭缓存
    cache: false
datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/seckill?useUnicode=true&amp;characterEncoding=utf-8&amp;useSSL=true
    username: root
    password: MySQL123
    # 数据库连接池
    hikari:
      #连接池名
      pool-name: Hsp_Hikari_Poll
      #最小空闲连接
      minimum-idle: 5
      #空闲连接存活最大时间,默认60000(10分钟)
      idle-timeout: 60000
      # 最大连接数,默认是10
      maximum-pool-size: 10
      #从连接池返回来的连接自动提交
      auto-commit: true
      #连接最大存活时间。0表示永久存活,默认180000(30分钟)
      max-lifetime: 180000
      #连接超时时间,默认30000(30秒)
      connection-timeout: 30000
      #测试连接是否可用的查询语句
      connection-test-query: select 1
    #配置Redis
redis:
    host: 192.168.76.168
    port: 6379
    password: rainbowsea
    database: 0
    timeout: 10000ms
    lettuce:
      pool:
      #最大连接数,默认是8
      max-active: 8
      #最大连接等待/阻塞时间,默认-1
      max-wait: 10000ms
      #最大空闲连接
      max-idle: 200
      #最小空闲连接,默认0
      min-idle: 5
# rabbimq 配置
rabbitmq:
    host: 192.168.76.156
    username: admin
    password: 123
    #虚拟主机
    virtual-host: /
    #端口
    port: 5672
    listener:
      simple:
      #消费者的最小数量
      concurrency: 10
      #消费者的最大数量
      max-concurrency: 10
      #限制消费者,每次只能处理一条消息,处理完才能继续下一条消息
      prefetch: 1
      #启动时,是否默认启动容器,默认true
      auto-startup: true
      #被拒绝后,重新进入队列
      default-requeue-rejected: true
    template:
      retry:
      #启用重试机制,默认false
      enabled: true
      #设置初始化的重试时间间隔
      initial-interval: 1000ms
      #重试最大次数,默认是3
      max-attempts: 3
      #重试最大时间间隔,默认是10s
      max-interval: 10000ms
      #重试时间间隔的乘数
      #比如配置是2 :第1次等 1s, 第2次等 2s,第3次等 4s..
      #比如配置是1 :第1次等 1s, 第2次等 1s,第3次等 1s..
      multiplier: 1
#mybatis-plus配置
mybatis-plus:
#配置mapper.xml映射文件
mapper-locations: classpath*:/mapper/*Mapper.xml
#配置mybatis数据返回类型别名
type-aliases-package: com.rainbowsea.seckill.pojo
#mybatis sql 打印
#logging:
#level:
#    com.rainbowsea.seckill.mapper: debug
server:
port: 8080

</code></pre>
<p>创建一个 SeckillMessage pojo 类,用于 RabbitMQ 生产者,消费者之间发送信息的封装。同时我们后续需要将该对象转换为 JSON 格式的 String 进行在 RabbitMQ 消息队列当中发送传输处理。</p>
<p>引入 JSON 转换工具 的 jar 包类</p>
<pre><code class="language-xml">   &lt;!--引入hutool依赖-工具类,JSON 转换工具--&gt;
      &lt;dependency&gt;
            &lt;groupId&gt;cn.hutool&lt;/groupId&gt;
            &lt;artifactId&gt;hutool-all&lt;/artifactId&gt;
            &lt;version&gt;5.3.3&lt;/version&gt;
      &lt;/dependency&gt;

</code></pre>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652003-1390875555.png"></p>
<pre><code class="language-java">package com.rainbowsea.seckill.pojo;


import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* SeckillMessage 秒杀消息对象,用于 RabbitMQ 消息队列进行发送传输
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SeckillMessage {

    private User user;

    private Long goodsId;
}



</code></pre>
<p>创建 RabbitMQSecKillConfig ,作为 RabbitMQ 的消息队列的配置。</p>
<p>配置类,RabbitMQ 创建消息队列和交换机,以及消息队列和交换机的之间的关系</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103651949-1268740709.png"></p>
<pre><code class="language-java">package com.rainbowsea.seckill.config;


import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import org.springframework.amqp.core.Queue;

/**
* 配置类,RabbitMQ 创建消息队列和交换机,以及消息队列和交换机的之间的关系
*/
@Configuration
public class RabbitMQSecKillConfig {

    // 定义消息队列和交换机名
    public static final String QUEUE = "seckillQueue";

    public static final String EXCHANGE = "seckillExchange";


    /**
   * 创建队列
   *
   * @return Queue 队列
   */
    @Bean // 没有指明 value ,默认就是方法名
    public Queue queue_seckill() {
      return new Queue(QUEUE);
    }


    /**
   * @return TopicExchange 主题交换机
   */
    @Bean
    public TopicExchange topicExchange_seckill() {
      return new TopicExchange(EXCHANGE);
    }


    /**
   * 将队列绑定到对应的交换机当中,并指定路由,"主题"(哪些信息发送给 seckill.# 哪个队列)
   *
   * @return
   */
    @Bean
    public Binding binding_seckill() {
      return BindingBuilder.bind(queue_seckill()).to(topicExchange_seckill())
                .with("seckill.#");
    }


}

</code></pre>
<p>创建 MQSenderMessage 对象,作为消息队列的发送者,发送信息</p>
<p>消息的生产者/发送者 发送【秒杀消息】</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653613-1336325409.png"></p>
<pre><code class="language-java">package com.rainbowsea.seckill.rabbitmq;


import com.rainbowsea.seckill.config.RabbitMQSecKillConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;


/**
* 消息的生产者/发送者 发送【秒杀消息】
*/
@Slf4j
@Service
public class MQSenderMessage {

    // 装配 RabbitTemplate
    @Resource
    private RabbitTemplate rabbitTemplate;

    /**
   * 发送者,将信息发送给交换机
   *
   * @param message
   */
    public void sendSeckillMessage(String message) {
      log.info("发送消息: " + message);
      rabbitTemplate.convertAndSend(RabbitMQSecKillConfig.EXCHANGE,
                "seckill.message",// 对应队列的 routingKey
                message);
    }
}

</code></pre>
<p>创建:MQReceiverConsumer 对象类,作为:消息的接收者/消费者,接收生产者,发送过来的信息</p>
<p>接收到信息后,调用秒杀商品的方法,orderService.seckill(user, goodsVo);</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103655314-1429491126.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653419-1675134879.png"></p>
<pre><code class="language-java">package com.rainbowsea.seckill.rabbitmq;


import cn.hutool.json.JSONUtil;
import com.rainbowsea.seckill.config.RabbitMQSecKillConfig;
import com.rainbowsea.seckill.pojo.SeckillMessage;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;


/**
* 消息的接收者/消费者,接收生产者,发送过来的信息
* ,接收到信息后,调用秒杀商品的方法,orderService.seckill(user, goodsVo);
*/
@Service
@Slf4j
public class MQReceiverConsumer {

    @Resource
    private GoodsService goodsService;


    @Resource
    private OrderService orderService;


    /**
   * 接受这个 queues = RabbitMQSecKillConfig.QUEUE 队列的当中的信息
   *
   * @param message 生产者发送的信息,其实就是 seckillMessage 对象信息,被我们转换为了 JSON
   *                格式的 String
   */
    @RabbitListener(queues = RabbitMQSecKillConfig.QUEUE)
    public void queue(String message) {
      log.info("接收到的消息是: " + message);
      /*
      这里我么们从队列中取出的是 String 类型
      但是,我们需要的是 SeckillMessage,因此需要一个工具类 JSONUtil
      ,该工具需要引入 hutool 工具类的 jar 包
         */
      SeckillMessage seckillMessage = JSONUtil.toBean(message, SeckillMessage.class);

      // 秒杀用户对象
      User user = seckillMessage.getUser();

      // 秒杀用户的商品ID
      Long goodsId = seckillMessage.getGoodsId();

      // 通过商品ID,得到对应的 GoodsVo 秒杀商品信息对象
      GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);

      // 下单操作
      orderService.seckill(user, goodsVo);

    }

}

</code></pre>
<p>控制层处理:采用消息队列:SeckillController</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652993-1438277690.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653878-1775321180.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653758-1023923193.png"></p>
<pre><code class="language-java">package com.rainbowsea.seckill.controller;


import cn.hutool.json.JSONUtil;
import com.rainbowsea.seckill.pojo.SeckillMessage;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.rabbitmq.MQSenderMessage;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.annotation.Resource;
import java.util.HashMap;
import java.util.List;

@Controller
@RequestMapping("/seckill")
// InitializingBean 当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
public class SeckillController implements InitializingBean {


    // 装配需要的组件/对象
    @Resource
    private GoodsService goodsService;

    @Resource
    private SeckillOrderService seckillOrderService;


    @Resource
    private OrderService orderService;


    // 如果某个商品库存已经为空, 则标记到 entryStockMap
    @Resource
    private RedisTemplate redisTemplate;


    // 定义 map- 记录秒杀商品
    private HashMap&lt;Long, Boolean&gt; entryStockMap = new HashMap&lt;&gt;();


    // 装配消息的生产者/发送者
    @Resource
    private MQSenderMessage mqSenderMessage;

    /**
   * 方法: 处理用户抢购请求/秒杀
   * 说明: 我们先完成一个 V 5.0版本,
   * - 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】
   * - 使用 优化秒杀: Redis 预减库存+Decrement
   * - 优化秒杀: 加入内存标记,避免总到 Redis 查询库存
   * - 优化秒杀: 加入消息队列,实现秒杀的异步请求
   *
   * @param model   返回给模块的 model 信息
   * @param user    User 通过用户使用了,自定义参数解析器获取 User 对象,
   * @param goodsId 秒杀商品的 ID 信息
   * @return 返回到映射在 resources 下的 templates 下的页面
   */
    @RequestMapping(value = "/doSeckill")
    public String doSeckill(Model model, User user, Long goodsId) {
      System.out.println("秒杀 V 5.0 ");

      // 定义 map - 记录秒杀商品是否还有库存

      if (null == user) { //用户没有登录
            return "login";
      }

      // 登录了,则返回用户信息给下一个模板内容
      model.addAttribute("user", user);

      // 获取到 GoodsVo
      GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);

      // 判断库存
      if (goodsVo.getStockCount() &lt; 1) {// 没有库存,不可以购买
            model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
            return "secKillFail"; // 返回一个错误页面
      }

      // 判断用户是否复购-直接到 Redis 当中获取(因为我们抢购成功直接
      // 将表单信息存储到了Redis 当中了。 key表示:order:userId:goodsIdValue表示订单 seckillOrder),
      // 获取对应的秒杀订单,如果有,则说明该
      // 用户已经桥抢购了,每人限购一个
      SeckillOrder o = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" +
                goodsVo.getId()); // 因为我们在 Redis 当中的 value值就是 SeckillOrder 订单对象,所以这里可以直接强制类型转换
      if (null != o) { // 不为null,说明 Redis 存在该用户订单信息,说明该用户已经抢购了该商品
            model.addAttribute("errmsg", RespBeanEnum.REPEAT_ERROR.getMessage()); // 将错误信息返回给下一页的模板当中
            return "secKillFail"; // 返回一个错误页面
      }


      // 对map进行判断[内存标记],如果商品在map 已经标记为没有库存,则直接返回,无需进行 Redis 预减
      if (entryStockMap.get(goodsId)) {
            model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
            return "secKillFail"; // 返回一个错误页面
      }

      // Redis库存预减,如果在 Redis 中预减库存,发现秒杀商品已经没有了,就直接返回
      // 从面减少去执行 orderService.seckill()请求,防止线程堆积,优化秒杀/高并发
      // 提示: Redis 的 decrement是具有原子性的,已经存在了原子性,就是一条一条执行的,不会存在,复购,多购的可能性。
      // 注意:这里我们要操作的 key 的是:seckillGoods:商品Id,value:该商品的库存量
      Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
      if (decrement &lt; 0) {// 说明这个商品已经没有库存了,返回

            // 说明当前秒杀的商品,已经没有库存
            entryStockMap.put(goodsId, true);

            // 这里我们可以恢复库存为 0 ,因为后面可能会一直减下去,恢复为 0 让数据更好看一些
            redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
            model.addAttribute("errmsg", RespBeanEnum.REPEAT_ERROR.getMessage()); // 将错误信息返回给下一页的模板当中
            return "secKillFail"; // 返回一个错误页面
      }

      /*
      抢购,向消息队列发送秒杀请求,实现了秒杀异步请求
      这里我们发送秒杀消息后,立即快速返回结果【临时结果】- “比如排队中...”
      客户端可以通过轮询,获取到最终结果
      创建 SeckillMessage
         */

      SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
      // 将 seckillMessage 对象封装为 JSON 格式的 String 让RabbitMQ 生产者发送出去
      // 被消费者接受消费
      mqSenderMessage.sendSeckillMessage(JSONUtil.toJsonStr(seckillMessage));
      model.addAttribute("errmsg","排队中...");

      System.out.println("秒杀 V 5.0 ");

      return "secKillFail";// 进入到表示排队中信息

    }


    /**
   * InitializingBean 接口当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
   * 该方法是在类的所有属性,都是初始化后,自动执行的
   * 这里我们就可以将所有秒杀商品的库存量,加载到 Redis 当中
   *
   * @throws Exception
   */
    @Override
    public void afterPropertiesSet() throws Exception {
      // 获取所有可以秒杀的商品信息
      List&lt;GoodsVo&gt; list = goodsService.findGoodsVo();
      // 先判断是否为空
      if (CollectionUtils.isEmpty(list)) {
            return;
      }

      // 遍历 List,然后将秒杀商品的库存量,放入到 Redis
      // key:秒杀商品库存量对应 key:seckillGoods:商品Id,value:该商品的库存量
      list.forEach(
                goodsVo -&gt; {
                  redisTemplate.opsForValue()
                            .set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
                  // 初始化 map
                  // 如果 goodsId: false 表示有库存
                  // 如果 goodsId: true 表示没有库存
                  entryStockMap.put(goodsVo.getId(), false);
                });

    }
}

</code></pre>
<p>测试:和上述测试一样。重置相关数据表</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103653403-1183786265.png"></p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652088-1198474000.png"></p>
<h1 id="补充客户端轮询秒杀结果-思路分析示意图">补充:客户端轮询秒杀结果-思路分析示意图</h1>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103652010-998683155.png"></p>
<h1 id="6-最后">6. 最后:</h1>
<blockquote>
<p>“在这个最后的篇章中,我要表达我对每一位读者的感激之情。你们的关注和回复是我创作的动力源泉,我从你们身上吸取了无尽的灵感与勇气。我会将你们的鼓励留在心底,继续在其他的领域奋斗。感谢你们,我们总会在某个时刻再次相遇。”</p>
<p><img src="https://img2024.cnblogs.com/blog/3084824/202505/3084824-20250529103655331-1688888904.gif"></p>
</blockquote><br><br>
来源:https://www.cnblogs.com/TheMagicalRainbowSea/p/18902199
頁: [1]
查看完整版本: 秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis +RabbitMQ +MyBatis-Plus +Maven + Linux + Jmeter ) -03