秒杀系统(详解)基于SpringBoot实现(持续更新中)

  • Post author:
  • Post category:其他


在这里插入图片描述

秒杀系统



全部代码(更新中)


码云地址


GitHub地址



1、什么是秒杀

“秒杀”这一词多半出现在购物方面,但是又不单单只是购物,比如12306购票和学校抢课(大学生的痛苦)也可以看成一个秒杀。秒杀应该是一个“三高”,这个三高不是指高血脂,高血压和高血糖。而是指“高并发”,“高性能”和“高可用”。



2、秒杀的问题



2.1、超卖

超卖是秒杀系统最大的一个问题,如果出现超卖这个锅只有程序员进行背了,有些秒杀系统宁愿用户等待时间长点或者体验稍微的降低一点也不愿意出现超卖的限制。(系统每出现一次超卖,就损失一位程序员)



2.2、高并发

秒杀系统会在一瞬间发送一(亿)点点请求,这时候如果服务器蹦了那就会出现常见的页面了,所以通常一个秒杀系统都是一个单独的服务(

单一职责

)。这个可以通过限流和负载均衡处理。



2.3、恶意请求

恶意请求的意思就是一个人可能一次性购买很多(有时候全部也不在话下),然后再将这些东西转手卖出去。这时候是不是浮现出两个字“

黄牛

”,这tm不是黄牛是什么。逼近一个人的手速再快(多年单身的手速),也比不过机器请求。不要小看一些黄牛可能他们使用的系统比一些大公司的系统都要NB。



2.4、高性能

如果一个秒杀系统在你点击了

抢购按钮

的时候然后出现一个

loading

的图标一直在那里转啊转一直转了几十分钟,然后通知你商品已售空(哈哈哈哈),刺激。



3、解决方法

  1. 乐观锁防止超卖(记得加事务)
  2. Redis
  3. Alibaba Sentinel限流和熔断
  4. 谷歌令牌桶限流
  5. 负载均衡等



4、项目落地实现



4.1、数据库准备


用户表(user)

image-20200913193315278


商品表(goods)

image-20200913193353446


订单表(commodity_order)

注意:订单表表名不要叫order,会出大问题。order是数据库的一个关键之,如果真的叫这个名字则需要在查询的时候加上


image-20200913193441499

/*
 Navicat Premium Data Transfer

 Source Server         : 本地
 Source Server Type    : MySQL
 Source Server Version : 80021
 Source Host           : localhost:3306
 Source Schema         : seckill_demo

 Target Server Type    : MySQL
 Target Server Version : 80021
 File Encoding         : 65001

 Date: 13/09/2020 19:46:01
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for commodity_order
-- ----------------------------
DROP TABLE IF EXISTS `commodity_order`;
CREATE TABLE `commodity_order`  (
  `id` int(0) NOT NULL AUTO_INCREMENT COMMENT '订单Id',
  `user_id` int(0) NOT NULL COMMENT '用户Id',
  `goods_id` int(0) NOT NULL COMMENT '商品Id',
  `gmt_create` datetime(0) NOT NULL COMMENT '创建时间',
  `gmt_modified` datetime(0) NOT NULL COMMENT '修改时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Table structure for goods
-- ----------------------------
DROP TABLE IF EXISTS `goods`;
CREATE TABLE `goods`  (
  `id` int(0) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
  `name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '商品名',
  `price` decimal(10, 2) NOT NULL COMMENT '商品价格',
  `stock` int(0) NOT NULL COMMENT '库存',
  `sale` int(0) NOT NULL COMMENT '售卖数量',
  `version` int(0) NOT NULL COMMENT '乐观锁版本号',
  `gmt_create` datetime(0) NOT NULL COMMENT '创建时间',
  `gmt_modified` datetime(0) NOT NULL COMMENT '修改时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `id` int(0) NOT NULL AUTO_INCREMENT COMMENT '用户Id',
  `name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '姓名',
  `usernam` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户名',
  `password` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '密码',
  `gmt_create` datetime(0) NOT NULL COMMENT '创建时间',
  `gmt_modified` datetime(0) NOT NULL COMMENT '修改时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;



4.2、项目创建

这里使用了SpringBoot创建项目,自己的项目使用了Mybatis-Plus的代码生成器这里依赖就不写出来了,还有基本的SpringBoot依赖也没有写出来。

导入的依赖有:

    <!--MybatisPlus-->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.3.2</version>
    </dependency>
    <!--swagger-->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>2.10.5</version>
    </dependency>
    <!--druid-->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>1.1.22</version>
    </dependency>
    <!-- mysql -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.20</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!--guava包含谷歌令牌桶-->
    <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>29.0-jre</version>
    </dependency>



5、JMeter压力测试工具安装



5.1、JMeter介绍(百度百科)

Apache JMeter是Apache组织开发的基于Java的压力测试工具。用于对软件做压力测试,它最初被设计用于Web应用测试,但后来扩展到其他测试领域。 它可以用于测试静态和动态资源,例如静态文件、Java

小服务程序

、CGI 脚本、Java 对象、数据库、FTP 服务器, 等等。JMeter 可以用于对服务器、网络或对象模拟巨大的负载,来自不同压力类别下测试它们的强度和分析整体性能。另外,JMeter能够对应用程序做功能/

回归测试

,通过创建带有断言的脚本来验证你的程序返回了你期望的结果。为了最大限度的灵活性,JMeter允许

使用正则表达式

创建断言。

Apache JMeter可以用于对静态的和动态的资源(文件,Servlet,Perl脚本,java 对象,数据库和查询,

FTP服务器

等等)的性能进行测试。它可以用于对服务器、网络或对象模拟繁重的负载来测试它们的强度或分析不同压力类型下的整体性能。你可以使用它做性能的图形分析或在大并发

负载测试

你的服务器/脚本/对象。



5.2、下载安装(需要Java 8+)


下载地址

image-20200913200025215


解压进入bin路径双击

jmeter.bat

等待UI界面启动



5.3、使用



5.3.1、UI界面的使用(官方不推荐)


可以改中文

image-20200913203346870


添加线程组

image-20200913203431776


创建HTTP请求

image-20200913203518124

image-20200913203610452


添加结果树

image-20200913203640442


启动

image-20200913200403478



5.3.2、命令行(官方推荐)

image-20200913200451088

**翻译:**不要使用GUI模式进行负载测试!,仅用于测试创建和测试调试。对于负载测试,请使用CLI模式(非GUI)。

jmeter -n -t [jmx file](压力测试的文件) -l [results file](结果文件) -e -o [Path to web report folder](html版的压力测试报告)

# 示例
jmeter.bat -n -t E:\JavaSoftware\JMeter\jmx\miaosha.jmx -l E:\JavaSoftware\JMeter\jmx\miaosha.txt -e -o E:\JavaSoftware\JMeter\html


详细使用在后面测试的时候一起讲解



6、超卖情况



6.1、代码实现


CommodityOrderService文件

package top.ddandang.seckill.service;

import top.ddandang.seckill.model.pojo.CommodityOrder;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author D
 * @since 2020-09-13
 */
public interface CommodityOrderService extends IService<CommodityOrder> {

    /**
     * 演示超卖现象
     *
     * @param userId 用户Id
     * @param goodsId  商品Id
     * @return 生成的订单Id
     */
    int overSold(Integer userId, Integer goodsId);
}


CommodityOrderServiceImpl文件

package top.ddandang.seckill.service.impl;

import org.springframework.transaction.annotation.Transactional;
import top.ddandang.seckill.mapper.GoodsMapper;
import top.ddandang.seckill.model.pojo.CommodityOrder;
import top.ddandang.seckill.mapper.CommodityOrderMapper;
import top.ddandang.seckill.model.pojo.Goods;
import top.ddandang.seckill.service.CommodityOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author D
 * @since 2020-09-13
 */
@Service
public class CommodityOrderServiceImpl extends ServiceImpl<CommodityOrderMapper, CommodityOrder> implements CommodityOrderService {

    @Resource
    private GoodsMapper goodsMapper;

    @Resource
    private CommodityOrderMapper commodityOrderMapper;

    @Transactional(rollbackFor=Exception.class)
    @Override
    public int overSold(Integer userId, Integer goodsId) {
        //判断库存是否充足
        Goods goods = checkInventory(goodsId);
        //扣除库存
        deductInventory(goods);
        //生成订单
        return generateOrders(userId, goodsId);

    }

    /**
     * 检测商品的库存
     *
     * @param goodsId 商品Id
     * @return 如果库存充足则返回商品的信息 否则抛出异常
     */
    private Goods checkInventory(Integer goodsId) {
        Goods goods = goodsMapper.selectById(goodsId);
        // 如果库存等于售卖 则商品售空
        if (goods.getSale() >= goods.getStock()) {
            throw new RuntimeException(goods.getName() + "已经售空!!");
        }
        return goods;
    }

    /**
     * 给用户生成商品订单
     *
     * @param userId  用户Id
     * @param goodsId 商品Id
     * @return 生成的订单Id
     */
    private Integer generateOrders(Integer userId, Integer goodsId) {
        CommodityOrder order = new CommodityOrder()
                .setUserId(userId)
                .setGoodsId(goodsId);
        System.out.println(order);
        int row = commodityOrderMapper.insert(order);
        if (row == 0) {
            throw new RuntimeException("生成订单失败!!");
        }
        return order.getId();
    }

    /**
     * 扣除库存(增加售卖数量)这里没有使用乐观锁
     * @param goods 商品信息
     */
    private void deductInventory(Goods goods) {
        int updateRows = goodsMapper.updateSaleNoOptimisticLock(goods);
        if(updateRows == 0) {
            throw new RuntimeException("抢购失败,请重试!");
        }
    }
}



CommodityOrderController文件

package top.ddandang.seckill.controller;


import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import org.springframework.web.bind.annotation.RestController;
import top.ddandang.seckill.service.CommodityOrderService;
import top.ddandang.seckill.utils.R;

import javax.annotation.Resource;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author D
 * @since 2020-09-13
 */
@RestController
@RequestMapping("/commodity-order")
@Slf4j
public class CommodityOrderController {

    @Resource
    private CommodityOrderService commodityOrderService;

    /**
     * 会出现超卖的接口
     *
     * @param userId  用户Id
     * @param goodsId 商品Id
     * @return 订单编号
     */
    @PostMapping("/overSold")
    public R overSold(Integer userId, Integer goodsId) {
        log.info("用户Id = {},商品Id = {}", userId, goodsId);
        try {
            int orderId = commodityOrderService.overSold(userId, goodsId);
            return R.success().data("orderId", orderId);
        } catch (RuntimeException e) {
            e.printStackTrace();
            return R.failed().message(e.getMessage());
        }
    }
}



6.2、单点测试(非高并发)

这里使用Swagger进行测试。

image-20200913202201311

image-20200913202558170

image-20200913202623604


再进行点击的时候出现商品已售空的情况

image-20200913202526519


由此看来整个接口是没有问题的,在单点测试中也没有任何问题。然后这里将数据都重置(售卖数改为0,和订单都删除)。



6.3、JMeter测试


测试:

这里发送1000个请求,模拟不同的用户购买不同的商品(使用UI界面),这里用户随机Id和商品随机Id都是数据库存在的数据,没有进一步校验。



6.3.1、创建随机数

image-20200913203832397

image-20200913203945773

image-20200913204040543



6.3.2、运行观察结果


商家:都售空了销量不错,心里开心

image-20200913204811746

image-20200913204828059

image-20200913204908118


看了下订单打印机发现怎么售空了还在一直打印,然后看了看数据库(商家不可能看数据库的)

image-20200913205526081


商家

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QllK708W-1600009472823)(http://image.dbbqb.com/202009132055/20f49fb6d82235ea786d1e14727f04de/gO6pl)]


程序员


愿天堂永无超卖



7、解决超卖(悲观锁)



7.1、解释

使用

synchronized

进行加锁,这个也就是悲观锁。悲观锁一次性只允许一个线程进入,其他的线程都是在阻塞状态,这也就是解决了超卖,然后负优化了用户的体验。


注意:这里加锁不能和事务一起使用。

synchronized (this) {
    int orderId = commodityOrderService.overSold(userId, goodsId);
    return R.success().data("orderId", orderId);
}


百度百科:

悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他

事务

,以及来自外部系统的

事务处理

)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的

排他性

,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。



7.2、代码实现

package top.ddandang.seckill.controller;


import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import org.springframework.web.bind.annotation.RestController;
import top.ddandang.seckill.service.CommodityOrderService;
import top.ddandang.seckill.utils.R;

import javax.annotation.Resource;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author D
 * @since 2020-09-13
 */
@RestController
@RequestMapping("/commodity-order")
@Slf4j
public class CommodityOrderController {

    @Resource
    private CommodityOrderService commodityOrderService;

    /**
     * 会出现超卖的接口
     *
     * @param userId  用户Id
     * @param goodsId 商品Id
     * @return 订单编号
     */
    @PostMapping("/overSold")
    public R overSold(Integer userId, Integer goodsId) {
        log.info("用户Id = {},商品Id = {}", userId, goodsId);
        try {
            int orderId = commodityOrderService.overSold(userId, goodsId);
            return R.success().data("orderId", orderId);
        } catch (RuntimeException e) {
            e.printStackTrace();
            return R.failed().message(e.getMessage());
        }
    }

    /**
     * 悲观锁解决超卖,一次只有一个线程进入后面的线程都在阻塞
     *
     * @param userId  用户Id
     * @param goodsId 商品Id
     * @return 订单编号
     */
    @PostMapping("/pessimisticLockSold")
    public R pessimisticLockSold(Integer userId, Integer goodsId) {
        log.info("用户Id = {},商品Id = {}", userId, goodsId);
        try {
            synchronized (this) {
                int orderId = commodityOrderService.overSold(userId, goodsId);
                return R.success().data("orderId", orderId);
            }
        } catch (RuntimeException e) {
            e.printStackTrace();
            return R.failed().message(e.getMessage());
        }
    }
}



7.3、JMeter测试


测试之前记得更改HTTP请求的地址,或者新建一个HTTP请求同时删除数据库的数据(销售量清零,订单删除)

image-20200913210739663

image-20200913211407566

image-20200913211429814


商家:

image-20200913211407566


用户:

image-20200913211757614



8、解决超卖(乐观锁)



8.1、解释

这里在数据库设计的时候给商品表增加了

version

字段。

image-20200913212102414


百度百科:

乐观锁( Optimistic Locking ) 相对

悲观锁

而言,乐观锁机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长

事务

而言,这样的开销往往无法承受。而乐观锁机制在一定程度上解决了这个问题。乐观锁,大多是基于数据版本( Version )记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号等于数据库表当前版本号,则予以更新,否则认为是过期数据。



8.2、乐观锁version图解

乐观锁解析



8.3、代码实现

package top.ddandang.seckill.mapper;

import org.apache.ibatis.annotations.Update;
import top.ddandang.seckill.model.pojo.Goods;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

/**
 * <p>
 * Mapper 接口
 * </p>
 *
 * @author D
 * @since 2020-09-13
 */
public interface GoodsMapper extends BaseMapper<Goods> {

    /**
     * 不包含乐观锁
     * 根据商品Id去扣除库存 数据库操作只有一个线程去操作
     *
     * @param goods 商品
     * @return 影响行数
     */
    @Update("update goods set sale = sale + 1, gmt_modified = now() where id = #{id}")
    int updateSaleNoOptimisticLock(Goods goods);

    /**
     * 包含乐观锁
     * 根据商品Id去扣除库存 数据库操作只有一个线程去操作
     * 注意version++的时候不要在java里面,应该直接在mysql语句中写
     *
     * @param goods 商品
     * @return 影响行数
     */
    @Update("update goods set sale = sale + 1,version = version + 1, gmt_modified = now() where id = #{id} and version = #{version}")
    int updateSaleOptimisticLock(Goods goods);
}


CommodityOrderServiceImpl文件增加代码

    @Transactional(rollbackFor=Exception.class)
    @Override
    public int optimisticLockSold(Integer userId, Integer goodsId) {
        //判断库存是否充足
        Goods goods = checkInventory(goodsId);
        //扣除库存 使用了乐观锁
        deductInventoryOptimisticLock(goods);
        //生成订单
        return generateOrders(userId, goodsId);
    }

    /**
     * 扣除库存(增加售卖数量)使用了乐观锁
     * @param goods 商品信息
     */
    private void deductInventoryOptimisticLock(Goods goods) {
        int updateRows = goodsMapper.updateSaleOptimisticLock(goods);
        if(updateRows == 0) {
            throw new RuntimeException("抢购失败,请重试!");
        }
    }
package top.ddandang.seckill.service;

import top.ddandang.seckill.model.pojo.CommodityOrder;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author D
 * @since 2020-09-13
 */
public interface CommodityOrderService extends IService<CommodityOrder> {

    /**
     * 演示超卖现象
     *
     * @param userId 用户Id
     * @param goodsId  商品Id
     * @return 生成的订单Id
     */
    int overSold(Integer userId, Integer goodsId);


    /**
     * 使用乐观锁防止超卖,乐观锁用户体验也更好
     *
     * @param userId 用户Id
     * @param goodsId  商品Id
     * @return 生成的订单Id
     */
    int optimisticLockSold(Integer userId, Integer goodsId);
}


CommodityOrderController文件增加代码

/**
 * 乐观锁解决超卖
 *
 * @param userId  用户Id
 * @param goodsId 商品Id
 * @return 订单编号
 */
@PostMapping("/optimisticLockSold")
public R optimisticLockSold(Integer userId, Integer goodsId) {
    log.info("用户Id = {},商品Id = {}", userId, goodsId);
    try {
        int orderId = commodityOrderService.optimisticLockSold(userId, goodsId);
        return R.success().data("orderId", orderId);
    } catch (RuntimeException e) {
        e.printStackTrace();
        return R.failed().message(e.getMessage());
    }
}



8.4、JMeter测试


测试之前记得更改HTTP请求的地址,或者新建一个HTTP请求同时删除数据库的数据(销售量清零,订单删除)

image-20200913222623247

image-20200913222652195



9、解决高并发问题(接口限流)



9.1、接口限流


接口限流:字面意思就是对一个接口的请求数据进行限制,如果一次性来了亿点点请求直接打在数据库上结果怎么样就不用说了,所以要对流量进行限制。



9.2、接口限流方式

常见的接口限流有以下几种。


  1. 令牌桶算法(Token Bucket)

  2. 漏桶算法(Leaky Bucket)

  3. 基于Redis计数

  4. Alibaba Sentinel



10、接口限流之漏桶算法



10.1、概述

漏桶可以看作是一个带有常量服务时间的单服务器

队列

,如果漏桶(包缓存)溢出,那么数据包会被丢弃。

也就是说不管什么情况都是以一定的速率进行输出的,如果再高并发的时候速率还是不会变,就会导致许多的请求被抛弃,不能应对突发情况(高并发),所有在限流的时候一般不使用这个方法。

image-20200913234045299



10.2、代码实现

package top.ddandang.seckill.utils;

import lombok.extern.slf4j.Slf4j;

/**
 * <p>
 * 漏桶算法
 * </p>
 *
 * @author: D
 * @since: 2020/9/13
 * @version: 1
 */
@Slf4j
public class LeakyBucket {

    /**
     * 漏桶输出的速率
     */
    private final double rate;

    /**
     * 桶的容量
     */
    private final double capacity;

    /**
     * 桶中现在有的水量
     */
    private int storage;

    /**
     * 上一次刷新时间的时间戳
     */
    private long refreshTime;

    public LeakyBucket(double rate, double capacity) {
        this.rate = rate;
        this.capacity = capacity;
        this.storage = 0;
        this.refreshTime = System.currentTimeMillis();
    }

    /**
     * 每次请求都会刷新桶中水的存储量
     */
    private void refreshStorage() {
        // 获取当前的时间戳
        long nowTime = System.currentTimeMillis();
        // 想象成水以一定的速率流出 但是如果加水的速度过快(高并发),水就满出来了
        storage = (int) Math.max(0, storage - (nowTime - refreshTime) * rate);
        refreshTime = nowTime;
    }

    /**
     * 请求是否进入桶(该请求是否有效)
     *
     * @return true代表请求有效 反之无效
     */
    public synchronized boolean enterBucket() {
        refreshStorage();
        log.info("桶的存储量 = {}", storage);
        // 桶内水的存储量小于桶的容量则成功请求并将桶内水的容量加一
        if (storage < capacity) {
            storage++;
            return true;
        } else {
            return false;
        }
    }
}


在CommodityOrderController类中增加以下方法

    // 设置流速和桶的容量
    private final static LeakyBucket leakyBucket = new LeakyBucket(0.01, 10);

    /**
     * 乐观锁解决超卖 + 漏桶限流
     *
     * @param userId  用户Id
     * @param goodsId 商品Id
     * @return 订单编号
     */
	private static int count = 0;

    @PostMapping("/optimisticLockAndLeakyBucketSold")
    public R optimisticLockAndLeakyBucketSold(Integer userId, Integer goodsId) {
        try {
            boolean enterBucket = leakyBucket.enterBucket();
            if (!enterBucket) {
                log.error("系统繁忙请稍后再试");
                return R.failed().message("系统繁忙请稍后再试");
            }
            count++;
            log.error("请求的次数 = {}", count);
            //这里没有调用购买的方法,不想一直改数据库
	       	//int orderId = commodityOrderService.optimisticLockSold(userId, goodsId);
            return R.success().data("orderId", 1);
        } catch (RuntimeException e) {
            return R.failed().message(e.getMessage());
        }
    }



10.3、测试


count

是没有拦截的请求的个数。

image-20200914225428092

private final static LeakyBucket leakyBucket = new LeakyBucket(1, 5);

image-20200914225415543


1000个请求大概拦截的165个请求,可以通过调节流速和桶的容量进行调节拦截请求的个数(拦截率)



11、接口限流之令牌桶算法



11.1、解释

令牌桶算法是

网络流量

整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。典型情况下,令牌桶算法用来控制发送到网络上的数据的数目,并

允许突发数据

的发送。

工作过程包括3个阶段:产生令牌、消耗令牌和判断数据包是否通过。其中涉及到2个参数:令牌产生的速率CIR(Committed Information Rate)/EIR(Excess Information Rate)和令牌桶的大小CBS(Committed Burst Size)/EBS(Excess Burst Size)。

  1. 产生令牌:周期性的以速率CIR/EIR向令牌桶中增加令牌,桶中的令牌不断增多。如果桶中令牌数已到达CBS/EBS,则丢弃多余令牌。
  2. 消耗令牌:输入数据包会消耗桶中的令牌。在网络传输中,数据包的大小通常不一致。大的数据包相较于小的数据包消耗的令牌要多。
  3. 判断是否通过:输入数据包经过令牌桶后的结果包括输出的数据包和丢弃的数据包。当桶中的令牌数量可以满足数据包对令牌的需求,则将数据包输出,否则将其丢弃。



11.2、使用谷歌guava中的令牌桶RateLimiter



11.2.1、导入依赖

<!--guava-->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>29.0-jre</version>
</dependency>



11.2.2、引用


可以通过调整参数来控制速率

/**
 * 创建令牌桶的实例
 */
private final RateLimiter rateLimiter = RateLimiter.create(100);



11.3、测试

令牌桶图解


rateLimiter.acquire()


rateLimiter.acquire();

不填写参数默认每次获取一个令牌,依然可以填写参数,这个方法并不会拦截请求只是限制了速度,==也就是说全部的请求都会进去。==该方法返回的时等待时间。

    /**
     * 乐观锁解决超卖 + 令牌桶限流
     *
     * @param userId  用户Id
     * @param goodsId 商品Id
     * @return 订单编号
     */
    @PostMapping("/optimisticLockAndTokenBucketSold")
    public R optimisticLockAndTokenBucketSold(Integer userId, Integer goodsId) {
        try {
            double acquire = rateLimiter.acquire();
            log.info("等待时间:{}", acquire);
            count++;
            log.error("请求的次数 = {}", count);
//            int orderId = commodityOrderService.optimisticLockSold(userId, goodsId);
            return R.success().data("orderId", 1);
        } catch (RuntimeException e) {
            return R.failed().message(e.getMessage());
        }
    }

image-20200915001150375


rateLimiter.tryAcquire


rateLimiter.tryAcquire(1, 1, TimeUnit.SECONDS);

第一个参数不填写也默认一次获取一个令牌,第二个参数是数值,第三个是单位,也就是说如果获取令牌的时候

等待时间超过1s则返回false

,可以根据这个返回值进行拦截请求。

    /**
     * 乐观锁解决超卖 + 令牌桶限流
     *
     * @param userId  用户Id
     * @param goodsId 商品Id
     * @return 订单编号
     */
    @PostMapping("/optimisticLockAndTokenBucketSold")
    public R optimisticLockAndTokenBucketSold(Integer userId, Integer goodsId) {
        try {
//            double acquire = rateLimiter.acquire();
//            log.info("等待时间:{}", acquire);

            //获取1个令牌 如果等待时间超过了1秒则拒绝
            boolean acquire = rateLimiter.tryAcquire(1, 1, TimeUnit.SECONDS);
            if (!acquire) {
                log.error("系统繁忙请稍后再试");
                return R.failed().message("系统繁忙请稍后再试");
            }
            count++;
            log.info("请求的次数 = {}", count);
//            int orderId = commodityOrderService.optimisticLockSold(userId, goodsId);
            return R.success().data("orderId", 1);
        } catch (RuntimeException e) {
            return R.failed().message(e.getMessage());
        }
    }

image-20200915001822337

image-20200915001835309


相比上一个方法这个方法并不是全部的请求都执行,而是可以自行进行控制拦截的。


加入购买商品业务测试(记得清空数据)

image-20200915004755064

image-20200915004811013



11.4、漏桶和令牌桶比较

漏桶算法:漏桶算法是强行限制数据的传输速率,也就是说就算当前桶内是空的它也是以固定的速率进行输出的,因此没有应对突发情况的能力,大量的请求过来也都是以一定的速率输出。

令牌桶算法:由于是获取令牌的方式来达到限流的,因此如果令牌数满的话这时候来了大量的请求这些请求会把令牌全部取走,这种情况也就是应对突发情况的能力(漏桶在这种情况输出的速率还是固定的)。



12、接口限流之Redis计数器(不完善)



12.1、介绍

这里使用Redis计数器,在请求过来的时候使计数器+1,并判断是否到达上限,如果到达上限则进行拦截。并开启一个定时任务使计数器减一个值(这里为2)。


这个拦截器会拦截大量的请求


可以通过调整最大值,和定时器的时间或者定时器中减的数量来控制拦截率。



12.2、代码

/**
 * 使用redis计数器进行限流
 * @return true可以正常访问 false为限制访问
 */
@Override
@Synchronized
public boolean redisCurrentLimit() {
    // 返回加一之后的值
    long incr = redisUtil.incr(LIMIT_KEY, 1);
    if (incr >= 200) {
        redisUtil.decr(LIMIT_KEY, 1);
        return false;
    }
    return true;
}

/**
 * initialDelay 服务启动100秒后执行一次
 * fixedRate 每隔50毫秒执行一次
 */
@Scheduled(initialDelay = 100,fixedRate = 50)
public void decrease() {
    Integer count = (Integer) redisUtil.get(LIMIT_KEY);
    log.info("count = {}", count);
    if (count != null && count > 0) {
        redisUtil.decr(LIMIT_KEY, 2);
    }
}



12.3、测试


1000个请求只有300多点的请求进入了,而已拦截的请求都集中在一起。

image-20200915102930889



13、接口限流之Alibaba Sentinel



13.1、什么是Alibaba Sentinel?


Sentinel:

分布式系统的流量防卫兵。

Sentinel 具有以下特征:


  • 丰富的应用场景

    :Sentinel 承接了阿里巴巴近 10 年的双十一大促流量的核心场景,例如秒杀(即突发流量控制在系统容量可以承受的范围)、消息削峰填谷、集群流量控制、实时熔断下游不可用应用等。

  • 完备的实时监控

    :Sentinel 同时提供实时的监控功能。您可以在控制台中看到接入应用的单台机器秒级数据,甚至 500 台以下规模的集群的汇总运行情况。

  • 广泛的开源生态

    :Sentinel 提供开箱即用的与其它开源框架/库的整合模块,例如与 Spring Cloud、Dubbo、gRPC 的整合。您只需要引入相应的依赖并进行简单的配置即可快速地接入 Sentinel。

  • 完善的 SPI 扩展点

    :Sentinel 提供简单易用、完善的 SPI 扩展接口。您可以通过实现扩展接口来快速地定制逻辑。例如定制规则管理、适配动态数据源等。



13.2、Alibaba Sentinel安装运行

下载的是一个独立的jar包,可以直接运行(需要java环境)。

下载慢的时候可以使用迅雷下载。


官方下载地址

image-20200915104127055

image-20200915104647495

image-20200915104659642


注意这里是8080端口,账号密码都是sentinel

image-20200915104801728



13.3、配置文件编写


application.yaml

cloud:
  sentinel:
    transport:
      // sentinel地址
      dashboard: localhost:8080



13.4、运行测试


进入sentinel UI页面会出现当前的项目已经在里面了(可能需要发送一个请求才出现)

image-20200915120931681



13.5、流控规则解释

image-20200915121132103

  • 资源名:唯一的名字(默认为请求的路径)
  • 针对来源:可以针对调用者进行限流(也就是可以限制谁调用)
  • 阈值类型:

    • QPS(每秒的请求数量):当调用的接口QPS达到阈值的时候进行限流
    • 线程数:当调用的接口线程数达到阈值的时候进行限流。
  • 是否集群:这里没有弄集群
  • 流控模式:

    • 直接:接口达到上面的限流条件的时候直接进行限流
    • 关联:当关联的资源(可以认为是别的接口)到达阈值的时候限流自己。
    • 链路:只记录指定链路上的流量
  • 流控效果:

    • 快速失败:直接失败并抛出异常
    • Warm Up:根据codeFactor(冷加载因子,默认为3) 的值,从阈值/codeFactor,经过预热时长,才达到设置的QPS阈值。详细见官网冷启动
    • 排队等待:匀速排队,让请求以均匀的速率通过(阈值类型必须为QPS)



13.6、测试



13.6.1、快速失败测试一

请求数 阈值类型 阈值 流控模式 流控效果
500 QPS 10 直接 快速失败

image-20200915122658327


接受到的请求30次左右

image-20200915123052640


错误信息查看JMeter中的结果树

image-20200915123226047



13.6.2、快速失败测试二

请求数 阈值类型 阈值 流控模式 流控效果
500 线程数 10 直接

image-20200915123459344

image-20200915123708870

image-20200915123719823



13.6.3、快速失败测试三


如果关联的资源达到了限流的条件,则将当前配置的接口(自己)进行限制(直接抛异常)。

image-20200915123925086



13.6.4、Warm Up测试

image-20200915124832809


Warm Up:

默认

coldFactor

为 3,即请求 QPS 从

threshold / 3

开始,经预热时长逐渐升至设定的 QPS 阈值。


以上图进行解释:意思就是在5秒前阈值是 30/3 = 10(前5s,QPS到达10就抛出异常),5秒后阈值慢慢升到设定的阈值(30



13.6.5、排队等待

image-20200915125450199


等待时间超过1s则直接拒绝,这个和那个令牌桶的差不多的,都是有一个等待时间



版权声明:本文为d875708765原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。