匿名刷子、批量评测、付费用户挤在同一套接口上,限流必须先分清楚”限谁”。没有限流的日子里,每次评测榜单在群里被转发,瞬时流量就能把机房出口 IP 打到被云厂商封禁,整站瘫痪。当时我对比了网关层限流和应用层限流两条路线,最终没有在网关设单一阈值,而是选择在 Spring Boot 应用内实现一套基于注解 + Redis 的限流方案,按维度分档、配置即用。下面把整个实现过程和踩过的坑完整拆开讲。
一、限流维度
一开始我图省事,只做了一条全局限流,结果发现完全不顶用:阈值设高,刷子拦不住;设低,付费用户跑批量评测直接被误伤,投诉一堆。后来我把”限谁”拆成四个维度,每个维度独立计数,任意一档超限都拒绝。这也解释了为什么单纯在 Nginx 配 limit_req 不够——它只能按 IP 或 URL 粗粒度限制,拿不到”这个请求背后是哪个用户”的信息,评测平台的场景必须依赖应用层的用户上下文。
下面这张表是平台实际用的四档配置,覆盖匿名、注册、付费三类的差异化策略:
| 维度 | 用途 |
|---|---|
| IP 限流 | 防匿名刷接口(如 100 次/分钟/IP) |
| 用户限流 | 防止单个用户脚本狂调(如 60 次/分钟) |
| 接口限流 | 保护昂贵接口(如评测提交 5 次/分钟) |
| 全局限流 | 兜底防 DDoS(如 1 万次/秒) |
四档叠加时,只要任意一档超限就直接拒绝,各档之间互不干扰。评测提交接口因为要调大模型、单次成本高,单独压到 5 次/分钟,这种精细度是单一阈值做不到的。这里有个 IP 档的坑:公司、学校出口往往共用一个公网 IP,阈值给太小会把一屋子正常用户一起限死,我后来把 IP 档放宽到 100 次/分钟,再靠用户档做精准拦截。
二、注解 + 拦截器
定下维度之后,接下来是落地形式。评估阶段我对比过三种写法:Filter、拦截器、注解 + AOP。Filter 拿不到方法级信息,想按某个 Controller 方法单独限流就得写一堆路径匹配规则,接口一多维护成本直接失控;拦截器能拿到 HandlerMethod,但每个接口都要手动注册一遍。注解方案直接把限流维度、次数、窗口写死在方法上方的 @RateLimit 里,新增接口顺手标一行就行,删接口也不会留下残留配置,团队协作时看代码一眼就能明白”这个接口限了没、限多少”。
这是限流注解的定义,四个属性对应”限谁、限几次、限多久”三要素:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key() default ""; // 自定义 key 前缀
int count() default 60; // 窗口内最大次数
int window() default 60; // 窗口秒数
LimitType type() default USER; // 限流维度
}
enum LimitType { IP, USER, GLOBAL, CUSTOM }
注解定义好之后,真正干活的拦截逻辑交给 AOP 切面,细节在第五节。有个小坑:LimitType 枚举和注解写在同一文件里能省一个类文件,但反射处理枚举时容易和别的注解处理逻辑纠缠,建议把枚举独立成文件,代码更干净。
三、Redis 滑动窗口实现
维度有了、注解有了,剩下就是计数逻辑放哪。集群部署下服务实例不止一台,每台各自在 JVM 内存里计数,总量会被放大成”实例数 × 单机阈值”,限流形同虚设。用 Redis 集中计数能全局统一,但并发下”读计数 → 判断 → 写回”三步如果拆成三条命令发出去,会出现竞态:两个请求同时读到 59,都判定未超限一起放行。把三步包进一个 Lua 脚本原子执行,这个窗口就补上了。
这段 Lua 是核心,用 ZSET 存每次请求的时间戳,实现窗口内的精确计数:
-- KEYS[1]=限流 key, ARGV[1]=count, ARGV[2]=windowMs, ARGV[3]=nowMs
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local cur = redis.call('ZCARD', key)
if cur >= limit then
return 0
end
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('PEXPIRE', key, window)
return 1
Java 端调用时把当前毫秒时间戳传进去,注意必须用服务器时间而不是客户端时间,否则多实例时钟不一致,判定会错乱。ZADD 的成员拼了随机数,是为了防止同一毫秒两个请求因成员相同被当成一次处理:
public boolean tryPass(String key, int count, int windowSec) {
long now = System.currentTimeMillis();
Long ok = redisTemplate.execute(limitScript,
List.of(key), count, windowSec * 1000, now);
return ok != null && ok == 1L;
}
脚本返回 0 表示超限,Java 端拿到后直接抛异常,调用方收到 429 状态码。这个脚本还有个附带好处:Redis 6 及以上自带脚本缓存,EVALSHA 命中后性能开销几乎可以忽略。
四、四种限流算法对比
选型时我把四种常见算法都过了一遍,各自适用场景差得很远。固定窗口实现最简单,但窗口边界会突刺,比如每分钟 60 次,第 59 秒时放行 60 次,下一秒又是一个新窗口再放行 60 次,瞬时冲到 120 次。评测平台的评测任务往往是集中发起的,对突刺特别敏感,固定窗口直接排除。漏桶把流量排成匀速输出,适合保护下游慢接口,但评测场景要的是”窗口内最多 N 次”这种硬语义,也不贴合。
下面从精度、内存、实现复杂度、适用场景四个维度做对比:
| 算法 | 精度 | 内存 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 固定窗口 | 低(边界突刺) | 极低 | 极低 | 简单粗粒度 |
| 滑动窗口 | 高 | 中 | 中 | 通用首选 |
| 漏桶 | 中 | 低 | 中 | 流量整形 |
| 令牌桶 | 中 | 低 | 中 | 允许突发 |
令牌桶允许短时突发,适合”平时省着用、峰值多打一点”的 API 配额场景,但评测平台要的是硬性”窗口内最多 N 次”,所以最终选了滑动窗口。滑动窗口的内存开销来自 ZSET 里的时间戳成员,窗口越长、QPS 越高节点越多,必须配合窗口外的清理逻辑和过期时间,否则内存会慢慢涨上去。
五、关键路径拦截
注解和算法都就绪后,拦截点放在哪直接决定方案的易用性。我最初想过在 Controller 方法里手动调用限流服务,结果几十个接口逐个写判断,代码重复还容易漏标。改成 AOP 切面统一拦截 @annotation(rateLimit) 后,所有标了注解的方法自动生效,新增接口只加一行注解即可,限流逻辑和业务逻辑彻底分离。
这个切面是执行入口,先按维度拼 key,再调用限流服务判定:
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) {
String key = buildKey(rateLimit, currentUserId(), currentIp());
boolean pass = rateLimitService.tryPass(key, rateLimit.count(), rateLimit.window());
ThrowUtils.throwIf(!pass, ErrorCode.TOO_MANY_REQUESTS, "调用过于频繁");
return pjp.proceed();
}
buildKey 根据 LimitType 拼不同前缀,同维度的不同方法天然隔离:
- IP:
rate:ip:{ip}:{method} - USER:
rate:user:{userId}:{method} - GLOBAL:
rate:global:{method}
这里有个必须强调的坑:AOP 默认拦不住同类内部调用,this.call() 根本不会走切面,注解等于白标。我后来把限流检查统一放在 Service 层,由 Controller 调 Service,问题就消失了。
六、降级与兜底
限流组件自身也是系统的一部分,Redis 挂掉时既不能简单放行——否则瞬时流量直接打穿后端;也不能全部拒绝——否则正常业务全停。我设计的策略是降级到本地令牌桶,每实例每秒 1000 个配额,牺牲一点精度换整体可用性。这个兜底虽然不精确,但极端情况下能保证服务不雪崩,等 Redis 恢复后自动切回精确计数。
除了 Redis 降级,还有几个配套策略一起上线:大模型调用接口因为成本高单独收紧;内部探活和管理员走白名单;超限响应带 Retry-After 头引导前端退避。这里有个细节值得注意——白名单只放行内部 IP 和管理员 token,绝不能放行所有带某个特定 Header 的请求,否则刷子伪造 Header 就能绕过限流。
- Redis 异常 → 降级到本地令牌桶(每实例每秒 1000 个),保证可用性。
- 大模型调用接口独立限流:评测提交走更严格的 5 次/分钟(成本高)。
- 白名单机制:内部探活 IP、管理员 token 走白名单,不参与限流。
- 限流响应携带 Retry-After 头:告诉前端多久后可以重试,避免雪崩重试。
HTTP/1.1 429 Too Many Requests
Retry-After: 30
{ "code": 42900, "message": "调用过于频繁,请 30 秒后重试" }
前端收到 429 后按 Retry-After 的秒数倒计时再试,而不是立刻疯狂重试,这是避免二次雪崩的关键。上线后压测验证过:Redis 宕机 30 秒内,接口可用率仍保持在 99% 以上,全部请求由本地令牌桶承接。
七、踩过的坑
方案上线后陆续踩了几个坑,都记录在这里,供后来维护的同学避雷。其中注解失效和 Lua 成员重复是线上真实出过问题的,其余是压测阶段发现的。
- 注解失效:同类内
this.call()不走 AOP,要么注入自身 bean 调用,要么下沉到 Service。 - Lua 脚本 ZADD 成员重复:用
now + ':' + random做成员,防止同 ms 并发只算一次。 - 分布式时间不同步:用服务器时间(不是客户端),多实例时钟差 < 50ms 时影响可忽略。
- Spring Cloud Gateway 限流与本注解重复:网关层用 Sentinel 限 IP/总入口,本注解做用户级精细限流,两层互不冲突。
- 秒杀场景下 ZSET 膨胀:高 QPS 接口 ZSET 节点很大,窗口外的数据必须
ZREMRANGEBYSCORE及时清掉,平台加PEXPIRE自动过期。
ZSET 膨胀这个坑尤其值得展开:线上有个榜单接口 QPS 到了几千,ZSET 里累积的成员数以万计,清理不及时 Redis 内存肉眼可见地涨。给每个 key 加 PEXPIRE 让不活跃的 key 自动消失,再配合窗口外的 ZREMRANGEBYSCORE,内存曲线才稳定下来。到这里,这套从维度设计到降级兜底的限流链路就完整了,后续新接口上线只需要遵循”加注解、定维度”两件事。
常见问题(FAQ)
Q1:为什么不用 Sentinel 或 Resilience4j?
它们主要做线程级隔离和熔断,分布式限流仍要 Redis,自己实现 Lua 脚本 30 行更可控。Sentinel 适合网关层做粗粒度。
Q2:滑动窗口与漏桶怎么选?
滑动窗口适合”窗口内最多 N 次”的语义,限流是硬规则。漏桶适合”平滑输出”的语义,例如写第三方 API。评测平台要硬规则,选滑动窗口。
Q3:用户级别限流 key 怎么设计?
rate:user:{userId}:{methodName},methodName 来自切面反射拿到的 pjp.getSignature().getName(),不同接口独立计数。