小型秒杀项目—用乐观锁解决超卖

601 阅读13分钟

项目介绍: 小型秒杀项目。采用乐观锁防止超卖+令牌桶算法限流+md5签名+单用户频率访问限制

项目地址:SmallSecKill

参考: 如何基于springboot优雅设计一个秒杀系统乐观锁解决超卖、Redis缓存、令牌桶桶限流等方案,已完结!

前期准备

在数据库创建两张表

  • 库存表 stock

    DROP TA`seckill`BLE IF EXISTS `stock`;CREATE TABLE `stock`(  `id` int(11) unsigned not null auto_increment,  `name` varchar(50) not null default '' comment '名称',  `count` int(11) not null comment '库存',  `sale` int(11) not null comment '已售',  `version` int(11) not null comment '版本号',  primary key(`id`))engine=InnoDB DEFAULT CHARSET=utf8;
    
  • 订单表 order

    DROP TABLE IF EXISTS `stock_order`;CREATE TABLE `stock_order`(    `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,    `sid` INT(11) NOT NULL COMMENT '库存ID',    `name` VARCHAR(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

     <dependency>     <groupId>org.mybatis.spring.boot</groupId>     <artifactId>mybatis-spring-boot-starter</artifactId>     <version>2.1.3</version></dependency><dependency>    <groupId>mysql</groupId>    <artifactId>mysql-connector-java</artifactId>    <version>5.1.47</version></dependency><dependency>    <groupId>org.projectlombok</groupId>    <artifactId>lombok</artifactId>    <version>1.18.8</version>    <optional>true</optional></dependency><!--数据源--><dependency>    <groupId>com.alibaba</groupId>    <artifactId>druid</artifactId>    <version>1.1.21</version></dependency>
    

创建 controller、dao、entity、service包,编写相关文件

  • 具体参考视频即可。

安装jmeter工具

  • 具体参考视频。

  • 运行命令:jmeter -n -t [jmx file] -l [results file] -e -o [Path to web report folder]

超卖问题及解决方法

  • 出现原因:并发的线程数量远远高于实际的库存数量,在不加锁的情况下,会出现超卖问题。

  • 秒杀代码:

    @Service@Transactionalpublic class OrderServiceImpl implements OrderService{​    @Autowired    private StockDAO stockDAO;​    @Autowired    private OrderDAO orderDAO;​    @Override    public int seckill(Integer id) {        //根据商品id校验库存        Stock stock = stockDAO.checkStock(id);        if(stock.getSale().equals(stock.getCount())){            throw new RuntimeException("库存不足");        }else{            //扣除库存            stock.setSale(stock.getSale()+1);            stockDAO.updateSale(stock);            //创建订单            Order order = new Order();            order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());            orderDAO.createOrder(order);            return order.getId();        }    }}​
    

悲观锁解决超卖

  • 使用悲观锁方式可以解决超卖。但不能在秒杀的方法上加,因为synchronized的作用域小于@Transactional注解,这样导致解锁之后,事务还没来得及提交,另外一个线程的事务读到数据库中未更新的值,出现了超卖问题。

  • 原因分析:

    • 库存表中某件商品count(库存)为100件,sale(已售)0件。

    • 线程1启动一个事务,执行完synchronized修饰的seckill方法后,还未来得及提交(数据库没有被修改)。

    • 此时线程2启动一个事务,进入synchronized修饰的seckill方法,此时读取到的sale=0

    • 线程1提交事务,修改sale=1,在订单表中新增了一条数据。

    • 线程2执行完seckill后,修改sale=1,并在订单表中新增了一条数据。

    • 最后导致1件商品被卖出了两次,即超卖现象。

  • 正确添加方式是在外部的controller方法中添加。悲观锁只能让线程串行执行,严重降低效率,不推荐使用。

    @RestController@RequestMapping("/stock")public class StackController {​    @Autowired    private OrderService orderService;​    @GetMapping("/kill")    public String secKill(Integer id){        try {            //使用悲观锁            synchronized (this) {                int orderId = orderService.seckill(id);                return "秒杀成功,订单id为:" + String.valueOf(orderId);            }        } catch (Exception e) {            e.printStackTrace();            return e.getMessage();        }    }}
    

乐观锁解决超卖

  • 秒杀业务代码:

    @Service@Transactionalpublic class OrderServiceImpl implements OrderService{​    @Autowired    private StockDAO stockDAO;​    @Autowired    private OrderDAO orderDAO;        //秒杀    @Override    public int seckill(Integer id) {        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();    }​}​
    
  • dao文件:

    • StockDAO

      public interface StockDAO { //根据商品id查询库存信息 Stock checkStock(Integer id);​ //根据商品id扣除库存 int updateSale(Stock stock);}

    • OrderDAO

      public interface OrderDAO { /** * 生成订单 * @param order */ void createOrder(Order order);}

  • mapper文件:

    • StockDAOMapper.xml

      <!--根据秒杀商品的id查询库存-->
          <select id="checkStock" parameterType="int" resultType="Stock">
              select * from stock
              where id = #{id}
          </select>
      
          <!--根据商品id更新库存-->
          <update id="updateSale" parameterType="Stock">
              update stock set
                  sale=sale+1,
                  version=version+1
              where
                  id=#{id}
                  and
                  version = #{version}
          </update>
      
    • OrderDAOMapper.xml

      <mapper namespace="com.qmh.dao.OrderDAO">
          <!--创建订单-->
          <insert id="createOrder" parameterType="Order" useGeneratedKeys="true" keyProperty="id">
              insert into stock_order values(#{id},#{sid},#{name},#{createDate})
          </insert>
      </mapper>
      

接口限流

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

接口限流

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

解决办法

  • 常用的限流算法有令牌桶和漏桶算法。在开发高并发系统时,有三把利器保护系统:缓存降级限流

    • 缓存:缓存的目的是提升系统访问速度和增大系统处理容量。

    • 降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以释放服务器资源保证核心业务的正常运行。

    • 限流:限流的目的是通过对并发访问请求进行限速,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。

令牌桶和漏桶算法

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

  • 漏桶算法:漏桶算法思路比较简单,请求先流入到漏桶里,漏桶以一定速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率

  • 令牌桶算法:大小固定的令牌桶自行以恒定速率源源不断产生令牌,如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断增加,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数就不会超过桶的大小。这意味着,面对瞬间大流量,该算法可以在短时间内请求拿到大量令牌。

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

  • 引入依赖

    <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来记录秒杀商品的时间,对秒杀过期的请求进行拒绝处理。

  • 引入依赖,并配置redis

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
  • 修改后的秒杀代码:

    @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();
        }
    
    }
    
  • 部分秒杀结果:

    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缓存验证值。

    • 用户请求秒杀商品的时候,要带上秒杀验证值进行校验。

  • 添加用户表:

    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进行秒杀

       @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实现方法:

      @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;
        }
    
  • 修改秒杀方法

     @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();
            }
        }
    
     @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接口:

    public interface UserService {
        //向redis中写入用户访问次数
        int saveUserCount(Integer userId);
    
        //判断单位时间调用次数
        boolean getUserCount(Integer userId);
    }
    
  • userServiceImpl实现类:

    @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代码:

     /**
         * 乐观锁防止超卖+令牌桶算法限流+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();
            }
        }
    
  • 秒杀结果:

    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       : 购买失败,超过频率限制