Spring Cloud Gateway 响应式限流:RedisRateLimiter 令牌桶 Lua 脚本原子性与 EVALSHA/Script Load 缓存
各位朋友,大家好。今天我们来深入探讨 Spring Cloud Gateway 中响应式限流的实现,特别是围绕 RedisRateLimiter 的令牌桶算法,以及它如何利用 Lua 脚本保证原子性,以及利用 EVALSHA 和 Script Load 缓存来提升性能。
一、限流的重要性及常见算法
在微服务架构中,限流是保护后端服务免受过载影响的关键手段。它可以防止恶意攻击、流量突增等情况导致的系统崩溃,保证服务的可用性和稳定性。
常见的限流算法包括:
- 计数器算法: 简单易实现,但在临界点可能出现瞬间流量超过限制的情况。
- 固定窗口算法: 与计数器类似,存在临界点问题。
- 滑动窗口算法: 精度更高,能够平滑流量,但实现相对复杂。
- 漏桶算法: 以恒定速率处理请求,多余的请求放入桶中,如果桶满了则丢弃。适合平滑突发流量。
- 令牌桶算法: 以恒定速率生成令牌,请求需要获取令牌才能通过,如果令牌不足则拒绝。允许一定程度的突发流量。
二、RedisRateLimiter:令牌桶算法的实践
Spring Cloud Gateway 自带了 RedisRateLimiter,它使用 Redis 实现令牌桶算法。 它的工作原理如下:
- 令牌生成: Redis 会以配置的速率(
replenishRate)向令牌桶中添加令牌。 - 请求处理: 每个请求到达时,会尝试从令牌桶中获取令牌。
- 令牌消耗: 如果令牌桶中有足够的令牌,则获取成功,请求通过,令牌数量减少。
- 请求拒绝: 如果令牌桶中没有足够的令牌,则获取失败,请求被拒绝。
RedisRateLimiter 的核心在于其操作的原子性。为了保证并发环境下令牌获取的正确性,它使用了 Lua 脚本。
三、Lua 脚本与原子性
在多线程或分布式环境下,多个客户端可能同时尝试从 Redis 令牌桶中获取令牌。如果使用多个 Redis 命令(例如,先检查令牌数量,再扣减令牌),则可能出现竞态条件,导致超发令牌。
Lua 脚本可以将多个 Redis 命令打包成一个原子操作。 Redis 会保证 Lua 脚本在执行期间不会被其他命令中断。
RedisRateLimiter 使用的 Lua 脚本如下(简化版,不包含其他逻辑):
local bucket = KEYS[1] -- 令牌桶的 Key
local rate = tonumber(ARGV[1]) -- 令牌补充速率
local burstCapacity = tonumber(ARGV[2]) -- 令牌桶容量
local requested = tonumber(ARGV[3]) -- 请求的令牌数量
local now = redis.call("TIME")[1] -- 当前时间戳
local lastRefreshed = redis.call("GET", bucket .. ".last_refresh") -- 上次刷新时间
if lastRefreshed == false then
lastRefreshed = 0
end
local tokens = redis.call("GET", bucket .. ".tokens") -- 当前令牌数量
if tokens == false then
tokens = burstCapacity
end
local diff = math.max(0, now - lastRefreshed)
local newTokens = math.min(burstCapacity, tokens + diff * rate)
local allowed = newTokens >= requested
if allowed then
redis.call("SET", bucket .. ".last_refresh", now)
redis.call("SET", bucket .. ".tokens", newTokens - requested)
return {1, newTokens - requested} -- 允许请求
else
redis.call("SET", bucket .. ".last_refresh", now)
redis.call("SET", bucket .. ".tokens", newTokens)
return {0, newTokens} -- 拒绝请求
end
脚本逻辑解释:
- 获取参数: 从
KEYS和ARGV中获取令牌桶的 Key、补充速率、容量和请求的令牌数量。 - 获取上次刷新时间和当前令牌数: 从 Redis 中获取上次刷新时间和当前令牌数。如果不存在,则初始化为 0 和令牌桶容量。
- 计算新增令牌数: 根据当前时间和上次刷新时间,计算新增的令牌数。
- 更新令牌数: 将新增令牌数加到当前令牌数上,但不能超过令牌桶容量。
- 判断是否允许请求: 如果当前令牌数大于等于请求的令牌数,则允许请求。
- 更新 Redis: 更新 Redis 中上次刷新时间和当前令牌数。
- 返回结果: 返回一个数组,第一个元素表示是否允许请求(1 表示允许,0 表示拒绝),第二个元素表示剩余令牌数。
原子性保证:
由于整个脚本在 Redis 服务器上以原子方式执行,因此可以保证在高并发情况下,令牌的获取和更新是线程安全的。
四、EVALSHA 与 Script Load:提升 Lua 脚本执行效率
每次执行 Lua 脚本,都需要将脚本内容发送到 Redis 服务器进行解析和执行。对于频繁调用的脚本,这会带来一定的性能开销。
Redis 提供了 EVALSHA 和 SCRIPT LOAD 命令来解决这个问题。
- SCRIPT LOAD: 将 Lua 脚本加载到 Redis 服务器,并返回一个 SHA1 摘要值。
- EVALSHA: 使用 SHA1 摘要值来执行已经加载到 Redis 服务器的脚本。
工作流程:
- 第一次执行脚本:
- 使用
SCRIPT LOAD将脚本加载到 Redis 服务器。 - 获取脚本的 SHA1 摘要值。
- 使用
EVALSHA和 SHA1 摘要值执行脚本。如果 Redis 服务器没有加载该脚本,则会返回错误。
- 使用
- 后续执行脚本:
- 直接使用
EVALSHA和 SHA1 摘要值执行脚本。
- 直接使用
优势:
- 减少网络传输: 只需要发送 SHA1 摘要值,而不是完整的脚本内容。
- 减少解析开销: Redis 服务器只需要解析一次脚本。
Spring Cloud Gateway 的实现:
RedisRateLimiter 内部会先尝试使用 EVALSHA 执行脚本。如果 Redis 服务器没有加载该脚本(例如,服务器重启),则会捕获异常,并使用 SCRIPT LOAD 加载脚本,然后再次使用 EVALSHA 执行脚本。
下面是 RedisRateLimiter 中与 Lua 脚本相关的关键代码片段:
private Mono<RateLimiterResponse> rateLimit(String routeId, String id, RequestedTokensRoutePredicateRouteFilter.Config config) {
int replenishRate = config.getReplenishRate();
int burstCapacity = config.getBurstCapacity();
int requestedTokens = config.getRequestedTokens();
List<String> keys = getKeys(id);
List<String> scriptArgs = Arrays.asList(String.valueOf(replenishRate), String.valueOf(burstCapacity), String.valueOf(requestedTokens), String.valueOf(Instant.now().getEpochSecond()));
// See https://github.com/spring-cloud/spring-cloud-gateway/issues/1291
Flux<List<Long>> resultFlux = this.redisTemplate.execute(this.script, keys, scriptArgs.toArray());
return resultFlux.onErrorResume(throwable -> {
//加载脚本到 redis 缓存
return scriptLoadMono().then(Mono.defer(() -> {
Flux<List<Long>> retryResultFlux = this.redisTemplate.execute(this.script, keys, scriptArgs.toArray());
return retryResultFlux;
}));
}).next().map(results -> {
boolean allowed = results.get(0) == 1L;
Long tokensLeft = results.get(1);
RateLimiterResponse response = new RateLimiterResponse(allowed, tokensLeft);
if (log.isDebugEnabled()) {
log.debug("限流结果: {}", response);
}
return response;
});
}
private Mono<String> scriptLoadMono() {
if (this.scriptShaValue != null) {
return Mono.just(this.scriptShaValue);
}
return Mono.fromSupplier(() -> {
DefaultRedisScript<List<Long>> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(RATE_LIMITER_LUA_SCRIPT);
redisScript.setResultType(List.class);
return redisScript;
}).flatMap(script -> {
this.script = script;
return redisTemplate.execute(script, Collections.emptyList(), Collections.emptyList());
}).map(sha -> {
this.scriptShaValue = (String) sha;
if (log.isDebugEnabled()) {
log.debug("redis 脚本 sha: {}", this.scriptShaValue);
}
return this.scriptShaValue;
}).onErrorResume(throwable -> {
log.error("加载 redis 脚本失败", throwable);
return Mono.error(throwable);
});
}
代码解释:
rateLimit方法:负责执行限流逻辑。- 首先,构建 Redis 命令的参数,包括 Key 和脚本参数。
- 然后,尝试使用
redisTemplate.execute(this.script, keys, scriptArgs.toArray())执行脚本。this.script包含脚本的 SHA1 摘要值。 - 如果执行失败,则使用
scriptLoadMono()加载脚本,并再次尝试执行。
scriptLoadMono方法:负责加载 Lua 脚本。- 如果
this.scriptShaValue不为空,则说明脚本已经加载,直接返回 SHA1 摘要值。 - 否则,创建一个
DefaultRedisScript对象,设置脚本内容和结果类型。 - 使用
redisTemplate.execute(script, Collections.emptyList(), Collections.emptyList())加载脚本,并获取 SHA1 摘要值。 - 将 SHA1 摘要值保存到
this.scriptShaValue中。
- 如果
通过这种方式,RedisRateLimiter 能够充分利用 EVALSHA 和 SCRIPT LOAD 的优势,提高 Lua 脚本的执行效率。
五、RedisRateLimiter 配置详解
RedisRateLimiter 的配置主要通过 application.yml 或 application.properties 文件进行。
spring:
cloud:
gateway:
routes:
- id: example_route
uri: http://example.com
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10 # 每秒补充的令牌数
redis-rate-limiter.burstCapacity: 20 # 令牌桶的容量
key-resolver: "#{@ipAddressKeyResolver}" # Key 解析器
redis-rate-limiter.requestedTokens: 1 # 每次请求消耗的令牌数, 默认是1
配置项说明:
| 配置项 | 说明 |
|---|---|
redis-rate-limiter.replenishRate |
每秒补充的令牌数。 |
redis-rate-limiter.burstCapacity |
令牌桶的容量。 决定了允许的最大突发流量。 |
key-resolver |
Key 解析器。 用于生成 Redis Key,不同的 Key 对应不同的令牌桶。 可以使用 SpEL 表达式引用 Bean,例如 @{#ipAddressKeyResolver} 或 @{#userKeyResolver}。 常见的实现包括根据 IP 地址、用户 ID 等进行限流。 |
redis-rate-limiter.requestedTokens |
每次请求消耗的令牌数, 默认是1。 |
Key 解析器:
Key 解析器是一个实现了 KeyResolver 接口的 Bean。 它负责根据请求信息生成 Redis Key。
以下是一个根据 IP 地址进行限流的 Key 解析器示例:
@Component
public class IpAddressKeyResolver implements KeyResolver {
@Override
public Mono<String> resolve(ServerWebExchange exchange) {
return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
}
}
在使用时,需要在配置文件中引用该 Bean:
spring:
cloud:
gateway:
routes:
- id: example_route
uri: http://example.com
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
key-resolver: "#{@ipAddressKeyResolver}"
六、深入理解 Redis 连接池配置
Spring Data Redis 默认使用 Lettuce 作为 Redis 客户端。 Lettuce 提供了强大的异步和响应式 API,并支持连接池。
合理的连接池配置对于提高 Redis 客户端的性能至关重要。
以下是一些重要的连接池配置项:
| 配置项 | 说明 |
|---|---|
spring.redis.lettuce.pool.max-active |
连接池中允许的最大连接数。 应该根据实际并发量进行调整。 |
spring.redis.lettuce.pool.max-idle |
连接池中允许的最大空闲连接数。 |
spring.redis.lettuce.pool.min-idle |
连接池中保持的最小空闲连接数。 |
spring.redis.lettuce.pool.max-wait |
从连接池获取连接的最大等待时间(毫秒)。 如果超过该时间仍然无法获取连接,则会抛出异常。 |
spring.redis.timeout |
Redis 操作的超时时间(毫秒)。 如果操作超过该时间仍然没有完成,则会抛出异常。 |
配置示例:
spring:
redis:
host: localhost
port: 6379
lettuce:
pool:
max-active: 200
max-idle: 50
min-idle: 10
max-wait: 5000
timeout: 5000
调优建议:
max-active: 应该根据实际并发量进行调整。 如果并发量很高,则需要增加max-active的值。max-idle和min-idle: 应该根据业务场景进行调整。 如果 Redis 连接经常被使用,则可以适当增加max-idle和min-idle的值,以避免频繁创建和销毁连接。max-wait: 应该设置一个合理的值,以避免长时间等待连接。timeout: 应该根据 Redis 操作的平均执行时间进行调整。 如果操作经常超时,则可以适当增加timeout的值。
七、总结
本文深入探讨了 Spring Cloud Gateway 中 RedisRateLimiter 的实现,重点介绍了 Lua 脚本在保证原子性方面的作用,以及 EVALSHA 和 SCRIPT LOAD 在提升性能方面的作用。 同时,也详细介绍了 RedisRateLimiter 的配置和 Redis 连接池的配置。 希望能够帮助大家更好地理解和使用 RedisRateLimiter,构建高可用、高性能的微服务架构。
关键点回顾
- Lua 脚本保证 RedisRateLimiter 在并发环境下的原子性操作。
- EVALSHA 和 Script Load 机制有效地减少了网络传输和脚本解析的开销,提升性能。
- 合理的 Redis 连接池配置对提升整体限流性能至关重要。