把限流从「注解」做活:RateLimitKeyResolver 四种真实业务的键玩法

前两篇搭好了管道和插件机制,这篇把限流从"能调阈值"推进到"能调维度":默认按接口限流,四行注解按参数限流,一个 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...

评论区聊聊:你们线上用的什么限流维度?有没有踩过"维度选错"的坑?下一篇打算拆告警风暴抑制------同一个接口被限流一万次,怎么保证钉钉群里只收到一条消息,关注不迷路。


相关推荐
小白的码BUG之路1 小时前
Docker -- 基本命令
java·docker·eureka
一帅1 小时前
InDy Advice:用一行 invokedynamic,把 Agent 藏进"平行宇宙"
后端
inhere1 小时前
miglite v0.8.0:迁移文件可以嵌进二进制了
后端
geovindu1 小时前
rust: tree
开发语言·后端·rust
imDwAaY1 小时前
Bean的生命周期
java·笔记·后端·学习·spring·dubbo
不停喝水1 小时前
【前端转全栈java速通课】 项目实战④-1 Spring Boot 创建项目-定义接口-请求参数处理-分层规范-依赖注入
java·前端·spring boot
上下求索,莫负韶华1 小时前
Spring全家桶
java·后端·spring
行百里er1 小时前
Redis 性能优化——内存、慢查询、Big Key 与 Hot Key
redis·后端
w_zero_one2 小时前
链表(2)
java·数据结构·链表