为什么需要限流
突发流量可能来自:
- 用户行为(秒杀、抢购)
- 系统故障(缓存失效、重试风暴)
- 恶意攻击(DDoS、爬虫)
限流保证系统在过载时优雅降级,而不是崩溃。
令牌桶 vs 漏桶
| 算法 | 特点 | 突发流量 | 适用 |
|---|---|---|---|
| 令牌桶 | 以固定速率放入令牌,请求消耗令牌 | 允许一定突发 | API 限流 |
| 漏桶 | 请求进入队列,以固定速率流出 | 平滑输出 | 网络流量整形 |
令牌桶更适合服务端限流:允许正常情况下的突发(用户手快多点几次),但长期超负荷时被限制。
Resilience4j RateLimiter
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
@Configuration
public class ResilienceRateLimiterConfig {
@Bean
public RateLimiter apiRateLimiter() {
io.github.resilience4j.ratelimiter.RateLimiterConfig config =
io.github.resilience4j.ratelimiter.RateLimiterConfig.custom()
.limitForPeriod(1000) // 每秒 1000 个请求
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ofMillis(10)) // 获取不到令牌等待 10ms
.build();
return RateLimiter.of("api", config);
}
}
使用:
@GetMapping("/api/orders")
public Flux<Order> listOrders() {
return orderRepo.findAll()
.transformDeferred(RateLimiterOperator.of(rateLimiter));
}
令牌耗尽时抛 RequestNotPermitted。注解式 Controller 返回的是 Flux<Order>,不能在这个链上 onErrorResume 成 ServerResponse(类型不匹配),应该用异常处理器统一映射:
@ExceptionHandler(RequestNotPermitted.class)
public ResponseEntity<Map<String, String>> handleRateLimit() {
return ResponseEntity.status(429)
.header("Retry-After", "1")
.body(Map.of("error", "Rate limit exceeded"));
}
函数式路由则可以在 handler 内部把它转成 ServerResponse.status(429),因为那里的返回类型本来就是 Mono<ServerResponse>。RateLimiterOperator 按订阅计费,不是按元素计费:一个返回 Flux 的请求只消耗一个令牌,不会因为流里有一万条记录就消耗一万个令牌。
Redis 分布式限流
单实例限流无法应对分布式部署。Redis 原子操作实现分布式令牌桶:
-- rate_limit.lua:令牌数和时间戳放在同一个 HASH 里
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- 每秒令牌数
local capacity = tonumber(ARGV[2]) -- 桶容量
local now = tonumber(ARGV[3]) -- 当前时间戳(毫秒)
local requested = tonumber(ARGV[4]) -- 请求令牌数
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then tokens = capacity end
if ts == nil then ts = now end
local delta = math.max(0, now - ts)
local filled = math.min(capacity, tokens + delta * rate / 1000)
local allowed = filled >= requested
local remaining = allowed and filled - requested or filled
redis.call('HSET', key, 'tokens', remaining, 'ts', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate * 2))
return allowed and 1 or 0
用 HMGET/HSET 而不是两个字符串 key,是因为 Redis Cluster 只允许脚本访问同一个 slot 的 key;key 和 key:last_updated 会触发 CROSSSLOT 错误。返回值也必须是整数:Lua 的 false 会被转成 nil,Boolean 输出类型拿不到明确的 false。
Java 调用用 DefaultRedisScript 直接传脚本内容。它自带脚本的 SHA1,Spring 先发 EVALSHA,收到 NOSCRIPT 再回退到 EVAL(EVAL 会让 Redis 缓存脚本);并不会先发一条独立的 SCRIPT LOAD:
@Service
public class DistributedRateLimiter {
private static final String LUA = """
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then tokens = capacity end
if ts == nil then ts = now end
local delta = math.max(0, now - ts)
local filled = math.min(capacity, tokens + delta * rate / 1000)
local allowed = filled >= requested
local remaining = allowed and filled - requested or filled
redis.call('HSET', key, 'tokens', remaining, 'ts', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate * 2))
return allowed and 1 or 0
""";
private static final DefaultRedisScript<Long> SCRIPT =
new DefaultRedisScript<>(LUA, Long.class);
private final ReactiveRedisTemplate<String, String> redis;
public Mono<Boolean> isAllowed(String key, int rate, int capacity) {
long now = System.currentTimeMillis();
return redis.execute(SCRIPT, List.of(key), List.of(
String.valueOf(rate), String.valueOf(capacity),
String.valueOf(now), "1"))
.next()
.map(result -> result != null && result == 1L);
}
}
不要手写 "rate_limit_sha".getBytes():那必须是 SCRIPT LOAD 返回的真实 SHA1,写死的字符串只是把一次错误的 EVALSHA 变成运行时错误。
分层限流
外部请求
→ 网关层(第 3.3 节):IP 限流,100 req/s
→ API 层:用户限流,10 req/s
→ 服务层:方法限流,100 req/s
→ 数据库层:连接池限流,20 conns
每层限流保护不同资源:
- 网关:防 DDoS,保护网络带宽
- API:防用户滥用,保护应用 CPU
- 服务:防内部错误,保护下游服务
- DB:防查询风暴,保护数据库
eventd v2 限流配置
eventd v2 的限流策略:
| 层级 | 粒度 | 限流 | 动作 |
|---|---|---|---|
| 网关 | IP | 100/s | 429 Too Many Requests |
| 网关 | 用户(JWT) | 30/s | 429 |
| API | 方法 | 500/s | 429 |
| 数据库 | 连接池 | 20 conn | 等待或拒绝 |
与熔断的配合
请求
→ 限流(RateLimiter,最外层)
→ 重试(Retry)
→ 熔断(CircuitBreaker,最内层)
→ 下游服务
顺序理由:
- 限流最先:超量请求在进入重试和熔断统计之前就被拒绝
- 重试在熔断外层:每次重试都经过熔断器,熔断器统计真实尝试次数;重试要排除
CallNotPermittedException - 熔断最内层:直接保护下游,打开后立即快速失败
这与 11.2 的「熔断内层、重试外层」是同一套顺序,不要反过来写成「限流 → 熔断 → 重试」。
小结
| 算法 | 实现 | 突发 | 平滑 |
|---|---|---|---|
| 令牌桶 | Resilience4j / Redis Lua | ✅ | 中 |
| 漏桶 | 队列限速 | ❌ | 高 |
| 固定窗口 | 计数器 | ❌ | 低 |
| 滑动窗口 | Redis ZSET | 中 | 中 |
| 层级 | 工具 | 粒度 |
|---|---|---|
| 网关 | Redis + Lua | IP / 用户 |
| 应用 | Resilience4j RateLimiter | 方法 / 服务 |
| 数据库 | 连接池大小 | 全局 |
第 11 章结束。下一节 12.1 事件驱动集成 :事件总线、事件网关、Saga 与响应式衔接。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。