参考: 如何基于springboot优雅设计一个秒杀系统乐观锁解决超卖、Redis缓存、令牌桶桶限流等方案,已完结! 项目介绍:小型秒杀项目。采用乐观锁防止超卖+令牌桶算法限流+md5签名+单用户频率访问限制。 项目地址: SmallSecKill

前期准备

在数据库创建两张表

  • 库存表 stock````sql DROP TAseckillBLE IF EXISTSstock; CREATE TABLEstock(idint(11) unsigned not null auto_increment,namevarchar(50) not null default '' comment '名称',countint(11) not null comment '库存',saleint(11) not null comment '已售',versionint(11) not null comment '版本号', primary key(id`) )engine=InnoDB DEFAULT CHARSET=utf8; ```

  • 订单表 order````sql DROP TABLE IF EXISTSstock_order; CREATE TABLEstock_order(idINT(11) UNSIGNED NOT NULL AUTO_INCREMENT,sidINT(11) NOT NULL COMMENT '库存ID',nameVARCHAR(30) NOT NULL DEFAULT '' COMMENT '商品名称',stock_order`create_name TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ‘创建时间’, PRIMARY KEY(id) )ENGINE=INNODB DEFAULT CHARSET=utf8; ```

安装依赖

  • mysql、mybatis等```xml org.mybatis.spring.boot mybatis-spring-boot-starter 2.1.3
mysql mysql-connector-java 5.1.47 org.projectlombok lombok 1.18.8 true

com.alibaba druid 1.1.21

  1. <a name="0fe9da1e"></a>
  2. #### 创建 controller、dao、entity、service包,编写相关文件
  3. - 具体参考视频即可。
  4. <a name="9b70bc76"></a>
  5. #### 安装jmeter工具
  6. - 具体参考视频。
  7. - 运行命令:`jmeter -n -t [jmx file] -l [results file] -e -o [Path to web report folder]`
  8. <a name="1bd30e2c"></a>
  9. ### 超卖问题及解决方法
  10. - 出现原因:并发的线程数量远远高于实际的库存数量,在不加锁的情况下,会出现超卖问题。
  11. - 秒杀代码:```java
  12. @Service
  13. @Transactional
  14. public class OrderServiceImpl implements OrderService{
  15. @Autowired
  16. private StockDAO stockDAO;
  17. @Autowired
  18. private OrderDAO orderDAO;
  19. @Override
  20. public int seckill(Integer id) {
  21. //根据商品id校验库存
  22. Stock stock = stockDAO.checkStock(id);
  23. if(stock.getSale().equals(stock.getCount())){
  24. throw new RuntimeException("库存不足");
  25. }else{
  26. //扣除库存
  27. stock.setSale(stock.getSale()+1);
  28. stockDAO.updateSale(stock);
  29. //创建订单
  30. Order order = new Order();
  31. order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
  32. orderDAO.createOrder(order);
  33. return order.getId();
  34. }
  35. }
  36. }

悲观锁解决超卖

  • 使用悲观锁方式可以解决超卖。但不能在秒杀的方法上加,因为synchronized的作用域小于@Transactional注解,这样导致解锁之后,事务还没来得及提交,另外一个线程的事务读到数据库中未更新的值,出现了超卖问题。
  • 原因分析:
    • 库存表中某件商品count(库存)为100件,sale(已售)0件。
    • 线程1启动一个事务,执行完synchronized修饰的seckill方法后,还未来得及提交(数据库没有被修改)。
    • 此时线程2启动一个事务,进入synchronized修饰的seckill方法,此时读取到的sale=0
    • 线程1提交事务,修改sale=1,在订单表中新增了一条数据。
    • 线程2执行完seckill后,修改sale=1,并在订单表中新增了一条数据。
    • 最后导致1件商品被卖出了两次,即超卖现象。

超卖.png

  • 正确添加方式是在外部的controller方法中添加。悲观锁只能让线程串行执行,严重降低效率,不推荐使用。```java @RestController @RequestMapping(“/stock”) public class StackController {

    @Autowired private OrderService orderService;

    @GetMapping(“/kill”) public String secKill(Integer id){

    1. try {
    2. //使用悲观锁
    3. synchronized (this) {
    4. int orderId = orderService.seckill(id);
    5. return "秒杀成功,订单id为:" + String.valueOf(orderId);
    6. }
    7. } catch (Exception e) {
    8. e.printStackTrace();
    9. return e.getMessage();
    10. }

    } } ```

乐观锁解决超卖

  • 秒杀业务代码:```java @Service @Transactional public class OrderServiceImpl implements OrderService{

    @Autowired private StockDAO stockDAO;

    @Autowired private OrderDAO orderDAO;

    //秒杀 @Override public int seckill(Integer id) {

    1. Stock stock = checkStock(id);
    2. updateSale(stock);
    3. return createOrder(stock);

    }

    //校验库存 private Stock checkStock(Integer id){

    1. Stock stock = stockDAO.checkStock(id);
    2. if(stock.getSale().equals(stock.getCount())) {
    3. throw new RuntimeException("库存不足");
    4. }
    5. return stock;

    }

    //扣除库存 private void updateSale(Stock stock){

    1. //stock.setSale(stock.getSale()+1);
    2. //在sql层面完成销量的+1,和版本号的+1,并根据商品id和版本号同时查询更新的商品。
    3. int result = stockDAO.updateSale(stock);
    4. if(result==0){
    5. throw new RuntimeException("抢购失败,请重试");//必须要抛异常,事务可以回滚,否则继续执行下去
    6. }

    }

    //创建订单 private Integer createOrder(Stock stock){

    1. Order order = new Order();
    2. order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
    3. orderDAO.createOrder(order);
    4. return order.getId();

    }

}

  1. - dao文件:
  2. - `StockDAO`
  3. ```java
  4. public interface StockDAO {
  5. //根据商品id查询库存信息
  6. Stock checkStock(Integer id);
  7. //根据商品id扣除库存
  8. int updateSale(Stock stock);
  9. }
  • OrderDAO
    1. public interface OrderDAO {
    2. /**
    3. * 生成订单
    4. * @param order
    5. */
    6. void createOrder(Order order);
    7. }
  • mapper文件:

    • `StockDAOMapper.xml````xml

      update stock set

         sale=sale+1,
         version=version+1
      

      where

         id=#{id}
         and
         version = #{version}
      

      ```

    • `OrderDAOMapper.xml````xml

      insert into stock_order values(#{id},#{sid},#{name},#{createDate}) ```

接口限流

  • 限流指对某一时间窗口内的请求进行限制,保持系统可用性和稳定性,防止因流量暴增而导致系统运行缓慢或宕机。

接口限流

  • 在面临高并发的抢购请求时,如果不对接口进行限流,可能会对后台系统造成极大的压力。大量的请求抢购成功时需要调用下单的接口,过多的请求打到数据库会对系统的稳定性造成影响。

解决办法

  • 常用的限流算法有令牌桶和漏桶算法。在开发高并发系统时,有三把利器保护系统:缓存降级限流
    • 缓存:缓存的目的是提升系统访问速度和增大系统处理容量。
    • 降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以释放服务器资源保证核心业务的正常运行。
    • 限流:限流的目的是通过对并发访问请求进行限速,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。

令牌桶和漏桶算法

各种限流算法的介绍请参考:图解+代码|常见限流算法以及限流在单机分布式场景下的思考

  • 漏桶算法:漏桶算法思路比较简单,请求先流入到漏桶里,漏桶以一定速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率
    小型秒杀项目使用乐观锁防止超卖 - 图2
  • 令牌桶算法:大小固定的令牌桶自行以恒定速率源源不断产生令牌,如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断增加,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数就不会超过桶的大小。这意味着,面对瞬间大流量,该算法可以在短时间内请求拿到大量令牌。
    小型秒杀项目使用乐观锁防止超卖 - 图3

使用令牌桶算法实现乐观锁+限流

  • 引入依赖xml <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>28.2-jre</version> </dependency>

  • 测试令牌桶

    • jemeter工具,设置并发请求为1000,运行之后可以发现某些请求被限流,直接抛弃。

      public class StackController {
      
      //创建令牌桶示例
      private RateLimiter rateLimiter = RateLimiter.create(40); //每秒产生40个token
      
      @GetMapping("/testToken")
      public String testTokenBucket(Integer id){
         //1.没有获取到令牌就一直阻塞,返回等待时间
      //        log.info("等待时间"+rateLimiter.acquire());
         //2.设置一个等待时间,如果在等待时间内获取到了令牌就处理业务,否则抛弃该请求
         if(!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){
             System.out.println("当前请求被限流,直接抛弃");
             return "失败";
         }
         System.out.println("处理业务");
         return "成功";
      }
      }
      
  • 使用令牌桶实现限流

    • 不能保证商品被全部售完。因为部分请求由于限流会被抛弃。

      //创建令牌桶示例
      private RateLimiter rateLimiter = RateLimiter.create(40); //每秒产生40个token
      
      @GetMapping("/tokenKill")
      public String secTokenKill(Integer id){
         //令牌桶限流
         if(!rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
             log.info("抢购失败,当前秒杀活动过于火爆,请重试");
             return "抢购失败,当前秒杀活动过于火爆,请重试";
         }
         try{
             int orderId = orderService.seckill(id);
             log.info("秒杀成功,订单id为:" + String.valueOf(orderId));
             return "秒杀成功,订单id为:" + String.valueOf(orderId);
         }catch (Exception e){
            // e.printStackTrace();
             return e.getMessage();
         }
      }
      

隐藏秒杀接口

  • 需要考虑的一些问题:
    • 应该在一定时间内进行秒杀处理,如何加入时间验证?— 限时抢购
    • 如何隐藏秒杀地址? — 秒杀接口隐藏
    • 秒杀后,如何限制单个用户的请求频率? —单用户限制频率

限时抢购的实现

  • 使用redis来记录秒杀商品的时间,对秒杀过期的请求进行拒绝处理。
  • 引入依赖,并配置redisxml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

  • 修改后的秒杀代码:```java @Service @Transactional @Slf4j public class OrderServiceImpl implements OrderService{

    @Autowired private StockDAO stockDAO;

    @Autowired private OrderDAO orderDAO;

    @Autowired private StringRedisTemplate redisTemplate;

    /**

    在redis中设置key
    

    **/ @PostConstruct public void init(){

      redisTemplate.opsForValue().set("kill1","1",10, TimeUnit.SECONDS);//设置商品的过期时间为10s
    

    }

    /**

     引入redis实现限时抢购
    

    **/ @Override public int seckill(Integer id) {

      //校验redis中秒杀商品是否超时
      if(!redisTemplate.hasKey("kill"+id)){
          log.info("该商品的秒杀活动已经结束了");
          throw new RuntimeException("该商品的秒杀活动已经结束了");
      }
      Stock stock = checkStock(id);
      updateSale(stock);
      return createOrder(stock);
    

    }

    //校验库存 private Stock checkStock(Integer id){

      Stock stock = stockDAO.checkStock(id);
      if(stock.getSale().equals(stock.getCount())) {
          throw new RuntimeException("库存不足");
      }
      return stock;
    

    }

    //扣除库存 private void updateSale(Stock stock){

      //stock.setSale(stock.getSale()+1);
      //在sql层面完成销量的+1,和版本号的+1,并根据商品id和版本号同时查询更新的商品。
      int result = stockDAO.updateSale(stock);
      if(result==0){
          throw new RuntimeException("抢购失败,请重试");//必须要抛异常,事务可以回滚,否则继续执行下去
      }
    

    }

    //创建订单 private Integer createOrder(Stock stock){

      Order order = new Order();
      order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
      orderDAO.createOrder(order);
      return order.getId();
    

    }

}


- 部分秒杀结果:```bash
2020-10-20 20:33:33.249  INFO 16116 --- [io-8080-exec-36] com.qmh.controller.StackController       : 秒杀成功,订单id为:2889
2020-10-20 20:33:33.510  INFO 16116 --- [io-8080-exec-41] com.qmh.controller.StackController       : 秒杀成功,订单id为:2890
2020-10-20 20:33:33.614  INFO 16116 --- [io-8080-exec-48] com.qmh.service.OrderServiceImpl         : 该商品的秒杀活动已经结束了
2020-10-20 20:33:33.668  INFO 16116 --- [io-8080-exec-49] com.qmh.service.OrderServiceImpl         : 该商品的秒杀活动已经结束了
2020-10-20 20:33:33.724  INFO 16116 --- [io-8080-exec-50] com.qmh.service.OrderServiceImpl         : 该商品的秒杀活动已经结束了
2020-10-20 20:33:33.778  INFO 16116 --- [io-8080-exec-51] com.qmh.service.OrderServiceImpl         : 该商品的秒杀活动已经结束了
2020-10-20 20:33:33.778  INFO 16116 --- [io-8080-exec-52] com.qmh.service.OrderServiceImpl         : 该商品的秒杀活动已经结束了
2020-10-20 20:33:33.815  INFO 16116 --- [io-8080-exec-45] com.qmh.controller.StackController       : 秒杀成功,订单id为:2891

抢购接口隐藏

  • 抢购接口隐藏(接口加盐)的具体做法:
    • 每次点击秒杀按钮,先从服务器获取一个秒杀验证值。
    • redis以缓存用户ID和商品ID为key,秒杀地址为value缓存验证值。
    • 用户请求秒杀商品的时候,要带上秒杀验证值进行校验。
  • 添加用户表:sql DROP TABLE IF EXISTS `user`; CREATE TABLE `user`( `id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '主键', `name` VARCHAR(80) DEFAULT NULL COMMENT '用户名', `password` VARCHAR(40) DEFAULT NULL COMMENT '用户密码', PRIMARY KEY(`id`) );

  • 控制器中添加生成md5的方法:用户请求该接口获得md5,携带md5进行秒杀```java @RequestMapping(“/md5”) public String getMd5(Integer id,Integer userId){

      String md5;
      try {
          md5 = orderService.getMd5(id,userId);
      } catch (Exception e) {
          e.printStackTrace();
          return "获取md5失败"+e.getMessage();
      }
      return "获取md5信息为"+md5;
    

    } ```

  • md5实现方法:```java @Override public String getMd5(Integer id, Integer userId) {

      //验证userId
      User user = userDAO.findById(userId);
      if(user==null) throw new RuntimeException("用户信息不存在");
      log.info("用户信息:[{}]",user.toString());
    
      //验证id
      Stock stock = stockDAO.checkStock(id);
      if(stock==null) throw  new RuntimeException("商品信息不合法");
      log.info("商品信息:[{}]",stock.toString());
    
      //生成hashKey
      String hashKey = "KEY_"+userId+"_"+id;
      //生成md5, 其中!jskf是盐
      String key = DigestUtils.md5DigestAsHex((userId+id+"!jskf").getBytes());
      //放入redis中
      redisTemplate.opsForValue().set(hashKey,key,120,TimeUnit.SECONDS);
      return key;
    

    } ```

  • 修改秒杀方法```java @GetMapping(“/tokenKill”) public String secTokenKill(Integer id,Integer userId,String md5){

      //令牌桶限流
      if(!rateLimiter.tryAcquire(3,TimeUnit.SECONDS)){
          log.info("抢购失败,当前秒杀活动过于火爆,请重试");
          return "抢购失败,当前秒杀活动过于火爆,请重试";
      }
      try{
          int orderId = orderService.seckill(id,userId,md5);
          log.info("秒杀成功,订单id为:" + String.valueOf(orderId));
          return "秒杀成功,订单id为:" + String.valueOf(orderId);
      }catch (Exception e){
         // e.printStackTrace();
          return e.getMessage();
      }
    

    }

    ```java
    @Override
      public int seckill(Integer id, Integer userId, String md5) {
         //校验redis中秒杀商品是否超时
          if(!redisTemplate.hasKey("kill"+id)){
              log.info("该商品的秒杀活动已经结束了");
              throw new RuntimeException("该商品的秒杀活动已经结束了");
          }
    
          //验证签名
          String hashKey = "KEY_"+userId+"_"+id;
          String s = redisTemplate.opsForValue().get(hashKey);
          if(s==null) throw new RuntimeException("没有携带签名");
          if(!md5.equals(s)){
              throw new RuntimeException("当前请求数据不合法");
          }
    
          Stock stock = checkStock(id);
          updateSale(stock);
          return createOrder(stock);
      }
    

单用户限制频率

  • 用redis对每个用户做访问统计,甚至带上商品id,对单个商品进行访问统计。
  • 具体实现:在用户申请下单时,检查用户的访问次数,超过访问次数就不让他下单。
  • 新增userService接口:```java public interface UserService { //向redis中写入用户访问次数 int saveUserCount(Integer userId);

    //判断单位时间调用次数 boolean getUserCount(Integer userId); } ```

  • userServiceImpl实现类:```java @Slf4j public class UserServiceImpl implements UserService{

    @Autowired private StringRedisTemplate redisTemplate;

    @Override public int saveUserCount(Integer userId) {

      //根据不同用户id生成调用次数的key
      String limitKey = "LIMIT"+"_"+userId;
      //获取调用次数
      String limitNum = redisTemplate.opsForValue().get(limitKey);
      int limit = -1;
      if(limitNum==null){
          redisTemplate.opsForValue().set(limitKey,"0",3600, TimeUnit.SECONDS);
      }else{
          limit = Integer.parseInt(limitNum)+1;
          redisTemplate.opsForValue().set(limitKey,String.valueOf(limit),3600,TimeUnit.SECONDS);
      }
      return limit;
    

    }

    @Override public boolean getUserCount(Integer userId) {

      //根据不同用户id生成调用次数的key
      String limitKey = "LIMIT"+"_"+userId;
      //获取调用次数
      String limitNum = redisTemplate.opsForValue().get(limitKey);
      if(limitNum==null){
          log.error("该用户没有访问,疑似异常");
          return true;
      }
      return Integer.parseInt(limitNum)>10; //一个用户一小时内只能调用10次
    

    } } ```

  • 修改秒杀的controller代码:```java /**

    • 乐观锁防止超卖+令牌桶算法限流+md5签名+单用户频率访问限制
    • @param id
    • @param userId
    • @param md5
    • @return */ @GetMapping(“/kill”) public String seckill(Integer id,Integer userId,String md5){ //令牌桶限流 if(!rateLimiter.tryAcquire(3,TimeUnit.SECONDS)){
       log.info("抢购失败,当前秒杀活动过于火爆,请重试");
       return "抢购失败,当前秒杀活动过于火爆,请重试";
      
      } try{
       //单用户调用接口频率限制
       int count = userService.saveUserCount(userId);
       log.info("用户目前的访问次数为:[{}]",count);
       boolean isBanned = userService.getUserCount(userId);
       if(isBanned){
           log.info("购买失败,超过频率限制");
           return "购买失败,超过频率限制";
       }
       //调用秒杀业务
       int orderId = orderService.seckill(id,userId,md5);
       log.info("秒杀成功,订单id为:" + String.valueOf(orderId));
       return "秒杀成功,订单id为:" + String.valueOf(orderId);
      
      }catch (Exception e){
      // e.printStackTrace();
       return e.getMessage();
      
      } } ```
  • 秒杀结果:bash 2020-10-20 22:15:24.470 INFO 19792 --- [io-8080-exec-63] com.qmh.controller.StackController : 用户目前的访问次数为:[10] 2020-10-20 22:15:24.470 INFO 19792 --- [io-8080-exec-64] com.qmh.controller.StackController : 用户目前的访问次数为:[10] 2020-10-20 22:15:24.521 INFO 19792 --- [io-8080-exec-22] com.qmh.service.OrderServiceImpl : 该商品的秒杀活动已经结束了 2020-10-20 22:15:24.521 INFO 19792 --- [io-8080-exec-65] com.qmh.controller.StackController : 用户目前的访问次数为:[10] 2020-10-20 22:15:24.574 INFO 19792 --- [io-8080-exec-63] com.qmh.controller.StackController : 购买失败,超过频率限制 2020-10-20 22:15:24.574 INFO 19792 --- [io-8080-exec-67] com.qmh.controller.StackController : 用户目前的访问次数为:[11] 2020-10-20 22:15:24.574 INFO 19792 --- [io-8080-exec-23] com.qmh.service.OrderServiceImpl : 该商品的秒杀活动已经结束了 2020-10-20 22:15:24.574 INFO 19792 --- [io-8080-exec-66] com.qmh.controller.StackController : 用户目前的访问次数为:[11] 2020-10-20 22:15:24.574 INFO 19792 --- [io-8080-exec-64] com.qmh.controller.StackController : 购买失败,超过频率限制