Spring Cloud Gateway响应式限流RedisRateLimiter令牌桶Lua脚本原子性?EVALSHA与Script Load缓存

Spring Cloud Gateway 响应式限流:RedisRateLimiter 令牌桶 Lua 脚本原子性与 EVALSHA/Script Load 缓存

各位朋友,大家好。今天我们来深入探讨 Spring Cloud Gateway 中响应式限流的实现,特别是围绕 RedisRateLimiter 的令牌桶算法,以及它如何利用 Lua 脚本保证原子性,以及利用 EVALSHA 和 Script Load 缓存来提升性能。

一、限流的重要性及常见算法

在微服务架构中,限流是保护后端服务免受过载影响的关键手段。它可以防止恶意攻击、流量突增等情况导致的系统崩溃,保证服务的可用性和稳定性。

常见的限流算法包括:

  • 计数器算法: 简单易实现,但在临界点可能出现瞬间流量超过限制的情况。
  • 固定窗口算法: 与计数器类似,存在临界点问题。
  • 滑动窗口算法: 精度更高,能够平滑流量,但实现相对复杂。
  • 漏桶算法: 以恒定速率处理请求,多余的请求放入桶中,如果桶满了则丢弃。适合平滑突发流量。
  • 令牌桶算法: 以恒定速率生成令牌,请求需要获取令牌才能通过,如果令牌不足则拒绝。允许一定程度的突发流量。

二、RedisRateLimiter:令牌桶算法的实践

Spring Cloud Gateway 自带了 RedisRateLimiter,它使用 Redis 实现令牌桶算法。 它的工作原理如下:

  1. 令牌生成: Redis 会以配置的速率(replenishRate)向令牌桶中添加令牌。
  2. 请求处理: 每个请求到达时,会尝试从令牌桶中获取令牌。
  3. 令牌消耗: 如果令牌桶中有足够的令牌,则获取成功,请求通过,令牌数量减少。
  4. 请求拒绝: 如果令牌桶中没有足够的令牌,则获取失败,请求被拒绝。

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

脚本逻辑解释:

  1. 获取参数:KEYSARGV 中获取令牌桶的 Key、补充速率、容量和请求的令牌数量。
  2. 获取上次刷新时间和当前令牌数: 从 Redis 中获取上次刷新时间和当前令牌数。如果不存在,则初始化为 0 和令牌桶容量。
  3. 计算新增令牌数: 根据当前时间和上次刷新时间,计算新增的令牌数。
  4. 更新令牌数: 将新增令牌数加到当前令牌数上,但不能超过令牌桶容量。
  5. 判断是否允许请求: 如果当前令牌数大于等于请求的令牌数,则允许请求。
  6. 更新 Redis: 更新 Redis 中上次刷新时间和当前令牌数。
  7. 返回结果: 返回一个数组,第一个元素表示是否允许请求(1 表示允许,0 表示拒绝),第二个元素表示剩余令牌数。

原子性保证:

由于整个脚本在 Redis 服务器上以原子方式执行,因此可以保证在高并发情况下,令牌的获取和更新是线程安全的。

四、EVALSHA 与 Script Load:提升 Lua 脚本执行效率

每次执行 Lua 脚本,都需要将脚本内容发送到 Redis 服务器进行解析和执行。对于频繁调用的脚本,这会带来一定的性能开销。

Redis 提供了 EVALSHASCRIPT LOAD 命令来解决这个问题。

  • SCRIPT LOAD: 将 Lua 脚本加载到 Redis 服务器,并返回一个 SHA1 摘要值。
  • EVALSHA: 使用 SHA1 摘要值来执行已经加载到 Redis 服务器的脚本。

工作流程:

  1. 第一次执行脚本:
    • 使用 SCRIPT LOAD 将脚本加载到 Redis 服务器。
    • 获取脚本的 SHA1 摘要值。
    • 使用 EVALSHA 和 SHA1 摘要值执行脚本。如果 Redis 服务器没有加载该脚本,则会返回错误。
  2. 后续执行脚本:
    • 直接使用 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 能够充分利用 EVALSHASCRIPT LOAD 的优势,提高 Lua 脚本的执行效率。

五、RedisRateLimiter 配置详解

RedisRateLimiter 的配置主要通过 application.ymlapplication.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-idlemin-idle 应该根据业务场景进行调整。 如果 Redis 连接经常被使用,则可以适当增加 max-idlemin-idle 的值,以避免频繁创建和销毁连接。
  • max-wait 应该设置一个合理的值,以避免长时间等待连接。
  • timeout 应该根据 Redis 操作的平均执行时间进行调整。 如果操作经常超时,则可以适当增加 timeout 的值。

七、总结

本文深入探讨了 Spring Cloud Gateway 中 RedisRateLimiter 的实现,重点介绍了 Lua 脚本在保证原子性方面的作用,以及 EVALSHASCRIPT LOAD 在提升性能方面的作用。 同时,也详细介绍了 RedisRateLimiter 的配置和 Redis 连接池的配置。 希望能够帮助大家更好地理解和使用 RedisRateLimiter,构建高可用、高性能的微服务架构。

关键点回顾

  • Lua 脚本保证 RedisRateLimiter 在并发环境下的原子性操作。
  • EVALSHA 和 Script Load 机制有效地减少了网络传输和脚本解析的开销,提升性能。
  • 合理的 Redis 连接池配置对提升整体限流性能至关重要。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注