前言
由于服务器资源的有限性,需要对请求的速度以及数量做限制,防止过多的请求导致服务器崩溃。一旦服务器接收请求的数量超过给定最大值或请求的速度大于服务器处理的速度,应当主动拒绝掉这些请求,来保证服务器自身的健康和稳定。
单机版限流
单机版限流就是对单个服务做限流。目前用得比较多的就是guava的RateLimiter限流算法了,一起看看吧。
guava
maven地址
// https://mvnrepository.com/artifact/com.google.guava/guava
implementation group: 'com.google.guava', name: 'guava', version: '31.0.1-jre'
main方法
public static void main(String[] args) throws InterruptedException {
RateLimiter rateLimiter = RateLimiter.create(1D, Duration.ofSeconds(5));
while (true) {
boolean elapsedSecond = rateLimiter.tryAcquire();
if(elapsedSecond) {
log.info("{} 获取 {}", Thread.currentThread(), System.nanoTime());
}
}
}
继承关系图
guava的关系比较简单
限流器
可睡眠的计时器
普通计时器
滴答器
用于获取当前时间
组合关系图
一把锁和一个睡眠计时器。
流程图
- 创建一个RateLimiter对象,有两个选择,SmoothBursty和SmoothWarmingUp。
- 加锁设置速率。
- 加锁,查询最近可用许可的时间,如果不能拿许可,返回false,如果可以拿许可,预定许可并获取等待时间。
- 睡眠等待直到许可生效。
使用悲观锁,不知道高并发的时候性能会不会有瓶颈?
总结
-
guava的RateLimiter分为两种,一种SmoothBursty,限流的速率始终一致;另一种是SmoothWarmingUp,有个预热时间,预热期间限流速率平滑上升,预热时间结束时,达到给定的最大值。
-
如果WarmingUp达到最大限流速度后暂停,那么限流器又会进行一次预热。
-
限流器有阻塞版本的
acquire()
和非阻塞版本的tryAcquire()
。
eureka
也是偶然间看到eureka也有一个限流器,用于InstanceInfoReplicator
向服务器同步数据。
maven地址
// https://mvnrepository.com/artifact/com.netflix.eureka/eureka-client
runtimeOnly group: 'com.netflix.eureka', name: 'eureka-client', version: '1.10.17'
main方法
public static void main(String[] args) throws InterruptedException {
int count = 0;
EurekaRateLimiter rateLimiter = new EurekaRateLimiter(TimeUnit.SECONDS);
while (true) {
boolean elapsedSecond = rateLimiter.acquire(1, 1);
if (elapsedSecond) {
log.info("{} 获取 {}", Thread.currentThread(), System.nanoTime());
++count;
if (count > 100) {
Thread.sleep(15000);
count = 0;
}
}
}
}
组合关系图
流程图
- 创建一个RateLimiter
- 填充令牌桶
- 消费令牌
总结
- eureka限流器,主要有两个参数,
burstSize
和averageRate
,burstSize
决定可以释放的总大小,当达到最大释放大小后,单位时间释放的个数由averateRate
速率决定。如果令牌桶(burstSize)还有令牌,则消耗令牌返回,没有令牌时,消耗令牌速率受(averageRate)限制。
图中,第一个数字代表
burstSize
,第二个数字代表averageRate
,第三个数字代表睡眠的时间,比如Eureka-50-1-60
表示burstSize=50,averageRate=1(acquire/s),sleepTime=60(ms)
- eureka限流器采用
while
和compareAndSet
乐观锁来进行线程同步。
分布式限流
分布式限流是对多个服务进行限流,即多个服务共享一个速率。
redis
目前用得比较多的是采用redis+lua的方案,用redis存储限流信息,lua编写限流算法。
这套方案需要对lua脚本有所了解,在spring-cloud-gateway
已有实现。
request_rate_limiter.lua
local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]
--redis.log(redis.LOG_WARNING, "tokens_key " .. tokens_key)
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local fill_time = capacity/rate
local ttl = math.floor(fill_time*2)
--redis.log(redis.LOG_WARNING, "rate " .. ARGV[1])
--redis.log(redis.LOG_WARNING, "capacity " .. ARGV[2])
--redis.log(redis.LOG_WARNING, "now " .. ARGV[3])
--redis.log(redis.LOG_WARNING, "requested " .. ARGV[4])
--redis.log(redis.LOG_WARNING, "filltime " .. fill_time)
--redis.log(redis.LOG_WARNING, "ttl " .. ttl)
local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
last_tokens = capacity
end
--redis.log(redis.LOG_WARNING, "last_tokens " .. last_tokens)
local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
last_refreshed = 0
end
--redis.log(redis.LOG_WARNING, "last_refreshed " .. last_refreshed)
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
--redis.log(redis.LOG_WARNING, "delta " .. delta)
--redis.log(redis.LOG_WARNING, "filled_tokens " .. filled_tokens)
--redis.log(redis.LOG_WARNING, "allowed_num " .. allowed_num)
--redis.log(redis.LOG_WARNING, "new_tokens " .. new_tokens)
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, capacity, filled_tokens, requested, new_tokens }
return { allowed_num, new_tokens }
笔者也是第一次接触lua语法,lua在openresty用得比较多,如果想用好openresty,lua还是绕不过,只能硬着头皮看了。
maven地址
//lettuce
implementation group: 'io.lettuce', name: 'lettuce-core', version: '6.1.4.RELEASE'
main方法
@Slf4j
public class RateLimiter {
private volatile StatefulRedisConnection<String, String> connection;
private volatile String scriptSha1;
private String uri;
private int replenishRate;
private int burstCapacity;
private int requestedTokens;
public RateLimiter(String uri, int replenishRate, int burstCapacity, int requestedTokens) {
this.uri = uri;
this.replenishRate = replenishRate;
this.burstCapacity = burstCapacity;
this.requestedTokens = requestedTokens;
getConnection();
getScriptSha1();
}
public boolean acquire(String id) {
// How many requests per second do you want a user to be allowed to do?
int replenishRate = this.replenishRate;
// How much bursting do you want to allow?
int burstCapacity = this.burstCapacity;
// How many tokens are requested per request?
int requestedTokens = this.requestedTokens;
List<String> keys = getKeys(id);
// The arguments to the LUA script. time() returns unixtime in seconds.
List<String> scriptArgs = Arrays.asList(replenishRate + "", burstCapacity + "",
Instant.now().getEpochSecond() + "", requestedTokens + "");
// allowed, tokens_left = redis.eval(SCRIPT, keys, args)
List<Long> result = execute(keys, scriptArgs);
return result.size() > 0 && result.get(0) == 1L;
}
private List<String> getKeys(String id) {
// use `{}` around keys to use Redis Key hash tags
// this allows for using redis cluster
// Make a unique key per user.
String prefix = "request_rate_limiter.{" + id;
// You need two Redis keys for Token Bucket.
String tokenKey = prefix + "}.tokens";
String timestampKey = prefix + "}.timestamp";
return Arrays.asList(tokenKey, timestampKey);
}
private List<Long> execute(List<String> keys, List<String> scriptArgs) {
try {
if (StringUtils.isEmpty(getScriptSha1())) {
return Arrays.asList(1L, -1L);
}
return getConnection().sync().evalsha(getScriptSha1(), ScriptOutputType.MULTI, keys.toArray(new String[0]), scriptArgs.toArray(new String[0]));
} catch (Exception e) {
log.info("请求限流信息报错", e);
return Arrays.asList(1L, -1L);
}
}
/**
* DCL
*/
private StatefulRedisConnection<String, String> getConnection() {
StatefulRedisConnection<String, String> connection = this.connection;
if (Objects.isNull(connection)) {
synchronized (this) {
connection = this.connection;
if (Objects.isNull(connection)) {
RedisClient redisClient = RedisClient.create(this.uri);
connection = redisClient.connect();
this.connection = connection;
}
}
}
return connection;
}
private String getScriptSha1() {
String sha1 = this.scriptSha1;
if (Objects.isNull(sha1)) {
synchronized (this) {
sha1 = this.scriptSha1;
if (Objects.isNull(sha1)) {
sha1 = doLoadScript();
this.scriptSha1 = sha1;
}
}
}
return sha1;
}
private String doLoadScript() {
try (InputStream inputStream = getClass().getResourceAsStream("/request_rate_limiter.lua")) {
if (Objects.isNull(inputStream)) {
return "";
}
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
int len;
byte[] buffer = new byte[256];
while (true) {
len = inputStream.read(buffer);
if (len == -1) {
break;
}
baos.write(buffer, 0, len);
}
byte[] script = baos.toByteArray();
try {
return getConnection().sync().scriptLoad(script);
} catch (Exception e) {
log.info("doLoadScript报错", e);
return "";
}
}
} catch (Exception e) {
log.info("doLoadScript报错", e);
return "";
}
}
public static void main(String[] args) throws InterruptedException {
int count = 0;
RateLimiter rateLimiter = new RateLimiter("redis://:civic@localhost/10", 1, 50, 1);
while (true) {
boolean elapsedSecond = rateLimiter.acquire("xxx");
if (elapsedSecond) {
log.info("{} 获取 {}", Thread.currentThread(), System.nanoTime());
++count;
if (count > 100) {
Thread.sleep(60000);
count = 0;
}
}
}
}
}
流程图
这个流程比较简单:
- 创建RateLimiter,初始化RedisClient,获取StatefulRedisConnection连接,加载脚本到Redis。
- 构建键值和参数,调用lettuce的evalSha方法执行限流lua脚本,并获取应答。
- 根据应答(allowed_num, new_tokens)判断是否有许可。
总结
通过测试总结以下几点规律:
- Redis的lua限流算法和Eureka的单机版算法表现一致。
图中,第一个数字代表
averageRate
,第二个数字代表burstSize
,第三个数字代表睡眠的时间,比如Redis-1-50-60
表示averageRate=1(acquire/s),burstSize=50,sleepTime=60(ms)
- Redis限流器采用lua脚本的原子性进行线程同步。