概述
什么是限流
对某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢或宕机
为什么要限流
因为互联网系统通常都要面对大并发大流量的请求,在突发情况下(最常见的场景就是秒杀、抢购),瞬时大流量会直接将系统打垮,无法对外提供服务。那为了防止出现这种情况最常见的解决方案之一就是限流
限流维度&分类
维度
两种维度:
时间
:基于某段时间范围或者某个时间点,也就是我们常说的“时间窗口”,比如对每分钟、每秒钟的时间窗口做限定资源
:基于可用资源的限制,比如设定最大访问次数,或最高可用连接数
总结:限流就是在某个时间窗口对资源访问做限制
,但在真正的场景里,我们不止设置一种限流规则,而是会设置多个限流规则共同作用
分类
限流的分类如下所示:
- 合法性验证限流:比如验证码、IP 黑名单等,这些手段可以有效的防止恶意攻击和爬虫采集;
- 容器限流:比如 Tomcat、Nginx 等限流手段,
- 其中 Tomcat 可以设置最大线程数(maxThreads),当并发超过最大线程数会排队等待执行;
- 而 Nginx 提供了两种限流手段:一是控制速率,二是控制并发连接数;
- 服务端限流:比如我们在服务器端通过限流算法实现限流
限流指标(TPS、HPS、QPS、RPS)
TPS(Transactions Per Second) 是指每秒事务数:一个事务是指事务内第一个请求发送到接收到最后一个请求的响应的过程,以此来计算使用的时间和完成的事务个数。
- 如果按照TPS来进行限流,时间粒度可能会很大大,很难准确评估系统的响应性能。
HPS(Hits Per Second) 指每秒点击次数(每秒钟服务端收到客户端的请求数量) 。是指在一秒钟的时间内用户对Web页面的链接、提交按钮等点击总和
。 它一般和TPS成正比关系,是B/S系统中非常重要的性能指标之一。
- 如果一个请求完成一笔事务,那TPS和HPS是等同的。
- 但在分布式场景下,完成一笔事务可能需要多次请求,所以TPS和HPS指标不能等同看待。
QPS(Queries Per Second) 是指每秒查询率。是一台服务器每秒能够响应的查询次数
(数据库中的每秒执行查询sql的次数),显然这个不够全面,不能描述增删改,所以不建议用QPS来作为系统性能指标
。
- 如果后台只有一台服务器,那 HPS 和 QPS 是等同的。
- 但是在分布式场景下,每个请求需要多个服务器配合完成响应。
RPS(Requests Per Second) 是一个衡量系统或应用程序在一秒钟内能够处理的请求数量
的性能指标。
容器限流
有两种限流方式:
- tomcat限流:配置最大线程数
- nginx限流:一是控制速率,二是控制并发连接数
tomcat限流
spring项目直接可以在配置文件中设置
- 使用
application.properties:src/main/resources/application.properties
文件中添加以下配置server.tomcat.max-threads=200 # 设置最大线程数为 200
- 使用 application.yml:在
src/main/resources/application.yml
中添加:server:tomcat:max-threads: 200 # 设置最大线程数为 200
nginx限流
控制速率:
- 使用
limit_req_zone
用来限制单位时间内的请求数,即速率限制,- 示例配置如下:
- 配置表示,限制每个 IP 访问的速度为 2r/s,因为 Nginx 的限流统计是基于毫秒的,我们设置的速度是 2r/s,转换一下就是 500ms 内单个 IP 只允许通过 1 个请求,从 501ms 开始才允许通过第 2 个请求。
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s; server { location / { limit_req zone=mylimit;} }
burst 关键字
:真实情况下我们应该控制一个 IP 单位总时间内的总访问次数,而不是像上面那么精确但毫秒,我们可以使用 burst 关键字开启此设置,它表示在限速时,允许的额外请求数量。- 示例配置如下:
- burst=4 表示每个 IP 最多允许4个突发请求
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s; server { location / { limit_req zone=mylimit burst=4;} }
控制并发数
- 利用 limit_conn_zone 和 limit_conn 两个指令即可控制并发数
- 示例配置如下:
- 其中 limit_conn perip 10 表示限制单个 IP 同时最多能持有 10 个连接;
- 只有当 request header 被后端处理后,这个连接才进行计数。
- limit_conn perserver 100 表示 server 同时能处理并发连接的总数为 100 个。
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;
}
服务端限流算法
常见的限流算法有三种:
- 计数器限流:主要用来限制总并发数,比如数据库连接池大小、线程池大小、接口访问并发数等都是使用计数器算法
- 漏桶算法:
- 思路:漏桶算法思路很简单,我们把水比作是请求,漏桶比作是系统处理能力极限,水先进入到漏桶里,漏桶里的水按一定速率流出,当流出的速率小于流入的速率时,由于漏桶容量有限,后续进入的水直接溢出(拒绝请求),以此实现限流
- 令牌桶算法:可以理解成医院的挂号看病,只有拿到号以后才可以进行诊病
- 系统会维护一个令牌(token)桶,以一个恒定的速度往桶里放入令牌(token),这时如果有请求进来想要被处理,则需要先从桶里获取一个令牌(token),当桶里没有令牌(token)可取时,则该请求将被拒绝服务。
- 令牌桶算法通过控制桶的容量、发放令牌的速率,来达到对请求的限制。
- 和漏桶算法区别
- 漏桶的天然特性决定了它不会发生突发流量,就算每秒1000个请求到来,那么它对后台服务输出的访问速率永远恒定。
- 而令牌桶则不同,其特性可以“预存”一定量的令牌,因此在应对突发流量的时候可以在短时间消耗所有令牌,其突发流量处理效率会比漏桶高,导向后台系统的压力也会相应增多。
- 滑动窗口:
计数器限流(固定窗口算法)
计数器限流算法也是比较常用的,主要用来限制总并发数,比如数据库连接池大小、线程池大小、程序访问并发数等都是使用计数器算法。也是最简单粗暴的算法。
常用的三个方法如下:
- 采用AtomicInteger:使用AomicInteger来进行统计当前正在并发执行的次数,如果超过域值就简单粗暴的直接响应给用户,说明系统繁忙,请稍后再试或其它跟业务相关的信息。
- 弊端:使用 AomicInteger 简单粗暴超过域值就拒绝请求,可能只是瞬时的请求量高,也会拒绝请求。
- 采用令牌Semaphore:使用Semaphore信号量来控制并发执行的次数,如果超过域值信号量,则进入阻塞队列中排队等待获取信号量进行执行。如果阻塞队列中排队的请求过多超出系统处理能力,则可以在拒绝请求。
- 相对Atomic优点:如果是瞬时的高并发,可以使请求在阻塞队列中排队,而不是马上拒绝请求,从而达到一个流量削峰的目的。
- 采用ThreadPoolExecutor java线程池:固定线程池大小,超出固定先线程池和最大的线程数,拒绝线程请求
滑动窗口
滑动窗口算法是对固定窗口算法的改进
滑动窗口计数器(Sliding Window)算法限流:解决固定窗口临界值的问题。它将单位时间周期分为n个小周期,分别记录每个小周期内接口的访问次数,并且根据时间滑动删除过期的小周期。
如下图
- 每 500ms 滑动一次窗口,可以发现窗口滑动的间隔越短,时间窗口的临界突变问题发生的概率也就越小,
- 不过只要有时间窗口的存在,还是有可能发生时间窗口的临界突变问题。
漏桶算法
漏桶的桶有大小,就如队列的容量,当请求堆积超过指定容量时,会触发拒绝策略。
漏桶模式中的消费处理总是能以恒定的速度进行,可以很好的保护自身系统不被突如其来的流量冲垮
令牌桶算法
最为常用的 Google 的 Java 开发工具包 Guava 中的限流工具类 RateLimiter 就是令牌桶的一个实现。令牌桶的实现思路类似于生产者和消费之间的关系。
系统服务作为生产者,按照指定频率向桶(容器)中添加令牌,如 QPS 为 2,每 500ms 向桶中添加一个令牌,如果桶中令牌数量达到阈值,则不再添加。
- 1s / 阈值(QPS) = 令牌添加时间间隔。
请求执行作为消费者,每个请求都需要去桶中拿取一个令牌,取到令牌则继续执行;如果桶中无令牌可取,就触发拒绝策略,可以是超时等待,也可以是直接拒绝本次请求,由此达到限流目的
实现方式
算法
应用级限流方式只是单应用内的请求限流,不能进行全局限流。
- 限流总资源数
- 限流总并发/连接/请求数
- 限流某个接口的总并发/请求数
- 限流某个接口的时间窗请求数
- 平滑限流某个接口的请求数
- Guava RateLimiter
我们需要分布式限流和接入层限流来进行全局限流。
- redis+lua实现中的lua脚本
- 使用Nginx+Lua实现的Lua脚本
- 使用 OpenResty 开源的限流方案
- 限流框架,比如Sentinel实现降级限流熔断
单点限流
应用级限流方式只是单应用内的请求限流,不能进行全局限流。
- 限流总资源数
- 限流总并发/连接/请求数
- 限流某个接口的总并发/请求数
- 限流某个接口的时间窗请求数
- 平滑限流某个接口的请求数
- Guava RateLimiter
Guava实现限流(令牌桶Token Bucket)
Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法实现流量限制
依赖包
- 版本选择:请根据你的项目需求选择合适的Guava版本。最新版本可以在Guava的Maven中央仓库页面上找到。
- 兼容性:确保所选版本与项目中使用的Java版本兼容
<dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>31.1-jre</version> <!-- 请根据需要选择合适的版本 -->
</dependency>
RateLimiter 常用方法
acquire:获取令牌
- acquire() :获取一个令牌, 该方法会阻塞直到获取到这一个令牌, 返回值为获取到这个令牌花费的时间
- acquire(int permits) :获取指定数量的令牌, 该方法也会阻塞, 返回值为获取到这 N 个令牌花费的时间
tryAcquire:当前能否获取到令牌
- tryAcquire() :判断当前时候能获取到令牌, 如果不能获取立即返回 false
- tryAcquire(int permits) :判断当前时候指定数量的令牌, 如果不能获取立即返回 false
- tryAcquire(long timeout, TimeUnit unit) :判断能否在指定时间内获取到令牌, 如果不能获取立即返回 false
- tryAcquire(int permits, long timeout, TimeUnit unit) :判断能否在指定时间内获取到指定数量的令牌, 如果不能获取立即返回 false
具体使用
示例1:直接使用RateLimiter
创建限流配置
import com.google.common.util.concurrent.RateLimiter;
import org.springframework.stereotype.Component;@Component
public class RateLimitService {// 每秒允许5个请求private final RateLimiter rateLimiter = RateLimiter.create(5.0);public boolean tryAcquire() {return rateLimiter.tryAcquire();}
}
给接口加上限流逻辑
在Controller中,使用RateLimitService来限制接口的访问。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;@RestController
public class MyController {@Autowiredprivate RateLimitService rateLimitService;@GetMapping("/api/resource")public ResponseEntity<String> getResource() {if (!rateLimitService.tryAcquire()) {return ResponseEntity.status(429).body("Too Many Requests");}// 处理请求return ResponseEntity.ok("Resource accessed successfully");}
}
我们在实际开发中并不能直接这样用
- 每个接口都需要手动给其加上tryAcquire(),业务代码和限流代码混在一起,而且明显违背了DRY原则,代码冗余,重复劳动。
因此需要使用下面的方式去获取:自定义注解 + AOP
示例2:自定义注解(RateLimiter) + AOP
上述方式使用RateLimiter的方式不够优雅,尽管我们可以把RateLimiter的逻辑包在service里面,controller直接调用即可,但是如果我们换成:自定义注解+切面 的方式实现的话,会优雅的多
自定义注解:如何自定义注解?
import java.lang.annotation.*;/*** 自定义注解可以不包含属性,成为一个标识注解*/
@Inherited
@Documented
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimitAspect {/*** 资源的key,唯一* 作用:不同的接口,不同的流量控制*/String key() default "";/*** 最多的访问限制次数*/double permitsPerSecond () ;/*** 获取令牌最大等待时间*/long timeout();/*** 获取令牌最大等待时间,单位(例:分钟/秒/毫秒) 默认:毫秒*/TimeUnit timeunit() default TimeUnit.MILLISECONDS;/*** 得不到令牌的提示语*/String msg() default "系统繁忙,请稍后再试.";
}
自定义切面类
import com.google.common.util.concurrent.RateLimiter;
import net.sf.json.JSONObject;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang