用 Redis 和 Spring Cloud Gateway 为 API 网关设计速率限制器

1. 概述

当多个客户端同时调用 API 时,一个客户端出错或恶意刷屏可能会导致其他人的后端服务崩溃。API 网关层的速率限制器可以在请求超过限制时将其阻止,防止它们到达后端服务。

本文通过一个完整的案例研究来讲解。步骤包括阐述需求、选择算法、设计数据模型和 API,然后使用 Redis Lua 脚本和 Spring Cloud Gateway 进行实现。读完本文后,你将能够构建一个分布式速率限制器,并了解如何处理并发冲突、时钟偏差、热键和 Redis 故障。

2. 问题需求

2.1. 功能需求

  • 按 租户的 API 密钥和路由进行限制。例如:租户 shop-a 调用 POST /orders 最多 100 请求/秒,允许突发至 200。
  • 超过限制的请求返回 429 Too Many Requests,并带上 Retry-After 头。
  • 每个响应都包含 X-RateLimit-Remaining 头,让客户端能够自我调节。
  • 规则可以在运行时修改,无需重新部署网关。

2.2. 规模和约束

  • 高峰时期约 50,000 RPS,20 个网关实例,100,000 个租户。
  • 每个请求增加的延迟必须在 p99 低于 2ms。
  • 限制必须在整个集群范围内准确,不是单独计算每个实例。如果限制是 100/s,整个集群最多只能通过约 100/s 的请求。
  • 速率限制器不应成为单点故障。当 Redis 遇到问题时,网关仍然需要能够提供服务。
  • 接受小的误差(几个百分点)以换取性能。

第三点约束排除了在单个实例内存中计数的方案。我们需要一个共享的状态存储。Redis 是自然的选择,因为它具有低延迟和通过 Lua 的原子操作。

3. 选择算法

算法 优点 缺点
固定时间窗口 简单,只需一个 INCR 在窗口边界处可能出现两倍的突发
滑动日志 绝对准确 消耗内存,需要存储每个时间戳
滑动窗口计数器 近似准确,轻量级 难以表示突发
令牌桶 自然支持突发,只需 2 个字段 需要计算令牌补充

我们选择令牌桶。每个桶有 capacity(突发大小)和补充速率 refillPerSecond。每个请求消耗一个令牌。没有令牌时,请求被拒绝。状态只包含两个数字:tokens 和 ts(最后更新时间)。对于 100,000 个租户乘以几个路由,总内存占用只有几十 MB。

4. 数据模型

4.1. SQL 中的规则配置

规则很少变化,需要被管理,所以我们将它们存储在 PostgreSQL 中:

CREATE TABLE rate_limit_rule (
    id                 BIGSERIAL PRIMARY KEY,
    tenant_id          VARCHAR(64)  NOT NULL,
    route_id           VARCHAR(64)  NOT NULL,
    capacity           INT          NOT NULL CHECK (capacity > 0),
    refill_per_second  INT          NOT NULL CHECK (refill_per_second > 0),
    updated_at         TIMESTAMPTZ  NOT NULL DEFAULT now(),
    UNIQUE (tenant_id, route_id)
);

INSERT INTO rate_limit_rule (tenant_id, route_id, capacity, refill_per_second)
VALUES ('shop-a', 'create-order', 200, 100);

网关不会在每个请求时读取该表。每个实例将所有规则加载到内存中,并定期刷新,例如每 30 秒一次。另一种方式是通过 Kafka 监听 rule-changed 事件。热路径永远不会触及数据库。

4.2. Redis 中的桶状态

每个租户-路由对是一个 Redis hash:

Key:    rl:{shop-a}:create-order
Fields: tokens = 153.4
        ts     = 1760256000123   (毫秒,根据 Redis 的时钟)
TTL:    当桶满后长时间未使用时自动过期

{shop-a} 部分是哈希标签。在 Redis Cluster 中,同一租户的所有密钥都落在同一个 slot 中。如果以后你需要在脚本中检查多个桶,它们将位于同一节点上。

5. API 和处理流程

客户端看到两种响应:

HTTP/1.1 200 OK
X-RateLimit-Remaining: 152

HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Remaining: 0

网关中的处理流程:

  1. 获取 X-Api-Key 来识别租户,从匹配的路由获取路由 ID。
  2. 从内存缓存中查找规则。没有规则则使用默认规则。
  3. 在 Redis 上调用 Lua 脚本,以原子方式补充令牌和消费令牌。
  4. 如果被允许,则添加头并转发。如果被拒绝,则返回 429。
  5. Redis 错误或超时则切换到本地限制器(第 7.3 部分)。

6. 实现

6.1. 依赖

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>2025.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
    </dependency>
</dependencies>

6.2. 令牌桶 Lua 脚本

将文件放在 src/main/resources/scripts/token_bucket.lua:

local key      = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillMs = tonumber(ARGV[2]) / 1000.0   -- 每毫秒的令牌数

local t   = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local data   = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts     = tonumber(data[2]) or now

tokens = math.min(capacity, tokens + math.max(0, now - ts) * refillMs)

local allowed = 0
if tokens >= 1 then
  tokens = tokens - 1
  allowed = 1
end

redis.call('HSET', key, 'tokens', tostring(tokens), 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / refillMs) * 2)

return { allowed, math.floor(tokens) }

关键点:

  • redis.call('TIME') 获取 Redis 的时间而不是网关的时间。20 个实例的时钟可能相差几十毫秒,如果每个实例发送自己的 now,令牌补充会出错。从 Redis 7 开始,脚本默认使用效果复制,所以在写入之前调用 TIME 是安全的。
  • 整个读取、计算和写入序列原子性地运行,因为 Redis 以单线程方式执行脚本。这是解决并发冲突的方法。
  • PEXPIRE 等于填满桶所需时间的两倍。未使用的桶将自动消失。当它重新出现时,它从满桶开始,这正是我们想要的行为。
  • 脚本返回 [allowed, remaining]。Lua 在返回时将浮点数转换为整数,所以我们预先 floor 以保持清晰。

6.3. 从 Java 调用脚本

public record RateLimitRule(int capacity, int refillPerSecond) {}

public record Decision(boolean allowed, long remaining) {}

@Component
public class RedisTokenBucket {

    private final ReactiveStringRedisTemplate redis;
    private final RedisScript<List> script =
            RedisScript.of(new ClassPathResource("scripts/token_bucket.lua"), List.class);

    public RedisTokenBucket(ReactiveStringRedisTemplate redis) {
        this.redis = redis;
    }

    @SuppressWarnings("unchecked")
    public Mono<Decision> tryAcquire(String tenant, String route, RateLimitRule rule) {
        String key = "rl:{" + tenant + "}:" + route;
        return redis.execute(script, List.of(key),
                        List.of(String.valueOf(rule.capacity()),
                                String.valueOf(rule.refillPerSecond())))
                .next()
                .map(r -> {
                    List<Long> res = (List<Long>) r;
                    return new Decision(res.get(0) == 1L, res.get(1));
                });
    }
}

Spring 自动计算脚本的 SHA1 并使用 EVALSHA。如果 Redis 还没有缓存脚本,Spring 会回退到 EVAL。因此,只有第一次调用才会通过网络发送整个脚本内容。

6.4. 网关中的全局过滤器

@Component
public class RateLimitFilter implements GlobalFilter, Ordered {

    private static final RateLimitRule DEFAULT_RULE = new RateLimitRule(20, 10);

    private final RedisTokenBucket redisBucket;
    private final LocalFallbackLimiter fallback;
    private final RuleCache rules; // Map<String, RateLimitRule> 定期从 DB 加载

    public RateLimitFilter(RedisTokenBucket redisBucket,
                           LocalFallbackLimiter fallback, RuleCache rules) {
        this.redisBucket = redisBucket;
        this.fallback = fallback;
        this.rules = rules;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String tenant = exchange.getRequest().getHeaders().getFirst("X-Api-Key");
        if (tenant == null) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
        String routeId = route != null ? route.getId() : "default";
        RateLimitRule rule = rules.find(tenant, routeId).orElse(DEFAULT_RULE);

        return redisBucket.tryAcquire(tenant, routeId, rule)
                .timeout(Duration.ofMillis(5))
                .onErrorResume(e -> Mono.just(fallback.tryAcquire(tenant, routeId, rule)))
                .flatMap(decision -> {
                    var response = exchange.getResponse();
                    response.getHeaders().set("X-RateLimit-Remaining",
                            String.valueOf(decision.remaining()));
                    if (decision.allowed()) {
                        return chain.filter(exchange);
                    }
                    response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
                    response.getHeaders().set("Retry-After",
                            String.valueOf(Math.max(1, 1 / rule.refillPerSecond())));
                    return response.setComplete();
                });
    }

    @Override
    public int getOrder() {
        return -100; // 在路由匹配后运行,在路由过滤器之前
    }
}

过滤器对所有路由运行。它设置 timeout(5ms) 以防一个缓慢的 Redis 节点拖累整个网关的延迟。当 Redis 出错时,onErrorResume 切换到本地限制器。用 curl 快速测试,规则为 capacity=2:

for i in 1 2 3; do
  curl -s -o /dev/null -w "%{http_code}\n" -H "X-Api-Key: shop-a" -X POST localhost:8080/orders
done
200
200
429

7. 困难点和解决方案

7.1. 实例之间的并发冲突

天真的做法是调用 HGET,在 Java 中计算,然后调用 HSET。两个实例可能同时读取 tokens=1 并都让请求通过。Lua 脚本完全消除了这种竞态条件。WATCH/MULTI 方案也是正确的,但在冲突时需要重试,并且需要更多的往返次数。

7.2. 热键和突发流量

一个大租户可能向一个键发送 20,000 RPS。一个 Redis 节点可以处理约 100,000 ops/s,但该键将成为热点。有两种解决方案:

  • 批量预取令牌(本地预取): 每个实例一次预先获取一批令牌,例如 50 个令牌,然后在内存中逐步消费。对 Redis 的调用次数减少约 50 倍。代价是,在短时间内,总限制可能超过 实例数 × 批量大小 倍。根据第 2.2 部分的“几个百分点的误差”要求,这在可接受的范围内。
  • 分片桶: 将 20,000/s 的限制拆分为 10 个子桶 rl:{shop-a}:create-order:0..9,每个 2,000/s。请求随机选择子桶。为这些键不使用哈希标签,使它们分散到多个节点。

已允许的突发被 capacity 吸收。这正是我们选择令牌桶而不是固定窗口的原因。

7.3. Redis 不可用:受控的失败开放

完全失败关闭(Redis 崩溃时拒绝所有请求)会将基础设施故障转变为系统范围的中断。完全失败开放则失去了保护层。中间的解决方案是使用本地限制器,将限制按实例数均匀分配:

@Component
public class LocalFallbackLimiter {

    private final int instanceCount;
    private final LongSupplier clock;
    private final ConcurrentHashMap<String, LocalBucket> buckets = new ConcurrentHashMap<>();

    public LocalFallbackLimiter(@Value("${gateway.instances:20}") int instanceCount) {
        this(instanceCount, System::currentTimeMillis);
    }

    LocalFallbackLimiter(int instanceCount, LongSupplier clock) {
        this.instanceCount = instanceCount;
        this.clock = clock;
    }

    public Decision tryAcquire(String tenant, String route, RateLimitRule rule) {
        double cap = Math.max(1, rule.capacity() / (double) instanceCount);
        double perMs = rule.refillPerSecond() / (double) instanceCount / 1000.0;
        LocalBucket b = buckets.computeIfAbsent(tenant + ":" + route,
                k -> new LocalBucket(cap, clock.getAsLong()));
        return b.take(cap, perMs, clock.getAsLong());
    }

    static final class LocalBucket {
        private double tokens;
        private long ts;

        LocalBucket(double tokens, long ts) {
            this.tokens = tokens;
            this.ts = ts;
        }

        synchronized Decision take(double cap, double perMs, long now) {
            tokens = Math.min(cap, tokens + Math.max(0, now - ts) * perMs);
            ts = now;
            if (tokens >= 1) {
                tokens -= 1;
                return new Decision(true, (long) tokens);
            }
            return new Decision(false, 0);
        }
    }
}

每个实例保持 1/instanceCount 的限制份额。整个集群的总限制仍然大约是正确的,无需 Redis。在生产环境中,你应该用断路器(例如 Resilience4j)包装 Redis 调用。当 Redis 宕机时,断路器避免为每个请求浪费 5ms 的超时。

7.4. 多区域一致性

如果网关运行在两个地区,每个请求都跨地区同步 Redis 会违反 2ms 的要求。实用的方法是根据地区按比例分割限制,例如 70/30,并接受一个租户可能在这个地区被提前限制,即使另一个地区还有余量。如果需要进一步优化,后台任务可以根据实际指标每几分钟调整比例。

8. 测试

令牌桶逻辑应该独立于实时时钟进行测试。由于 LocalFallbackLimiter 接受 LongSupplier,我们可以用 AtomicLong 来调整时间:

class LocalFallbackLimiterTest {

    @Test
    void shouldRejectWhenBucketEmptyAndRefillOverTime() {
        AtomicLong now = new AtomicLong(0);
        var limiter = new LocalFallbackLimiter(1, now::get);
        var rule = new RateLimitRule(2, 1); // 突发 2,每秒补充 1 个令牌

        assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
        assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
        assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isFalse();

        now.addAndGet(1_000); // 1 秒后有额外的 1 个令牌

        Decision d = limiter.tryAcquire("shop-a", "create-order", rule);
        assertThat(d.allowed()).isTrue();
        assertThat(d.remaining()).isZero();
    }
}

此测试验证了三个行为:允许突发至 capacity,拒绝超出限制的请求,令牌按时间正确补充。对于 Lua 脚本,你应该编写 integration 测试,使用 Testcontainers 和 redis:7 镜像。并行发送 1,000 个请求,并验证允许的请求数不超过 capacity。

注意: 常见错误:

  • 在脚本中使用网关的 System.currentTimeMillis() 作为 now。实例之间的时钟偏差将导致令牌补充错误。
  • 忘记为键设置 TTL。Redis 会因为每个出现过的租户而逐渐膨胀。
  • 在 NAT 或负载均衡器后面用 IP 标识客户端。整个公司将共享一个桶。
  • 不应该用网关的速率限制器替代服务内部的过载保护机制(bulkhead、load shedding)。网关只知道请求数,不知道哪个请求更重。

9. 结论

在本文中,我们从需求到实现设计了 API 网关的速率限制器。令牌桶优雅地处理突发。Redis 上的 Lua 脚本通过单次原子往返解决并发冲突。从 Redis 而非实例获取时间消除了实例间的时钟偏差问题。

真正的困难不在算法中。热键需要批量令牌预取或桶分片。Redis 故障需要用本地限制器进行受控的失败开放。多地区系统需要按比例分割限制,而不是紧密同步。在每一点上,我们都放弃一点准确性来换取低延迟和高可用性,这与我们从一开始就设定的约束完全一致。

system-designrate-limitingredisspring-cloud-gatewayjava