限流的几种方式
限流的几种方式
- 计数器算法
:将时间周期划分为固定大小的窗口(如每分钟、每小时),并在每个窗口内统计请求的数量。当窗口内的请求数达到预设的阈值时,后续请求将被限制。时间窗口结束后,计数器清零。
- 优点:实现简单,易于理解。
- 缺点:在窗口切换时刻可能会有突刺流量问题,即在窗口结束时会有短暂的大量请求被允许通过。
- 滑动窗口算法
:改进了计算器算法(固定窗口算法)的突刺问题,将时间窗口划分为多个小的时间段(桶),每个小时间段有自己的计数器。随着时间流逝,窗口像滑块一样平移,过期的小时间段的计数会被丢弃,新时间段加入计数。所有小时间段的计数之和不能超过设定的阈值。
- 优点:更平滑地处理流量,避免了突刺问题。
- 缺点:实现相对复杂,需要维护多个计数器。
- 漏桶算法
:想象一个固定容量的桶,水(请求)以恒定速率流入桶中,同时桶底部有小孔让水以恒定速率流出。当桶满时,新来的水(请求)会被丢弃。此算法主要用来平滑网络流量,防止瞬时流量过大。
- 优点:可以平滑突发流量,保证下游系统的稳定。
- 缺点:无法处理突发流量高峰,多余的请求会被直接丢弃。
- 令牌桶算法
:与漏桶相反,有一个固定速率填充令牌的桶,令牌代表请求许可。当请求到达时,需要从桶中取出一个令牌,如果桶中有令牌则允许请求通过,否则拒绝。桶的容量是有限的,多余的令牌会被丢弃。
- 优点:既能平滑流量,又能处理一定程度的突发流量(因为令牌可以累积)。
- 缺点:需要精确控制令牌生成速度,实现较漏桶复杂。
单机版本限流
可以采用Guava的RateLimiter的方式
将 RateLimiter 和 Guava Cache 整合到一个 Spring Boot 项目中,并通过 @Autowired 注入这两个组件以便在控制器中使用。以下是整合的代码示例:
1. 添加 Guava 依赖
确保在 pom.xml 文件中已添加 Guava 依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.2-jre</version>
</dependency>
2. 配置 RateLimiter 和 Guava Cache
将 RateLimiter 和 LoadingCache 配置在同一个配置类中,并将它们定义为 Spring Bean。
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.RateLimiter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
@Configuration
public class GuavaConfig {
@Bean
public RateLimiter rateLimiter() {
// 配置 RateLimiter,每秒允许 1 个请求
return RateLimiter.create(1.0);
}
@Bean
public LoadingCache<String, String> cache() {
// 配置 Guava Cache,最大容量为 100,过期时间为 10 分钟
return CacheBuilder.newBuilder()
.maximumSize(100)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
// 定义缓存未命中时的加载行为
return "Default_Value_For_" + key;
}
});
}
}
3. 使用 RateLimiter 和 Guava Cache
在控制器中通过 @Autowired 注解将 RateLimiter 和 LoadingCache 注入,并在业务方法中使用它们。
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.RateLimiter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class ApiController {
private final RateLimiter rateLimiter;
private final LoadingCache<String, String> cache;
@Autowired
public ApiController(RateLimiter rateLimiter, LoadingCache<String, String> cache) {
this.rateLimiter = rateLimiter;
this.cache = cache;
}
@GetMapping("/limited")
public String rateLimitedEndpoint() {
// 使用 RateLimiter 限流
if (rateLimiter.tryAcquire()) {
return "Request successful!";
} else {
return "Rate limit exceeded. Please try again later.";
}
}
@GetMapping("/cache/get")
public String getFromCache(@RequestParam String key) {
// 从缓存中获取值
return cache.getUnchecked(key);
}
@GetMapping("/cache/put")
public String putToCache(@RequestParam String key, @RequestParam String value) {
// 将数据放入缓存
cache.put(key, value);
return "Added to cache: " + key + " -> " + value;
}
}
原理
Guava的RateLimiter基于令牌桶算法(Token Bucket Algorithm)实现限流。其核心原理是通过在固定时间间隔内生成“令牌”,当请求到来时会消耗这些令牌,从而控制请求的速率。以下是RateLimiter底层的实现原理:
-
令牌桶模型:
RateLimiter会以一个恒定的速率生成令牌,令牌放入桶中,当桶满了则不再生成新令牌。请求到来时需要从桶中获取令牌,只有当有可用令牌时请求才会通过,若没有可用令牌则需要等待或被拒绝。 -
稳定速率限制:
RateLimiter使用一种平滑的速率限制机制,每秒生成固定数量的令牌。这可以确保在给定的时间段内不会超过指定的速率。 -
预热模式:
RateLimiter支持平滑的预热模式(warmup模式),可以在服务启动时逐渐提升速率,防止突发请求对系统造成压力。预热模式下会逐步增加令牌生成速率,直至达到目标速率。 -
基于时间的令牌生成:
RateLimiter记录上次令牌生成的时间戳,通过计算当前时间与上次生成时间的差来决定是否生成新令牌。由于每次调用都会更新时间戳,因此RateLimiter可以在多线程环境下并发执行。
这种实现方式的优点是令牌生成速率平稳,且具有较高的灵活性,可以根据不同的限流需求调整速率或配置预热模式。
使用redisson 进行RateLimiter限流
原理:
local rate = redis.call("hget", KEYS[1], "rate") # 100
local interval = redis.call("hget", KEYS[1], "interval") # 3600000
local type = redis.call("hget", KEYS[1], "type") # 0
assert(rate ~= false and interval ~= false and type ~= false, "RateLimiter is not initialized")
local valueName = KEYS[2] # {xindoo.limiter}:value 用来存储剩余许可数量
local permitsName = KEYS[4] # {xindoo.limiter}:permits 记录了所有许可发出的时间戳
# 如果是单实例模式,name信息后面就需要拼接上clientId来区分出来了
if type == "1" then
valueName = KEYS[3] # {xindoo.limiter}:value:b474c7d5-862c-4be2-9656-f4011c269d54
permitsName = KEYS[5] # {xindoo.limiter}:permits:b474c7d5-862c-4be2-9656-f4011c269d54
end
# 对参数校验
assert(tonumber(rate) >= tonumber(ARGV[1]), "Requested permits amount could not exceed defined rate")
# 获取当前还有多少许可
local currentValue = redis.call("get", valueName)
local res
# 如果有记录当前还剩余多少许可
if currentValue ~= false then
# 回收已过期的许可数量
local expiredValues = redis.call("zrangebyscore", permitsName, 0, tonumber(ARGV[2]) - interval)
local released = 0
for i, v in ipairs(expiredValues) do
local random, permits = struct.unpack("Bc0I", v)
released = released + permits
end
# 清理已过期的许可记录
if released > 0 then
redis.call("zremrangebyscore", permitsName, 0, tonumber(ARGV[2]) - interval)
if tonumber(currentValue) + released > tonumber(rate) then
currentValue = tonumber(rate) - redis.call("zcard", permitsName)
else
currentValue = tonumber(currentValue) + released
end
redis.call("set", valueName, currentValue)
end
# ARGV permit timestamp random, random是一个随机的8字节
# 如果剩余许可不够,需要在res中返回下个许可需要等待多长时间
if tonumber(currentValue) < tonumber(ARGV[1]) then
local firstValue = redis.call("zrange", permitsName, 0, 0, "withscores")
res = 3 + interval - (tonumber(ARGV[2]) - tonumber(firstValue[2]))
else
redis.call("zadd", permitsName, ARGV[2], struct.pack("Bc0I", string.len(ARGV[3]), ARGV[3], ARGV[1]))
# 减小可用许可量
redis.call("decrby", valueName, ARGV[1])
res = nil
end
else # 反之,记录到还有多少许可,说明是初次使用或者之前已记录的信息已经过期了,就将配置rate写进去,并减少许可数
redis.call("set", valueName, rate)
redis.call("zadd", permitsName, ARGV[2], struct.pack("Bc0I", string.len(ARGV[3]), ARGV[3], ARGV[1]))
redis.call("decrby", valueName, ARGV[1])
res = nil
end
local ttl = redis.call("pttl", KEYS[1])
# 重置
if ttl > 0 then
redis.call("pexpire", valueName, ttl)
redis.call("pexpire", permitsName, ttl)
end
return res
我将向你展示如何在 Spring Boot 项目中集成 Redisson 的分布式限流功能,并提供原理说明,以帮助你更好地理解如何运作。下面的代码将展示如何使用 Redisson 的 RateLimiter 来实现分布式限流。
示例代码:Spring Boot 集成 Redisson RateLimiter
以下是一个完整的 Spring Boot 项目代码示例,用于演示如何使用 Redisson 实现分布式限流:
-
引入依赖
首先需要在pom.xml中引入 Redisson 的依赖:
xml <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.16.3</version> <!-- 选择适合的版本 --> </dependency> -
配置 RedissonClient
在application.yml中配置 Redis 的连接信息:
yaml spring: redis: host: localhost port: 6379 -
创建 Redisson 配置类
在 Spring Boot 中,通过配置类创建RedissonClient,并将其注入到 Spring 容器中:
```java
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
}
```
- 创建限流服务
使用RRateLimiter来定义一个限流逻辑:
```java
import org.redisson.api.RRateLimiter;
import org.redisson.api.RateIntervalUnit;
import org.redisson.api.RateType;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class RateLimiterService {
private final RRateLimiter rateLimiter;
@Autowired
public RateLimiterService(RedissonClient redissonClient) {
// 获取或创建一个限流器
rateLimiter = redissonClient.getRateLimiter("myRateLimiter");
// 初始化限流器:每秒最多产生 5 个令牌
rateLimiter.trySetRate(RateType.OVERALL, 5, 1, RateIntervalUnit.SECONDS);
}
public boolean tryAcquire() {
// 尝试获取一个令牌
return rateLimiter.tryAcquire();
}
}
```
- 创建 REST 控制器
创建一个控制器,使用RateLimiterService来限制 API 请求的速率:
```java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class RateLimiterController {
private final RateLimiterService rateLimiterService;
@Autowired
public RateLimiterController(RateLimiterService rateLimiterService) {
this.rateLimiterService = rateLimiterService;
}
@GetMapping("/limited")
public String limitedEndpoint() {
if (rateLimiterService.tryAcquire()) {
return "请求成功";
} else {
return "请求被限流,请稍后再试";
}
}
}
```
原理说明
-
Redis 和分布式锁机制
- Redisson 通过使用 Redis 来共享状态。限流器的令牌桶计数信息存储在 Redis 中,因此即使有多个应用实例,这些实例都能访问到相同的令牌桶信息。
-RRateLimiter通过 Redis 的原子性操作来保证每次请求时,令牌的获取和计数递减操作都是安全的。这种分布式共享机制确保不同节点都遵循相同的限流策略。 -
令牌桶算法
- Redisson 的RRateLimiter使用了类似令牌桶(Token Bucket)的算法进行限流。令牌桶算法的工作原理如下:- 每秒生成一定数量的令牌,代表允许通过的请求数。
- 每次请求前,必须从令牌桶中获取令牌。如果令牌足够,则允许请求;如果没有足够的令牌,说明请求速率过高,需要被限流。
- 当没有令牌时,请求将会被限制,从而保护服务不被突发流量压垮。
-
高可用与一致性
- 通过 Redis 作为中心存储,RateLimiter能够为分布式系统中的所有实例提供全局一致的限流行为。
- Redis 的单线程架构保证了对令牌计数器的原子性操作,这使得无论有多少个应用实例在并发访问,都不会出现竞态条件,限流行为是一致的。 -
RateLimiter 参数解析
-RateType.OVERALL:全局限流模式,所有客户端共享同一个令牌桶。
-trySetRate(rateType, rate, interval, unit):rateType:限流类型。rate:在时间窗口内可以生成的最大令牌数量。interval和unit:定义时间窗口的长度。
-
Spring Boot 集成优势
- Spring Boot 提供了便捷的依赖管理和配置机制,通过依赖注入(DI)可以轻松地将限流逻辑集成到项目的各个部分。
- 使用@Service和@RestController的 Spring 注解,可以将限流服务无缝集成到业务逻辑中,实现对某些关键接口的保护。
RRateLimiter使用时注意事项
了解了RRateLimiter的底层原理,再结合Redis自身的特性,我想到了RRateLimiter使用的几个局限点(问题点)。
RRateLimiter是非公平限流器
这个是我查阅资料得知,并且在自己代码实践的过程中也得到了验证,具体表现就是如果多个实例(机器)取竞争这些许可,很可能某些实例会获取到大部分,而另外一些实例可怜巴巴仅获取到少量的许可,也就是说容易出现旱的旱死 涝的涝死的情况。在使用过程中,你就必须考虑你能否接受这种情况,如果不能接受就得考虑用某些方式尽可能让其变公平。
Rate不要设置太大
从RRateLimiter的实现原理你也看出了,它采用的是滑动窗口的模式来限流的,而且记录了所有的许可授权信息,所以如果你设置的Rate值过大,在Redis中存储的信息(permitsName对应的zset)也就越多,每次执行那段lua脚本的性能也就越差,这对Redis实例也是一种压力。个人建议如果你是想设置较大的限流阈值,倾向于小Rate+小时间窗口的方式,而且这种设置方式请求也会更均匀一些。
限流的上限取决于Redis单实例的性能
从原理上看,RRateLimiter在Redis上所存储的信息都必须在一个Redis实例上,所以它的限流QPS的上限就是Redis单实例的上限,比如你Redis实例就是1w QPS,你想用RRateLimiter实现一个2w QPS的限流器,必然实现不了。 那有没有突破Redis单实例性能上限的方式?单限流器肯定是实现不了的,我们可以拆分多个限流器,比如我搞10个限流器,名词用不一样的,然后每台机器随机使用一个限流器限流,实际的流量不就被分散到不同的限流器上了吗,总的限流上线不也就上来了。