Spring 接口限流,我最后还是没上 Guava
起因
有个查询接口被爬了。不是恶意攻击,对方就是慢慢爬,但频率太高,数据库扛不住,其他用户的请求开始超时。查日志发现同一个 IP 一分钟调了几百次,正常用户根本用不到这个量。
第一反应是在网关加限流,但我们网关是运维那边管的,走流程排期得等一周。等不了,先在应用层自己兜一下。
先试了本地缓存
最省事的方案,ConcurrentHashMap 按 IP 计数,一分钟内超过阈值就拒。单机测没问题,上线之后发现两个实例各算各的,一个用户打两次请求,可能分别落到两个实例上,每个实例看到的次数都只有实际的一半。阈值形同虚设。
本地限流在单机场景下够用,多实例必须换分布式方案。这个前面防重提交那篇也提过,一个道理。
最后用的 Redis 固定窗口
没上 Guava RateLimiter,也没引入 Bucket4j。项目里已经有 Redis,加个 INCR 和 EXPIRE 就够了:
public boolean isAllowed(String key, int limit, int windowSec) {
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1) {
redisTemplate.expire(key, windowSec, TimeUnit.SECONDS);
}
return count != null && count <= limit;
}
这就是固定窗口计数器,实现最简单,性能也最好。压测数据说能跑到十几万 QPS,对我们这个量级绰绰有余。
缺点也很明确:临界问题。窗口边界那一秒,可能涌进来接近两倍的请求量。比如限一分钟 100 次,用户在 0:59 打了 100 次,1:00 又打 100 次,中间只隔了一秒,但两个窗口各自都没超限,全部放行。
我这个场景是防爬,不是防秒杀,临界突刺的影响不大。真要精确控制,得上滑动窗口,用 ZSET 存时间戳,但内存和性能代价都上去了。
抽成注解之后好用了点
一开始每个接口里手动调 isAllowed,写多了也烦。抽了个 @RateLimit 注解,AOP 切面里解析 key:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int limit() default 100;
int window() default 60;
String key() default "";
}
切面里从方法参数或者 SpEL 表达式拿业务标识,拼上接口路径做 Redis key。比如按用户 ID 限,key 就是 rate:userId:/api/query。用 AOP 的好处是 Controller 里干干净净,跟之前防重提交那套注解是一个思路。
几个实际撞上的问题
第一,INCR 和 EXPIRE 不是原子的。 上面那段代码,如果 INCR 之后应用挂了,EXPIRE 没执行,这个 key 就永不过期,计数一直涨,用户被永久限流。概率不高,但真发生了就是大问题。稳妥的做法是用 Lua 脚本把两步包在一起,Redis 单线程执行,保证原子性。我后来改了 Lua,虽然代码丑了点,但心里踏实。
第二,key 的设计得想清楚。 按 IP 限流,办公室几十号人共用一个出口 IP,一个人狂刷,全公司遭殃。按用户 ID 限,未登录的请求没有 ID,只能退化回 IP。我现在是登录用户按 userId,未登录按 IP 加一个更宽松的阈值,两套规则分开跑。
第三,返回 429 的时候最好带上 Retry-After。 客户端拿到 429 直接重试,限流窗口还没过,又撞回来,循环几次可能触发更严格的规则。在 response header 里带一个剩余秒数,稍微体面一点。
和网关限流的分工
应用层限流是兜底,不是替代网关。网关限流在流量进来的第一道关口就把异常流量掐掉,应用层根本不用处理那些请求。应用层限流管的是网关没拦住的部分,比如内部服务调用、或者网关规则没覆盖到的接口。
我现在这个项目,网关的限流规则还在走流程,应用层的 @RateLimit 先顶着。等网关配好了,应用层这套也不拆,留着当第二道防线。毕竟网关挂了或者规则漏配的时候,应用层还能保命。
小结
单机用本地缓存,多实例用 Redis,防爬用固定窗口够了,要精确就滑动窗口。注解加 AOP 是最终形态,但先把 Redis 的原子性保住,别让 INCR 和 EXPIRE 分家。临界问题看场景,我的场景可以忍,你的场景如果忍不了,趁早换算法。
先记这些。
- 点赞
- 收藏
- 关注作者
评论(0)