用 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
网关中的处理流程:
- 获取
X-Api-Key来识别租户,从匹配的路由获取路由 ID。 - 从内存缓存中查找规则。没有规则则使用默认规则。
- 在 Redis 上调用 Lua 脚本,以原子方式补充令牌和消费令牌。
- 如果被允许,则添加头并转发。如果被拒绝,则返回 429。
- 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 故障需要用本地限制器进行受控的失败开放。多地区系统需要按比例分割限制,而不是紧密同步。在每一点上,我们都放弃一点准确性来换取低延迟和高可用性,这与我们从一开始就设定的约束完全一致。