限流方案学习汇总(业务层+技术层+分布式)

业务层

合法性验证

比如验证码、IP 黑名单等,这些手段可以有效的防止恶意攻击和爬虫采集

业务限流

比如抢购活动在前端就可以拦截掉部分请求只允许会员进行抢购

技术层

容器限流

tomcat限流

Tomcat 8.5 版本的最大线程数在 conf/server.xml 配置中,如下所示:

<Connector port="8080" protocol="HTTP/1.1"
          connectionTimeout="20000"
          maxThreads="150"
          redirectPort="8443" />

其中 maxThreads 就是 Tomcat 的最大线程数,当请求的并发大于此值(maxThreads)时,请求就会排队执行,这样就完成了限流的目的。

maxThreads 的值可以适当的调大一些,此值默认为 150(Tomcat 版本 8.5.42),但这个值也不是越大越好,要看具体的硬件配置,需要注意的是每开启一个线程需要耗用 1MB 的 JVM 内存空间用于作为线程栈之用,并且线程越多 GC 的负担也越重。最后需要注意一下,操作系统对于进程中的线程数有一定的限制,Windows 每个进程中的线程数不允许超过 2000,Linux 每个进程中的线程数不允许超过 1000。

Nginx 限流

Nginx 提供了两种限流手段:一是控制速率,二是控制并发连接数。

控制速率

我们需要使用 limit_req_zone 用来限制单位时间内的请求数,即速率限制,示例配置如下:

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
    location / {
        limit_req zone=mylimit;
    }
}

以上配置表示,限制每个 IP 访问的速度为 2r/s,因为 Nginx 的限流统计是基于毫秒的,我们设置的速度是 2r/s,转换一下就是 500ms 内单个 IP 只允许通过 1 个请求,从 501ms 开始才允许通过第 2 个请求。

速率限制升级版

上面的速率控制虽然很精准但是应用于真实环境未免太苛刻了,真实情况下我们应该控制一个 IP 单位总时间内的总访问次数,而不是像上面那么精确但毫秒,我们可以使用 burst关键字开启此设置,示例配置如下:

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
    location / {
        limit_req zone=mylimit burst=4;
    }
}

burst=4 表示每个 IP 最多允许4个突发请求,如果单个 IP 在 10ms 内发送 6 次请求的结果如下:

从以上结果可以看出,有 1 个请求被立即处理了,4 个请求被放到 burst 队列里排队执行了,另外 1 个请求被拒绝了。

控制并发数

利用limit_conn_zone 和 limit_conn 两个指令即可控制并发数,示例配置如下:

limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;
server {
    ...
    limit_conn perip 10;
    limit_conn perserver 100;
}

其中limit_conn perip 10表示限制单个 IP 同时最多能持有 10 个连接;limit_conn perserver 100 表示 server 同时能处理并发连接的总数为 100 个。

只有当 request header 被后端处理后,这个连接才进行计数。

服务端限流

时间窗口算法

所谓的滑动时间算法指的是以当前时间为截止时间,往前取一定的时间,比如往前取 60s 的时间,在这 60s 之内运行最大的访问数为 100,此时算法的执行逻辑为,先清除 60s 之前的所有请求记录,再计算当前集合内请求数量是否大于设定的最大请求数 100,如果大于则执行限流拒绝策略,否则插入本次请求记录并返回可以正常执行的标识给客户端。

在这里插入图片描述
其中每一小个表示 10s,被红色虚线包围的时间段则为需要判断的时间间隔,比如 60s 秒允许 100 次请求,那么红色虚线部分则为 60s。
我们可以借助 Redis 的有序集合 ZSet 来实现时间窗口算法限流,实现的过程是先使用 ZSet 的 key 存储限流的 ID,score 用来存储请求的时间,每次有请求访问来了之后,先清空之前时间窗口的访问量,统计现在时间窗口的个数和最大允许访问量对比,如果大于等于最大访问量则返回 false 执行限流操作,负责允许执行业务逻辑,并且在 ZSet 中添加一条有效的访问记录,具体实现代码如下。
我们借助 Jedis 包来操作 Redis,实现在 pom.xml 添加 Jedis 框架的引用,配置如下:

<!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.3.0</version>
</dependency>
import redis.clients.jedis.Jedis;

public class RedisLimit {
    // Redis 操作客户端
    static Jedis jedis = new Jedis("127.0.0.1", 6379);

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 15; i++) {
            boolean res = isPeriodLimiting("java", 3, 10);
            if (res) {
                System.out.println("正常执行请求:" + i);
            } else {
                System.out.println("被限流:" + i);
            }
        }
        // 休眠 4s
        Thread.sleep(4000);
        // 超过最大执行时间之后,再从发起请求
        boolean res = isPeriodLimiting("java", 3, 10);
        if (res) {
            System.out.println("休眠后,正常执行请求");
        } else {
            System.out.println("休眠后,被限流");
        }
    }

    /**
     * 限流方法(滑动时间算法)
     * @param key      限流标识
     * @param period   限流时间范围(单位:秒)
     * @param maxCount 最大运行访问次数
     * @return
     */
    private static boolean isPeriodLimiting(String key, int period, int maxCount) {
        long nowTs = System.currentTimeMillis(); // 当前时间戳
        // 删除非时间段内的请求数据(清除老访问数据,比如 period=60 时,标识清除 60s 以前的请求记录)
        jedis.zremrangeByScore(key, 0, nowTs - period * 1000);
        long currCount = jedis.zcard(key); // 当前请求次数
        if (currCount >= maxCount) {
            // 超过最大请求次数,执行限流
            return false;
        }
        // 未达到最大请求数,正常执行业务
        jedis.zadd(key, nowTs, "" + nowTs); // 请求记录 +1
        return true;
    }
}
正常执行请求:0
正常执行请求:1
正常执行请求:2
正常执行请求:3
正常执行请求:4
正常执行请求:5
正常执行请求:6
正常执行请求:7
正常执行请求:8
正常执行请求:9
被限流:10
被限流:11
被限流:12
被限流:13
被限流:14
休眠后,正常执行请求

此实现方式存在的缺点有两个:
使用 ZSet 存储有每次的访问记录,如果数据量比较大时会占用大量的空间,比如 60s 允许 100W 访问时
此代码的执行非原子操作,先判断后增加,中间空隙可穿插其他业务逻辑的执行,最终导致结果不准确。

漏桶算法

在这里插入图片描述
滑动时间算法有一个问题就是在一定范围内,比如 60s 内只能有 10 个请求,当第一秒时就到达了 10 个请求,那么剩下的 59s 只能把所有的请求都给拒绝掉,而漏桶算法可以解决这个问题。
漏桶算法类似于生活中的漏斗,无论上面的水流倒入漏斗有多大,也就是无论请求有多少,它都是以均匀的速度慢慢流出的。当上面的水流速度大于下面的流出速度时,漏斗会慢慢变满,当漏斗满了之后就会丢弃新来的请求;当上面的水流速度小于下面流出的速度的话,漏斗永远不会被装满,并且可以一直流出。
漏桶算法的实现步骤是,先声明一个队列用来保存请求,这个队列相当于漏斗,当队列容量满了之后就放弃新来的请求,然后重新声明一个线程定期从任务队列中获取一个或多个任务进行执行,这样就实现了漏桶算法。
上面我们演示 Nginx 的控制速率其实使用的就是漏桶算法,当然我们也可以借助 Redis 很方便的实现漏桶算法。
我们可以使用 Redis 4.0 版本中提供的 Redis-Cell 模块,该模块使用的是漏斗算法,并且提供了原子的限流指令,而且依靠 Redis 这个天生的分布式程序就可以实现比较完美的限流了。
Redis-Cell 实现限流的方法也很简单,只需要使用一条指令 cl.throttle 即可,使用示例如下:
在这里插入图片描述
其中 15 为漏斗的容量,30 / 60s 为漏斗的速率。

令牌算法

在令牌桶算法中有一个程序以某种恒定的速度生成令牌,并存入令牌桶中,而每个请求需要先获取令牌才能执行,如果没有获取到令牌的请求可以选择等待或者放弃执行,如下图所示:
在这里插入图片描述
我们可以使用 Google 开源的 guava 包,很方便的实现令牌桶算法,首先在 pom.xml 添加 guava 引用,配置如下:

<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>28.2-jre</version>
</dependency>
import com.google.common.util.concurrent.RateLimiter;

import java.time.Instant;

/**
 * Guava 实现限流
 */
public class RateLimiterExample {
    public static void main(String[] args) {
        // 每秒产生 10 个令牌(每 100 ms 产生一个)
        RateLimiter rt = RateLimiter.create(10);
        for (int i = 0; i < 11; i++) {
            new Thread(() -> {
                // 获取 1 个令牌
                rt.acquire();
                System.out.println("正常执行方法,ts:" + Instant.now());
            }).start();
        }
    }
}
正常执行方法,ts:2020-05-15T14:46:37.175Z
正常执行方法,ts:2020-05-15T14:46:37.237Z
正常执行方法,ts:2020-05-15T14:46:37.339Z
正常执行方法,ts:2020-05-15T14:46:37.442Z
正常执行方法,ts:2020-05-15T14:46:37.542Z
正常执行方法,ts:2020-05-15T14:46:37.640Z
正常执行方法,ts:2020-05-15T14:46:37.741Z
正常执行方法,ts:2020-05-15T14:46:37.840Z
正常执行方法,ts:2020-05-15T14:46:37.942Z
正常执行方法,ts:2020-05-15T14:46:38.042Z
正常执行方法,ts:2020-05-15T14:46:38.142Z

从以上结果可以看出令牌确实是每 100ms 产生一个,而 acquire()方法为阻塞等待获取令牌,它可以传递一个 int 类型的参数,用于指定获取令牌的个数。它的替代方法还有 tryAcquire(),此方法在没有可用令牌时就会返回 false 这样就不会阻塞等待了。当然 tryAcquire() 方法也可以设置超时时间,未超过最大等待时间会阻塞等待获取令牌,如果超过了最大等待时间,还没有可用的令牌就会返回 false。

注意:使用 guava 实现的令牌算法属于程序级别的单机限流方案,而上面使用 Redis-Cell 的是分布式的限流方案(redis也可以做单机版)。

分布式 - 基于 Spring cloud Gateway 的限流

<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
    </dependency>

spring:
  application:
    name: gateway-service
  redis: #redis相关配置
    database: 8
    host: 10.12.15.5
    port: 6379
    password: 123456 #有密码时设置
    jedis:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0
    timeout: 10000ms

@Primary
  @Bean(value = "ipKeyResolver")
  KeyResolver ipKeyResolver() {
      return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getHostName());
      //return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
      //return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
  }

 
  @Bean(value = "apiKeyResolver")
  KeyResolver apiKeyResolver() {
    return exchange -> Mono.just(exchange.getRequest().getPath().value());
  }

  
  @Bean(value = "userKeyResolver")
  KeyResolver userKeyResolver() {
    return exchange -> Mono.just(exchange.getRequest().getQueryParams().getFirst("userId"));
  }


这里引入ipKeyResolver、apiKeyResolver、userKeyResolver三种策略,可以利用注解 @Primary 来决定其中一个被使用。
注入bean后,需要在配置中备用:

spring:
  application:
    name: gateway-service
  redis: #redis相关配置
    database: 8
    host: 10.12.15.5
    port: 6379
    password: 123456 #有密码时设置
    jedis:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0
    timeout: 10000ms

后面是限流的主要配置:

spring
  cloud:
    gateway:
      routes: #路由配置:参数为一个List
      - id: cas-server #唯一标识
        uri: lb://cas-server-service #转发的地址,写服务名称
        order: -1
        predicates:
        - Path=/cas-server/** #判断匹配条件,即地址带有/ribbon/**的请求,会转发至lb:cas-server-service
        filters:
        - StripPrefix=1 #去掉Path前缀,参数为1代表去掉/ribbon

        - name: RequestRateLimiter #基于redis的Gateway的自身限流
          args:
            redis-rate-limiter.replenishRate: 1  # 允许用户每秒处理多少个请求
            redis-rate-limiter.burstCapacity: 3  # 令牌桶的容量,允许在一秒钟内完成的最大请求数
            key-resolver: "#{@ipKeyResolver}" #SPEL表达式取的对应的bean

      - id: admin-web
        uri: lb://admin-web-service
        order: -1
        predicates:
        - Path=/admin-web/**
        filters:
        - StripPrefix=1

        - name: RequestRateLimiter
          args:
            redis-rate-limiter.replenishRate: 1  # 允许用户每秒处理多少个请求
            redis-rate-limiter.burstCapacity: 3  # 令牌桶的容量,允许在一秒钟内完成的最大请求数
            key-resolver: "#{@ipKeyResolver}" #SPEL表达式取的对应的bean


这里是在原有的路由基础上加入 RequestRateLimiter限流过滤器,包括三个参数:

- name: RequestRateLimiter #基于redis的Gateway的自身限流
          args:
            redis-rate-limiter.replenishRate: 3  #允许用户每秒处理多少个请求
            redis-rate-limiter.burstCapacity: 5  #令牌桶的容量,允许在一秒钟内完成的最大请求数
            key-resolver: "#{@ipKeyResolver}" #SPEL表达式取的对应的bean

其中 replenishRate,其含义表示允许每秒处理请求数;
burstCapacity 表示允许在一秒内处理的最大请求数;
key-resolver 这里采用请求 IP 限流,利用SPEL 表达式取对应的 bean

写一个小脚本来压测一下

for i in $(seq 1 30000); do echo $(expr $i \\* 3 + 1);curl -i -H "Accept: application/json" -H "Authorization:bearer b064d95b-af3f-4053-a980-377c63ab3413" -X GET http://10.10.15.5:5556/order-service/api/order/getUserInfo;done

for i in $(seq 1 30000); do echo $(expr $i \\* 3 + 1);curl -i -H "Accept: application/json" -H "Authorization:bearer b064d95b-af3f-4053-a980-377c63ab3413" -X GET http://10.10.15.5:5556/admin-web/api/user/getCurrentUser;done

上面两个脚本分别对2个服务进行压测,打印结果:

{“message”:{“status”:200,“code”:0,“message”:“success”},“data”:“{“message”:{“status”:200,“code”:0,“message”:“get user success”},“data”:{“id”:23,“isAdmin”:1,“userId”:“fbb18810-e980-428c-932f-848f3b9e7c84”,“userType”:“super_admin”,“username”:“admin”,“realName”:“super_admin”,“password”:”$2a$10$89AqlYKlnsTpNmWcCMvgluRFQ/6MLK1k/nkBpz.Lw6Exh.WMQFH6W",“phone”:null,“email”:null,“createBy”:“admin”,“createTime”:1573119753172,“updateBy”:“admin”,“updateTime”:1573119753172,“loginTime”:null,“expireTime”:null,“remarks”:“super_admin”,“delFlag”:0,“loginType”:null}}"}ex

在用测试工具Jmeter在同一秒内多次请求后:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Remaining: 0
X-RateLimit-Burst-Capacity: 3
X-RateLimit-Replenish-Rate: 1
content-length: 0

expr: syntax error

HTTP/1.1 429 Too Many Requests
X-RateLimit-Remaining: 0
X-RateLimit-Burst-Capacity: 3
X-RateLimit-Replenish-Rate: 1
content-length: 0

expr: syntax error


从上面可以看到,执行后,会出现调用失败的情况,状态变为429 (Too Many Requests) 。

分布式 - 基于阿里开源限流神器:Sentinel

<!--基于 阿里的sentinel作限流 -->
    <dependency>
          <groupId>com.alibaba.cloud</groupId>
          <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
    </dependency>

在配置文件 application.yaml 文件中配置,需要新增2个配置:

spring:
  application:
    name: admin-web
  cloud:
    kubernetes:
      discovery:
        all-namespaces: true
    sentinel:
      eager: true #取消Sentinel控制台的懒加载
      transport:
        dashboard: 10.12.15.2:8080 #sentinel的Dashboard地址
        port: 8719 #是sentinel应用端和控制台通信端口
        heartbeat-interval-ms: 500 #心跳时间
      scg:
        fallback: #scg.fallback为sentinel限流后的响应配置
          mode: response
          response-status: 455
          response-body: 已被限流

其中,这里面配置了一个服务:spring.cloud.sentinel.transport.dashboard,配置的是 sentinel 的 Dashboard 地址。同时 spring.cloud.sentinel.transport.port 这个端口配置会在应用对应的机器上启动一个Http Server,该 Server 会与 Sentinel 控制台做交互。
Sentinel 默认为所有的 HTTP 服务提供限流埋点,上面配置完成后自动完成所有埋点,只需要控制配置限流规则即可。
这里我们讲下通过注解来给指定接口函数加上限流埋点,写一个RestController,在接口函数上加上注解

@SentinelResource:
@GetMapping(value = "/getToken")
@SentinelResource("getToken")
public Response<Object> getToken(Authentication authentication){
    //Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    authentication.getCredentials();
    OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails)authentication.getDetails();
    String token = details.getTokenValue();
    return Response.ok(200, 0, "get token success", token);
}

以上代码部分完成了,接下来先安装SentinelDashBoard,Sentinel DashBoard下载地址:github.com/alibaba/Sentinel/releases。
下载完成后,命令启动:

java -jar sentinel-dashboard-1.6.2.jar

默认启动端口为8080,访问 IP:8080,就可以显示 Sentinel 的登录界面,用户名与密码均为sentinel。登录 Dashboard 成功后,多次访问接口"/getToken",可以在 Dashboard 看到相应数据,这里不展示了。接下来可以设置接口的限流功能,在 “+流控” 按钮点击打开设置界面,设置阈值类型为 qps,单机阈值为5。
浏览器重复请求 http://10.10.15.5:5556/admin-web/api/user/getToken 如果超过阀值就会出现如下界面信息:
Blocked by Sentinel (flow limiting)
此时,就看到Sentinel 限流起作用了,可以加上 spring.cloud.sentinel.scg.fallback 为sentinel 限流后的响应配置,亦可自定义限流异常信息:

@GetMapping(value = "/getToken")
@SentinelResource(value = "getToken", blockHandler = "handleSentinelException", blockHandlerClass = {MySentinelException.class}))
public Response<Object> getToken(Authentication authentication){
    //Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    authentication.getCredentials();
    OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails)authentication.getDetails();
    String token = details.getTokenValue();
    return Response.ok(200, 0, "get token success", token);
}

public class MySentinelException {
    public static Response<Object> handleSentinelException(BlockException e) {
        Map<String,Object> map=new HashMap<>();
        logger.info("Oops: " + ex.getClass().getCanonicalName());
        return Response.ok(200, -8, "通过注解 @SentinelResource 配置限流埋点并自定义限流后的处理逻辑", null);
    }
}

这里讲下注解 @SentinelResource 包含以下属性:

value:资源名称,必需项;
entryType:入口类型,可选项(默认为 EntryType.OUT);
blockHandler:blockHandlerClass中对应的异常处理方法名,参数类型和返回值必须和原方法一致;
blockHandlerClass:自定义限流逻辑处理类

Sentinel 限流逻辑处理完毕了,但每次服务重启后,之前配置的限流规则就会被清空。因为是内存形式的规则对象。所以下面就讲下用 Sentinel 的一个特性 ReadableDataSource 获取文件、数据库或者配置中心设置限流规则,目前支持 Apollo、Nacos、ZK 配置来管理。
首先回忆一下,一条限流规则主要由下面几个因素组成:

resource:资源名,即限流规则的作用对象,即为注解 @SentinelResource 的value;
count:限流阈值;grade:限流阈值类型(QPS 或并发线程数);
limitApp:流控针对的调用来源,若为 default 则不区分调用来源;
strategy:基于调用关系的限流策略;
controlBehavior:流量控制效果(直接拒绝、排队等待、匀速器模式)

理解了意思,接下来通过文件来配置

#通过文件读取限流规则
spring.cloud.sentinel.datasource.ds1.file.file=classpath:flowrule.json
spring.cloud.sentinel.datasource.ds1.file.data-type=json
spring.cloud.sentinel.datasource.ds1.file.rule-type=flow

在resources新建一个文件,比如 flowrule.json 添加限流规则:

[
  {
    "resource": "getToken",
    "count": 1,
    "controlBehavior": 0,
    "grade": 1,
    "limitApp": "default",
    "strategy": 0
  },
  {
    "resource": "resource",
    "count": 1,
    "controlBehavior": 0,
    "grade": 1,
    "limitApp": "default",
    "strategy": 0
  }
]

重新启动项目,出现如下日志说明成功:

DataSource ds1-sentinel-file-datasource start to loadConfig
DataSource ds1-sentinel-file-datasource load 2 FlowRule

如果采用 Nacos 作为配置获取限流规则,可在文件中加如下配置:

spring:
  application:
    name: order-service
  cloud:
    nacos:
      config:
        server-addr: 10.10.15.5:8848
      discovery:
        server-addr: 10.10.15.5:8848
    sentinel:
      eager: true
      transport:
        dashboard: 10.10.15.5:8080
      datasource:
        ds1:
          nacos:
            server-addr: 10.10.15.5:8848
            dataId: ${spring.application.name}-flow-rules
            data-type: json
            rule-type: flow

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不能吃辣的JAVA程序猿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值