在高并发的应用中,限流是一个绕不开的话题。限流可以保障我们的 API 服务对所有用户的可用性,也可以防止网络攻击。
令牌桶
是一种限速算法,与之相对的是漏桶
。常用的平滑的限流算法有两种:漏桶算法和令牌桶算法:
漏桶(Leaky Bucket)算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率。这里有两个变量,一个是桶的大小,支持流量突发增多时可以存多少的水(burst),另一个是水桶漏洞的大小(rate)。因为漏桶的漏出速率是固定的参数,所以,即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使流突发(burst)到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率。
令牌桶算法(Token Bucket)和 Leaky Bucket 效果一样但方向相反的算法,更加容易理解。随着时间流逝,系统会按恒定 1/QPS 时间间隔(如果 QPS=100,则间隔是 10ms)往桶里加入 Token(想象和漏洞漏水相反,有个水龙头在不断的加水),如果桶已经满了就不再加了。新请求来临时,会各自拿走一个 Token,如果没有 Token 可拿了就阻塞或者拒绝服务。令牌桶的另外一个好处是可以方便的改变速度。一旦需要提高速率,则按需提高放入桶中的令牌的速率。一般会定时(比如 100 毫秒)往桶中增加一定数量的令牌,有些变种算法则实时的计算应该增加的令牌的数量。
当进行任务的操作时,消耗一定的令牌
,后台以一定的速率生产
令牌。在没有令牌的情况下,就阻塞任务,或者拒绝服务。令牌的生产速率,代表了大部分情况下的平均流速。
桶
的作用就是存储令牌,消耗的令牌都是从桶
中获取。桶的作用是用来限制流速的峰值,当桶中有额外令牌的时候,实际的流速就会高于限定的令牌生产速率。假设令牌生产速率为v
,桶大小为b
,处理时间为t
,则实际流量速度为V=v+b/t
。
为了保证功能的完整,后台必须保证令牌生产
,而且是持续服务,不能中断。同时,为了桶
功能的正确作用,当桶满了以后,后续生产的令牌会溢出
,不会存储到桶内部。
1.消费
public static void main(String[] args) {
Bandwidth limit = Bandwidth.simple(10, Duration.ofSeconds(1));
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
if(bucket.tryConsume(1)){
System.out.println("do something");
}else{
System.out.println("do nothing");
}
}
Bandwidth
:带宽,也就是每秒能够通过的流量,自动维护令牌生产。
Bucket
:桶,不论状态,或是令牌的消费,bucket
是我们操作的入口。
tryConsume
:尝试消费n
个令牌,返回布尔值,表示能够消费或者不能够消费,给我们判断依据。
为了简单理解可以尝试一下如下代码。
public static void main(String[] args) {
Bandwidth limit = Bandwidth.simple(1, Duration.ofSeconds(1));
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
while(true){
if(bucket.tryConsume(1)){
System.out.println(new SimpleDateFormat("HH:mm:ss").format(new Date()));
}else{
try{
System.out.println("waiting...");
Thread.sleep(200);
}catch (Exception e){
}
}
}
}
2.阻塞
public static void main(String[] args) throws InterruptedException {
Bandwidth limit = Bandwidth.simple(1, Duration.ofSeconds(1));
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
while(true){
bucket.asScheduler().consume(1);
System.out.println(new SimpleDateFormat("HH:mm:ss").format(new Date()));
}
}
asScheduler
会进行阻塞,直到获取令牌才进行后续语句的执行。
3.探针
public static void main(String[] args) throws InterruptedException {
Bandwidth limit = Bandwidth.simple(5, Duration.ofSeconds(1));
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
while(true){
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if(probe.isConsumed()){
System.out.println(new SimpleDateFormat("HH:mm:ss").format(new Date())
+"\t剩余令牌: "+ probe.getRemainingTokens());
}else{
System.out.println("waiting...");
Thread.sleep(200);
}
}
}
tryConsumeAndReturnRemaining
:获取探针
isConsumed
:判断是否能消耗
getRemainingTokens
:查询剩余令牌数量
4.桶控制
public static void main(String[] args) throws InterruptedException {
long bucketSize = 9;
Refill filler = Refill.greedy(2, Duration.ofSeconds(1));
Bandwidth limit = Bandwidth.classic(bucketSize, filler);
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
while(true){
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if(probe.isConsumed()){
System.out.println(new SimpleDateFormat("HH:mm:ss").format(new Date())
+"\t剩余令牌: "+ probe.getRemainingTokens());
}else{
Thread.sleep(2000);
}
}
}
Refiller
:填充速度
bucketSize
:桶容量
classic
:用Refiller
创建Bandwidth
可以观察到初始容量有10个,休眠两秒,每次消耗刚好四个,验证Refill
功能。
1.初始化令牌个数
public static void main(String[] args) throws InterruptedException {
long bucketSize = 9;
Refill filler = Refill.greedy(2, Duration.ofSeconds(1));
Bandwidth limit = Bandwidth.classic(bucketSize, filler).withInitialTokens(5);
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
while(true){
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if(probe.isConsumed()){
System.out.println(new SimpleDateFormat("HH:mm:ss").format(new Date())
+"\t剩余令牌: "+ probe.getRemainingTokens());
}else{
Thread.sleep(2000);
}
}
}
withInitialTokens
:桶初始化令牌数。一般情况下,桶容量初始化时默认是满的,可以设置初始化时桶内的令牌数。
2.杜绝贪婪
public static void main(String[] args) throws InterruptedException {
long bucketSize = 10;
Refill filler = Refill.greedy(10, Duration.ofSeconds(1));
Bandwidth limit = Bandwidth.classic(bucketSize, filler).withInitialTokens(0);
Bucket bucket = Bucket4j.builder()
.addLimit(limit)
.build();
while(true){
if(bucket.tryConsume(1)){
System.out.println(new SimpleDateFormat("HH:mm:ss:SSS").format(new Date()));
}
}
}
运行一下,就能够大致了解Refill的贪婪了。它总是那么急,我们的10/s,施行下去结果变成了1/ms。如果我们必须是瞬间生成10个,这就违背我们的意愿了。
Refill filler = Refill.intervally(10, Duration.ofSeconds(1));
这样,我们的意愿就不会因为贪婪而有所扭曲了。
3.手动添加
bucket.addTokens(500);
不等创建,自己手动添加,适用于回滚。
public static void main(String[] args) throws InterruptedException {
long bucketSize = 10;
Refill filler = Refill.intervally(10, Duration.ofSeconds(1));
Bandwidth limit = Bandwidth.classic(bucketSize, filler).withInitialTokens(0);
Bucket bucket = Bucket4j.builder()
.addLimit(limit)
.build();
bucket.addTokens(500);
while(true){
if(bucket.tryConsume(1)){
try{
throw new Exception("create a Exception");
} catch (Exception e) {
bucket.addTokens(1);
}
}
}
}
4.时钟
从上面的贪婪
,可以发现,Bucket4j
的时钟是以毫秒进行衡量的,如果想要微秒
之类的更细粒度操控,需要自己设置
// 毫秒
Bucket4j.builder().withMillisecondPrecision().build;
// 微秒
Bucket4j.builder().withNanosecondPrecision().build()
withMillisecondPrecision
:毫秒
withNanosecondPrecision
:微秒
担心系统时钟不准,可以采用自己的时钟。
Bucket4j.builder().withCustomTimePrecision(new MyClock()).build()
withCustomTimePrecision
:指定时钟
5.配置热替换
BucketConfiguration config = Bucket4j.configurationBuilder()
.addLimit(Bandwidth.simple(1, Duration.ofSeconds(2)))
.build();
bucket.replaceConfiguration(config);
时机到了,就可以更换配置。业务繁忙的时候令牌带宽可以适当的放宽,没有请求的时候也可以适当的减缓令牌生成。毕竟生成令牌也会占用资源不是。
6.波动令牌
Bucket bucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(1000, Duration.ofMinutes(1)))
.addLimit(Bandwidth.simple(50, Duration.ofSeconds(1)))
.build();
当添加多个BandWidth,都会生效。考虑总体的满足,在情况允许的情况下会尽量满足全部的要求。如例子,如果满足第二个条件,第一个条件必定会被打破,但是也不是一直不会满足第二个条件。因此,在整体上不超过带宽,但是允许不全部占用的情况。令牌的生成也不必时刻都是火力全开,为了满足全部的限制,有时候不得不消极怠工。这种波动,打破了平均生产的境况,从而允许动态速率的生成令牌。
在 Gateway 上实现限流是个不错的选择,只需要编写一个过滤器就可以了。我们这里采用令牌桶算法,Google Guava 的RateLimiter
、Bucket4j、RateLimitJ 都是一些基于此算法的实现,只是他们支持的 back-ends(JCache、Hazelcast、Redis 等)不同罢了,你可以根据自己的技术栈选择相应的实现。
我们这里使用 Bucket4j,引入依赖坐标:
compile group: 'com.github.vladimir-bukhtoyarov', name: 'bucket4j-core', version: '4.4.1'//限流,令牌桶
添加一个过滤器:
package com.zoo.gateway.filter;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.Refill;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @Author: xf
* @Date: 2019/8/14 15:38
* @Version 1.0
*/
@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RateLimitByIpGatewayFilter implements GatewayFilter, Ordered {
int capacity;//桶的最大容量,即能装载 Token 的最大数量
int refillTokens;//每次 Token 补充量
Duration refillDuration;//补充 Token 的时间间隔
private static final Map CACHE = new ConcurrentHashMap<>();
private static final boolean enableRateLimit = true;//开关
private Bucket createNewBucket() {
Refill refill = Refill.of(refillTokens, refillDuration);
Bandwidth limit = Bandwidth.classic(capacity, refill);
return Bucket4j.builder().addLimit(limit).build();
}
@Override
public int getOrder() {
return -2;
}
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (!enableRateLimit){
return chain.filter(exchange);
}
String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
Bucket bucket = CACHE.computeIfAbsent(ip, k -> createNewBucket());
if (bucket.tryConsume(1)) {
return chain.filter(exchange);
} else {
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
return exchange.getResponse().setComplete();
}
}
}
在这个实现中,我们使用了 IP 来进行限制,当达到最大流量就返回429 Too Many Requests (太多请求)错误。这里我们简单使用一个 Map 来存储 bucket,所以也决定了它只能单点使用,如果是分布式的话,可以采用 Hazelcast 或 Redis 等解决方案。
然后我们在Route添加这个过滤器,这里指定了 bucket 的容量为 10 且每一秒会补充 1 个 Token。
@Bean
public RouteLocator customerRouteLocator(RouteLocatorBuilder builder) {
// @formatter:off
return builder.routes()
.route(r -> r.path("/api/test/**")
.filters(f -> f.stripPrefix(2)
.filter(new RateLimitByIpGatewayFilter(10, 1, Duration.ofSeconds(1))))
.uri("lb://zebra-test")
.order(0)
.id("zebra-test")
)
.build();
// @formatter:on
}
启动服务并多次快速刷新改接口,就会看到 Tokens 的数量在不断减小,等一会又会增加上来。
(未完待续)