上文中介绍了单实例下如何在业务接口层做限流,本文是SpringBoot第22讲,主要介绍分布式场景下限流的方案,以及什么样的分布式场景下需要在业务层加限流而不是接入层; 并且结合 开源的ratelimiter-spring-boot-starter 为例,作者是kailing, 学习思路+代码封装+starter封装。
上文我们提到了分布式限流的思路:
我们需要分布式限流和接入层限流来进行全局限流。
redis+lua是代码层实现较为常见的方案,网上有很多的封装, 我这里找一个给你分享下。以 gitee开源的ratelimiter-spring-boot-starter为例,作者是kailing, 值得初学者学习思路+代码封装+starter封装:
基于 redis 的偏业务应用的分布式限流组件,使得项目拥有分布式限流能力变得很简单。限流的场景有很多,常说的限流一般指网关限流,控制好洪峰流量,以免打垮后方应用。这里突出偏业务应用的分布式限流
的原因,是因为区别于网关限流,业务侧限流可以轻松根据业务性质做到细粒度的流量控制。比如如下场景,
案例一:
案例二:
让我们看下,作者kailing是如何封装实现 ratelimiter-spring-boot-starter的。
使用gradle,引入如下包
ext {
redisson_Version = '3.15.1'
}
dependencies {
compile "org.redisson:redisson:${redisson_Version}"
compile 'org.springframework.boot:spring-boot-starter-aop'
compileOnly 'org.springframework.boot:spring-boot-starter-web'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springdoc:springdoc-openapi-ui:1.5.2'
}
作者考虑了时间表达式,限流后的自定义回退后的拒绝逻辑, 用户自定义Key(PS:这里其实可以加一些默认的Key生成策略,比如按照方法策略, 按照方法&IP 策略, 按照自定义策略等,默认为按照方法)
package com.taptap.ratelimiter.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author kl (http://kailing.pub)
* @since 2021/3/16
*/
@Target(value = {ElementType.METHOD})
@Retention(value = RetentionPolicy.RUNTIME)
public @interface RateLimit {
/**
* 时间窗口流量数量
* @return rate
*/
long rate();
/**
* 时间窗口流量数量表达式
* @return rateExpression
*/
String rateExpression() default "";
/**
* 时间窗口,最小单位秒,如 2s,2h , 2d
* @return rateInterval
*/
String rateInterval();
/**
* 获取key
* @return keys
*/
String [] keys() default {};
/**
* 限流后的自定义回退后的拒绝逻辑
* @return fallback
*/
String fallbackFunction() default "";
/**
* 自定义业务 key 的 Function
* @return key
*/
String customKeyFunction() default "";
}
around环绕方式, 通过定义 RateLimiterService 获取方法注解的信息,存放在为 RateLimiterInfo,如果还定义了回调方法,被限流后还会执行回调方法,回调方法也在RateLimiterService中。
package com.taptap.ratelimiter.core;
import com.taptap.ratelimiter.annotation.RateLimit;
import com.taptap.ratelimiter.exception.RateLimitException;
import com.taptap.ratelimiter.model.LuaScript;
import com.taptap.ratelimiter.model.RateLimiterInfo;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.redisson.api.RScript;
import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
/**
* Created by kl on 2017/12/29.
* Content : 切面拦截处理器
*/
@Aspect
@Component
@Order(0)
public class RateLimitAspectHandler {
private static final Logger logger = LoggerFactory.getLogger(RateLimitAspectHandler.class);
private final RateLimiterService rateLimiterService;
private final RuleProvider ruleProvider;
public RateLimitAspectHandler(RateLimiterService lockInfoProvider, RuleProvider ruleProvider) {
this.rateLimiterService = lockInfoProvider;
this.ruleProvider = ruleProvider;
}
@Around(value = "@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
Rule rule = ruleProvider.getRateLimiterRule(joinPoint, rateLimit);
Result result = rateLimiterService.isAllowed(rule);
boolean allowed = result.isAllow();
if (!allowed) {
logger.info("Trigger current limiting,key:{}", rule.getKey());
if (StringUtils.hasLength(rule.getFallbackFunction())) {
return ruleProvider.executeFunction(rule.getFallbackFunction(), joinPoint);
}
long extra = result.getExtra();
throw new RateLimitException("Too Many Requests", extra, rule.getMode());
}
return joinPoint.proceed();
}
}
Rule getRateLimiterRule(JoinPoint joinPoint, RateLimit rateLimit) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String businessKeyName = this.getKeyName(joinPoint, rateLimit);
String rateLimitKey = this.getKey(signature) + businessKeyName;
if (StringUtils.hasLength(rateLimit.customKeyFunction())) {
try {
rateLimitKey = this.getKey(signature) + this.executeFunction(rateLimit.customKeyFunction(), joinPoint).toString();
} catch (Throwable throwable) {
logger.info("Gets the custom Key exception and degrades it to the default Key:{}", rateLimit, throwable);
}
}
int rate = this.getRate(rateLimit);
int bucketCapacity = this.getBucketCapacity(rateLimit);
long rateInterval = DurationStyle.detectAndParse(rateLimit.rateInterval()).getSeconds();
Rule rule = new Rule(rateLimitKey, rate, rateLimit.mode());
rule.setRateInterval(Long.valueOf(rateInterval).intValue());
rule.setFallbackFunction(rateLimit.fallbackFunction());
rule.setRequestedTokens(rateLimit.requestedTokens());
rule.setBucketCapacity(bucketCapacity);
return rule;
}
这里LuaScript加载定义的lua脚本
package com.taptap.ratelimiter.model;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StreamUtils;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
/**
* @author kl (http://kailing.pub)
* @since 2021/3/18
*/
public final class LuaScript {
private LuaScript() {
}
private static final Logger log = LoggerFactory.getLogger(LuaScript.class);
private static final String timeWindowRateLimiterScript;
private static final String tokenBucketRateLimiterScript;
static {
timeWindowRateLimiterScript = getRateLimiterScript("META-INF/timeWindow-rateLimit.lua");
tokenBucketRateLimiterScript = getRateLimiterScript("META-INF/tokenBucket-rateLimit.lua");
}
private static String getRateLimiterScript(String scriptFileName) {
InputStream inputStream = Thread.currentThread().getContextClassLoader()
.getResourceAsStream(scriptFileName);
try {
return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
} catch (IOException e) {
log.error("tokenBucket-rateLimit.lua Initialization failure", e);
throw new RuntimeException(e);
}
}
public static String getTimeWindowRateLimiterScript() {
return timeWindowRateLimiterScript;
}
public static String getTokenBucketRateLimiterScript() {
return tokenBucketRateLimiterScript;
}
}
lua脚本放在META-INF/timeWindow-rateLimit.lua, 如下
--
-- Created by IntelliJ IDEA.
-- User: kl
-- Date: 2021/3/18
-- Time: 11:17 上午
-- To change this template use File | Settings | File Templates.
local rateLimitKey = KEYS[1];
local rate = tonumber(ARGV[1]);
local rateInterval = tonumber(ARGV[2]);
local allowed = 1;
local ttlResult = 0;
local currValue = redis.call('incr', rateLimitKey);
if (currValue == 1) then
redis.call('expire', rateLimitKey, rateInterval);
allowed = 1;
else
if (currValue > rate) then
allowed = 0;
ttlResult = redis.call('ttl', rateLimitKey);
end
end
return { allowed, ttlResult }
tokenBucket-rateLimit.lua
-- https://gist.github.com/ptarjan/e38f45f2dfe601419ca3af937fff574d#file-1-check_request_rate_limiter-rb-L11-L34
redis.replicate_commands()
local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
local now = redis.call('TIME')[1]
local fill_time = capacity/rate
local ttl = math.floor(fill_time*2)
local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
last_tokens = capacity
end
local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
last_refreshed = 0
end
local delta = math.max(0, now-last_refreshed)
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
local allowed_num = 0
if allowed then
new_tokens = filled_tokens - requested
allowed_num = 1
end
if ttl > 0 then
redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)
end
return { allowed_num, new_tokens }
RateLimiterAutoConfiguration + RateLimiterProperties + spring.factories
package com.taptap.ratelimiter.configuration;
import com.taptap.ratelimiter.core.BizKeyProvider;
import com.taptap.ratelimiter.core.RateLimitAspectHandler;
import com.taptap.ratelimiter.core.RateLimiterService;
import com.taptap.ratelimiter.web.RateLimitExceptionHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.codec.JsonJacksonCodec;
import org.redisson.config.Config;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
/**
* @author kl (http://kailing.pub)
* @since 2021/3/16
*/
@Configuration
@ConditionalOnProperty(prefix = RateLimiterProperties.PREFIX, name = "enabled", havingValue = "true")
@AutoConfigureAfter(RedisAutoConfiguration.class)
@EnableConfigurationProperties(RateLimiterProperties.class)
@Import({RateLimitAspectHandler.class, RateLimitExceptionHandler.class})
public class RateLimiterAutoConfiguration {
private final RateLimiterProperties limiterProperties;
public RateLimiterAutoConfiguration(RateLimiterProperties limiterProperties) {
this.limiterProperties = limiterProperties;
}
@Bean(destroyMethod = "shutdown")
@ConditionalOnMissingBean
RedissonClient redisson() {
Config config = new Config();
if (limiterProperties.getRedisClusterServer() != null) {
config.useClusterServers().setPassword(limiterProperties.getRedisPassword())
.addNodeAddress(limiterProperties.getRedisClusterServer().getNodeAddresses());
} else {
config.useSingleServer().setAddress(limiterProperties.getRedisAddress())
.setDatabase(limiterProperties.getRedisDatabase())
.setPassword(limiterProperties.getRedisPassword());
}
config.setCodec(new JsonJacksonCodec());
config.setEventLoopGroup(new NioEventLoopGroup());
return Redisson.create(config);
}
@Bean
public RateLimiterService rateLimiterInfoProvider() {
return new RateLimiterService();
}
@Bean
public BizKeyProvider bizKeyProvider() {
return new BizKeyProvider();
}
}
来看下作者kailing是如何提供的ratelimiter-spring-boot-starter使用文档。
maven
<dependency>
<groupId>com.github.taptapgroupId>
<artifactId>ratelimiter-spring-boot-starterartifactId>
<version>1.2version>
dependency>
gradle
implementation 'com.github.taptap:ratelimiter-spring-boot-starter:1.2'
spring.ratelimiter.enabled = true
spring.ratelimiter.redis-address = redis://127.0.0.1:6379
spring.ratelimiter.redis-password = xxx
启用 ratelimiter 的配置必须加,默认不会加载。redis 相关的连接是非必须的,如果你的项目里已经使用了 Redisson
,则不用配置限流框架的 redis 连接
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/get")
@RateLimit(rate = 5, rateInterval = "10s")
public String get(String name) {
return "hello";
}
}
@RateLimit 注解可以添加到任意被 spring 管理的 bean 上,不局限于 controller,service 、repository 也可以。在最基础限流功能使用上,以上三个步骤就已经完成了。@RateLimit 有两个最基础的参数,rateInterval 设置了时间窗口,rate 设置了时间窗口内允许通过的请求数量
限流的粒度是通过限流的 key 来做的,在最基础的设置下,限流的 key 默认是通过方法名称拼出来的,规则如下:
key = RateLimiter_ + 类名 + 方法名
除了默认的 key 策略,ratelimiter-spring-boot-starter 充分考虑了业务限流时的复杂性,提供了多种方式。结合业务特征,达到更细粒度的限流控制。
默认触发限流后 程序会返回一个 http 状态码为 429 的响应,响应值如下:
{
"code":429,
"msg":"Too Many Requests"
}
同时,响应的 header 里会携带一个 Retry-After 的时间值,单位 s,用来告诉调用方多久后可以重试。当然这一切都是可以自定义的,进阶用法可以继续往下看
自定义限流 key 有三种方式,当自定义限流的 key 生效时,限流的 key 就变成了(默认的 key + 自定义的 key)。下面依次给出示例
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/get")
@RateLimit(rate = 5, rateInterval = "10s")
public String get(@RateLimitKey String name) {
return "get";
}
}
@RateLimitKey 注解可以放在方法的入参上,要求入参是基础数据类型,上面的例子,如果 name = kl。那么最终限流的 key 如下:
key = RateLimiter_com.taptap.ratelimiter.web.TestController.get-kl
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/get")
@RateLimit(rate = 5, rateInterval = "10s", keys = {"#name"})
public String get(String name) {
return "get";
}
@GetMapping("/hello")
@RateLimit(rate = 5, rateInterval = "10s", keys = {"#user.name", "user.id"})
public String hello(User user) {
return "hello";
}
}
keys 这个参数比 @RateLimitKey 注解更智能,基本可以包含 @RateLimitKey 的能力,只是简单场景下,使用起来没有 @RateLimitKey 那么便捷。keys 的语法来自 spring 的 Spel
,可以获取对象入参里的属性,支持获取多个,最后会拼接起来。使用过 spring-cache 的同学可能会更加熟悉 如果不清楚 Spel
的用法,可以参考 spring-cache 的注解文档
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/get")
@RateLimit(rate = 5, rateInterval = "10s", customKeyFunction = "keyFunction")
public String get(String name) {
return "get";
}
public String keyFunction(String name) {
return "keyFunction" + name;
}
}
当 @RateLimitKey 和 keys 参数都没法满足时,比如入参的值是一个加密的值,需要解密后根据相关明文内容限流。可以通过在同一类里自定义获取 key 的函数,这个函数要求和被限流的方法入参一致,返回值为 String 类型。返回值不能为空,为空时,会回退到默认的 key 获取策略。
spring.ratelimiter.enabled=true
spring.ratelimiter.response-body=Too Many Requests
spring.ratelimiter.status-code=509
添加如上配置后,触发限流时,http 的状态码就变成了 509 。响应的内容变成了 Too Many Requests 了
默认的触发限流后,限流器会抛出一个异常,限流器框架内定义了一个异常处理器来处理。自定义限流触发处理器,需要先禁用系统默认的限流触发处理器,禁用方式如下:
spring.ratelimiter.exceptionHandler.enable=false
然后在项目里添加自定义处理器,如下:
@ControllerAdvice
public class RateLimitExceptionHandler {
private final RateLimiterProperties limiterProperties;
public RateLimitExceptionHandler(RateLimiterProperties limiterProperties) {
this.limiterProperties = limiterProperties;
}
@ExceptionHandler(value = RateLimitException.class)
@ResponseBody
public String exceptionHandler(HttpServletResponse response, RateLimitException e){
HttpHeaders headers = new HttpHeaders();
if (e.getMode().equals(Mode.TIME_WINDOW)){
headers.add(HttpHeaders.RETRY_AFTER, String.valueOf(e.getExtra()));
}else {
headers.add(REMAINING_HEADER, String.valueOf(e.getExtra()));
}
return ResponseEntity.status(limiterProperties.getStatusCode())
.headers(headers)
.contentType(MediaType.APPLICATION_JSON)
.body(limiterProperties.getResponseBody());
}
}
@RequestMapping("/test")
public class TestController {
@GetMapping("/get")
@RateLimit(rate = 5, rateInterval = "10s",fallbackFunction = "getFallback")
public String get(String name) {
return "get";
}
public String getFallback(String name){
return "Too Many Requests" + name;
}
}
这种方式实现和使用和 2.1.3、自定义 key 获取函数类似。但是多一个要求,返回值的类型需要和原限流函数的返回值类型一致,当触发限流时,框架会调用 fallbackFunction 配置的函数执行并返回,达到限流降级的效果
从 v1.2
版本开始,在 @RateLimit
注解里新增了属性 rateExpression。该属性支持 Spel
表达式从 Spring 的配置上下文中获取值。 当配置了 rateExpression 后,rate 属性的配置就不生效了。使用方式如下:
@GetMapping("/get2")
@RateLimit(rate = 2, rateInterval = "10s",rateExpression = "${spring.ratelimiter.max}")
public String get2() {
return "get";
}
集成 apollo 等配置中心后,可以做到限流大小的动态调整在线热更。
启动 src/test/java/com/taptap/ratelimiter/Application.java
后,访问 http://localhost:8080/swagger-ui.html
#压测数据
kldeMacBook-Pro-6:ratelimiter-spring-boot-starter kl$ wrk -t16 -c100 -d15s --latency http://localhost:8080/test/wrk
Running 15s test @ http://localhost:8080/test/wrk
16 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 6.18ms 20.70ms 281.21ms 98.17%
Req/Sec 1.65k 307.06 2.30k 76.44%
Latency Distribution
50% 3.57ms
75% 4.11ms
90% 5.01ms
99% 115.48ms
389399 requests in 15.03s, 43.15MB read
Requests/sec: 25915.91
Transfer/sec: 2.87MB
压测下,所有流量都过限流器,qps 可以达到 2w+。
text/plain
变成了 application/json
Spel
从 Spring 的配置上下文中获取,结合 apollo
等配置中心后,支持规则的动态下发热更新https://gitee.com/kailing/ratelimiter-spring-boot-starter