Spring 接口限流,我最后还是没上 Guava

举报
yd_241780069 发表于 2026/11/04 15:41:08 2026/11/04
【摘要】 起因有个查询接口被爬了。不是恶意攻击,对方就是慢慢爬,但频率太高,数据库扛不住,其他用户的请求开始超时。查日志发现同一个 IP 一分钟调了几百次,正常用户根本用不到这个量。第一反应是在网关加限流,但我们网关是运维那边管的,走流程排期得等一周。等不了,先在应用层自己兜一下。 先试了本地缓存最省事的方案,ConcurrentHashMap 按 IP 计数,一分钟内超过阈值就拒。单机测没问题,上...

起因

有个查询接口被爬了。不是恶意攻击,对方就是慢慢爬,但频率太高,数据库扛不住,其他用户的请求开始超时。查日志发现同一个 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 分家。临界问题看场景,我的场景可以忍,你的场景如果忍不了,趁早换算法。

先记这些。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。