前两篇搭好了管道和插件机制,这篇把限流从"能调阈值"推进到"能调维度":默认按接口限流,四行注解按参数限流,一个 Bean 按用户/IP/租户任意组合限流------最后讲清楚「不同维度各解决什么问题、各自有什么安全边界」。
第一篇把接口治理做成了管道,第二篇讲了怎么写一个敢上生产的插件。这篇兑现第二篇结尾的预告:讲管道上那个最有商业价值的扩展点------限流键解析器(RateLimitKeyResolver)。
很多团队做限流,做的其实是「调阈值」:加注解、配 limit、收工。这个够解决"接口被刷了"------但解决不了"只刷了一个用户"、"租户 A 把租户 B 的配额吃完了"、"登录接口被爆破,但全局阈值调低会误伤正常用户"这类问题。这些问题的区别不在算法,在"以什么维度限流"------而这,正是键解析器这个扩展点存在的意义。
一、默认长什么样:接口级限流
先理解框架不开箱就给的默认值。@RateLimit(limit = 100) 贴在一个 Controller 方法上,同一个接口的所有请求共享同一份配额,不管是谁发起的:
java
@PostMapping
@RateLimit(limit = 100, window = 60)
public Order create(@RequestBody OrderRequest req) { ... }
限流键 = 全限定类名#方法名(比如 com.example.OrderController#create)。同一个键,同一份配额。一个用户连刷 100 次,第 101 次被拒------不管这个用户是 VIP 还是爬虫。
够用吗?对于"接口被并发刷爆"这个场景,够。对于"一个恶意用户疯狂刷单"这个场景,不够------把全局阈值调高会拖垮接口,调低会误伤正常用户。区别就是维度。
二、四行注解升级:SpEL 参数维度限流
最常见的维度需求是"按参数":同一个接口,不同的参数值各自独立计数。注解上直接写 SpEL:
java
@GetMapping("/users/{id}")
@RateLimit(limit = 10, window = 60, key = "#id")
public User get(@PathVariable Long id) { ... }
@PostMapping("/login")
@RateLimit(limit = 5, window = 60, key = "#request.username")
public Token login(@RequestBody LoginRequest request) { ... }
最终限流键 = 全限定类名#方法名:表达式结果。id=42 和 id=43 各自 10 次,互不相干。
实现上用受限的 SimpleEvaluationContext 求值:不允许类型引用、构造器调用和 Bean 引用,表达式失败自动回退接口级限流(只打 warn)。这套防御是为了杜绝 SpEL 注入------StandardEvaluationContext 加上客户端可控输入,是可以直接打出 RCE 的。
但参数维度有一个安全边界 ,上一篇提过,这里必须再说一遍:当表达式结果由客户端可控输入派生时,每个新取值都从满配额开始 。攻击者遍历一万个不同的 id,等于拥有一万个独立 10 次配额------所以:
- 参数维度的正确用途是租户间公平性(防止一个租户耗尽共享配额);
- 它不能作为防爆破等安全边界使用;
- 防爆破应叠加接口级总配额,或者用下面的
RateLimitKeyResolver组合 IP 等低基数维度。
三、正餐:一个 Bean,四种真实业务的键玩法
当 SpEL 搞不定(需要请求头、安全上下文、组合维度)时,就该上 RateLimitKeyResolver 了。它是 @FunctionalInterface,注册成 Bean 就自动覆盖默认实现:
java
@FunctionalInterface
public interface RateLimitKeyResolver {
String resolve(FilterContext context);
}
约定:相同键共享同一份配额,不同键互不相干。返回值就是 Redis 里的 key,因此本地/Redis 限流都生效。
玩法 1:按用户限流(VIP 与恶意用户分开算)
从请求头或安全上下文取用户 ID,键追加 #user: 后缀:
java
@Bean
public RateLimitKeyResolver userRateLimitKeyResolver() {
return context -> {
String userId = context.getAttribute("X-User-Id", "anonymous");
// 匿名用户共用一个键(兜底),登录用户各自独立
return context.getApiKey() + "#user:" + userId;
};
}
效果:user:42 刷到第 101 次被拒,user:43 第一次还能放------接口级总配额和用户级配额各自独立,恶意用户刷不完别人的。
注意 :不要把整个接口都切到纯用户级。如果用户 ID 不存在时也要限流,一定要给匿名流量一个独立键(上面用了 "anonymous"),否则匿名流量会共享全局配额,等于退回了接口级。
玩法 2:按 IP 限流(登录接口防爆破)
登录、注册、找回密码这类接口,需要的是"同一个 IP 每秒最多试几次"。IP 从上下文拿(框架已按 X-Forwarded-For → X-Real-IP → remoteAddr 解析好):
java
@Bean
public RateLimitKeyResolver ipRateLimitKeyResolver() {
return context -> context.getApiKey() + "#ip:" + context.getClientIp();
}
但这里有个坑,必须说出来 :clientIp 可以被 X-Forwarded-For 伪造。按 IP 限流可以用来"挡懒虫"(正常用户不会伪造 IP),但不能把它当安全边界------把按 IP 限流当唯一防线,攻击者换一行 header 就绕过了。防爆破应该是「接口级总配额 + IP 级配额」的组合:接口级保证整个登录接口不崩,IP 级挡住单个 IP 的爆破尝试。
更稳妥的组合键:
java
@Bean
public RateLimitKeyResolver loginBruteForceResolver() {
return context -> {
String ip = context.getClientIp();
// 接口级总配额(ip = null 时共享)+ 单 IP 独立配额,防穿透
return context.getApiKey() + (ip == null ? "" : "#ip:" + ip);
};
}
玩法 3:按租户限流(多租户公平性)
这是「按参数限流」的正确归宿------拿租户 ID 作为键的一部分,让租户之间互不影响:
java
@Bean
public RateLimitKeyResolver tenantRateLimitKeyResolver() {
return context -> {
String tenantId = context.getAttribute("X-Tenant-Id", "default");
return context.getApiKey() + "#tenant:" + tenantId;
};
}
关键区别 :租户 ID 通常是服务端签发的(JWT 里带的、网关写进 header 的),不像 SpEL 参数那样客户端可控------所以它可以作为安全边界使用。租户 A 刷到配额用完,租户 B 正常访问,这正是"公平性"的本意。
但要提醒一点:高基数键(租户多时)要注意本地限流器的 max-entries 上限,超限会淘汰旧键------如果你按租户限流,一定要评估租户数量会不会超过这个上限,超限的租户键被淘汰后等于"重新满血",配额就失效了。
玩法 4:组合维度------「接口 + 用户 + 租户」
真实业务常常是多维的。键本身没有结构限制,你可以任意拼接:
java
@Bean
public RateLimitKeyResolver compositeRateLimitKeyResolver() {
return context -> {
String userId = context.getAttribute("X-User-Id", "anon");
String tenantId = context.getAttribute("X-Tenant-Id", "default");
// 同一用户在同一租户下访问同一接口,才共享配额
return context.getApiKey() + "#tenant:" + tenantId + "#user:" + userId;
};
}
组合维度唯一要注意的:键越细,内存/Redis 里的 key 越多 。本地限流的 max-entries 和 Redis 的内存规划都要按"键的数量 = 接口数 × 用户数 × 租户数"的乘积来评估,别到线上才发现键爆了。
四、附带福利:限流拒绝响应头
从 0.5.x 开始,被限流的请求会自动带上 IETF 草案的 RateLimit-* 标准响应头:
makefile
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 60
Retry-After: 60
客户端可以据此做智能重试(等到 Retry-After 秒后再试),不用解析文本错误信息。想自定义响应头或响应体,注册 RateLimitRejectHandler Bean 即可覆盖(它抛出异常会自动回退默认行为,不影响短路语义)。
五、选型速查:哪种业务该用哪个维度
| 业务场景 | 推荐维度 | 实现方式 | 安全边界? |
|---|---|---|---|
| 接口防刷、防 QPS 击穿 | 接口级 | 默认 @RateLimit(limit=...) |
✅ 是 |
| 不同参数独立配额 | SpEL 参数级 | @RateLimit(key = "#id") |
❌ 否(仅公平性) |
| 登录/注册防爆破 | 接口级 + IP 级组合 | RateLimitKeyResolver |
⚠️ IP 可伪造,需组合 |
| VIP 用户与爬虫分开算 | 用户级 | RateLimitKeyResolver |
⚠️ 匿名用户要兜底 |
| 多租户公平性 | 租户级 | RateLimitKeyResolver |
✅ 是(租户 ID 服务端签发) |
| 三者组合 | 接口+用户+租户 | RateLimitKeyResolver |
视组合而定 |
六、写在最后
限流从"注解调阈值"到"解析器调维度",是治理能力从"能用"到"敢上生产"的分水岭。维度选错,阈值调得再准也白搭------接口级是兜底,参数级是公平,用户级是区分,租户级是隔离。
完整代码和文档:👉 github.com/BIGLV666/ap...
评论区聊聊:你们线上用的什么限流维度?有没有踩过"维度选错"的坑?下一篇打算拆告警风暴抑制------同一个接口被限流一万次,怎么保证钉钉群里只收到一条消息,关注不迷路。