1. 先聊清楚:这个“限速注解”到底解决了什么问题
做后端接口开发的时候,限速是个绕不开的话题。尤其是面向公网的业务接口,一旦遇到突发流量、爬虫脚本、或者某个调用方写了个有问题的重试循环,服务端的压力瞬间就能被打满。轻则接口响应变慢,重则数据库连接池耗尽、应用直接雪崩。
常规做法无非几种:网关层限流、Nginx 层限流、中间件限流(比如 Sentinel),但这些东西都有一个共同的问题——它们都是“全局”的,或者说“按路由”的,没法精细到“某个用户的某个操作最多每秒几次”。而且引入一套独立的限流中间件,在小团队、小项目里往往意味着额外的运维成本和架构复杂度。
那有没有一种方式,能让限流这件事像@Transactional一样简单:往方法上一标,就自动生效?这就是这套“一个注解搞定网络限速”方案的出发点。核心思路是自定义注解 + Spring 拦截器(或 AOP 切面) + 分布式计数器存储,把限速逻辑从业务代码里彻底剥离出去。业务方不用关心限流怎么实现,只需要知道自己这个接口允许什么频率的访问,然后在方法上加一个注解、填好参数即可。
这篇文章我会从零开始把整个方案拆开讲清楚:怎么设计注解参数、为什么用拦截器而不是过滤器、计数器存本地还是存 Redis、不同维度(按 IP / 按用户 / 按接口)怎么做、超限之后返回什么、以及生产环境容易踩哪些坑。无论你是刚接触 Spring Boot 的初学者,还是已经在写中间件的老手,这套方案都能直接落地到项目里。
2. 整体方案设计:为什么是“注解 + 拦截器 + Redis”
2.1 技术选型:为什么不选 Filter,也不选 AOP
先回答一个很多人会问的问题:实现限速,用 Filter(过滤器)、Interceptor(拦截器)、AOP(切面)到底有什么区别?
很多网上的 demo 喜欢用 Filter 来做,因为 Filter 是 Servlet 规范层面的东西,Spring Boot 里注册一个FilterRegistrationBean就行。但 Filter 有一个很尴尬的问题:它拿不到 HandlerMethod 的信息。也就是说,在 Filter 里你根本不知道当前请求最终会进到哪个 Controller 的哪个方法,自然也就没法判断“这个方法上有没有加限速注解”。你要做的话,只能按 URL 前缀去匹配,那就退化成了粗粒度的路由限流。
AOP 能做,但有一个隐藏的坑:切面默认只拦截 Spring 管理的 Bean 的方法调用,而 Controller 方法恰恰不走代理场景的时候容易踩坑。更麻烦的是,如果你对 Controller 层用了 AOP,方法内部自调用、或者某些异步调用会导致切面失效。虽然可以通过@EnableAspectJAutoProxy(exposeProxy = true)之类的手段绕过去,但平白增加了理解成本。
Interceptor 是三者中最合适的选择。它在 HandlerMapping 确认了目标 Handler 之后执行,能轻松拿到HandlerMethod,进而拿到方法上的自定义注解;同时preHandle方法在 Controller 执行之前运行,天然适合做“要不要放行”的决策。Spring MVC 本身就是这么设计的,用起来最顺手。
2.2 限速算法:固定窗口还是滑动窗口
限速算法的选择上,我见过不少人一上来就写令牌桶,其实很多时候是杀鸡用牛刀。令牌桶(Token Bucket)的优势是允许一定的突发流量,适合对突发有要求的场景;但它的实现相对复杂,需要维护令牌的补充速率,在分布式环境下还要考虑原子性问题。
这套方案我推荐优先做固定窗口计数,原因很简单:大多数业务接口的限速需求是“每秒最多 N 次”“每分钟最多 M 次”,这种语义用固定窗口直接对应,实现也最简单。Redis 里面一个 key 就搞定,INCR + EXPIRE组合起来是原子的(因为 INCR 是单命令),不会有并发覆盖的问题。
固定窗口唯一的问题是临界突变。比如窗口是 60 秒,你允许 100 次,那么在 00:59 和 01:01 这两秒内,理论上可能各进来 100 次,形成 2 秒内 200 次的“穿墙”。但绝大多数业务场景里,这个概率和影响都小到可以忽略。真遇到不能接受的场景,再把窗口缩小到 1 秒或者升级成 Lua 脚本实现的滑动窗口即可,后面我会给出滑动窗口的替换方案。
2.3 存储选型:本地 ConcurrentHashMap 还是 Redis
这一步取决于你的应用是单机部署还是多机部署。
- 单机版:直接用
ConcurrentHashMap记录每个 key 的窗口计数。没有网络开销,性能极高,但多实例部署时限速是各自的,等于没限。 - 分布式版:用 Redis。每次请求一次
INCR,网络开销大约零点几毫秒,对于一个接口来说完全可接受。多实例之间共享计数,才能真正做到整个服务维度的限速。
我的建议是:代码里把“计数器”抽象成一个接口,默认提供 Redis 实现和本地内存实现,通过配置项切换。这样开发环境不用依赖 Redis 也能跑,生产环境切到 Redis 即可。后面我会把代码贴出来。
3. 核心实现:从零手写限速注解
3.1 定义注解:参数怎么设计才够用
先看注解的定义。这是整个方案的“门面”,参数设计得好不好,直接决定业务方用起来顺不顺手。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RateLimit { /** * 限流维度:按什么区分调用方 */ Dimension dimension() default Dimension.IP; /** * 时间窗口大小,默认 1 秒 */ long time() default 1L; /** * 时间窗口单位,默认秒 */ TimeUnit unit() default TimeUnit.SECONDS; /** * 窗口内允许的最大请求次数 */ long count() default 100L; }再定义一个维度枚举:
public enum Dimension { /** 按客户端 IP 维度限流 */ IP, /** 按用户维度限流,需要从上下文中获取用户 ID */ USER, /** 按接口方法维度限流,接口整体限流 */ METHOD }解释一下每个参数的设计意图:
dimension:决定 key 怎么拼。IP 维度最简单,从HttpServletRequest.getRemoteAddr()取;USER 维度适合登录后的接口,从SecurityContext或 ThreadLocal 里拿用户 ID;METHOD 维度是接口整体限流,适合防刷。time+unit:合成一个窗口长度。拆成两个字段而不是直接用毫秒数,是出于可读性考虑。业务方写time = 1, unit = TimeUnit.MINUTES比写time = 60000直观得多。count:窗口内允许的最大次数。这个值设多大完全取决于业务场景,比如验证码接口可以严格一点,读接口可以宽松一点。
我见过有人把注解设计成@RateLimit(rate = "1/100s")这种字符串解析的样式,我觉得没必要。字符串解析虽然写法上很炫,但增加了出错概率和阅读成本,编译期也没法检查,不如用明确的字段。
3.2 定义计数器接口:本地和 Redis 实现随意切换
这一步是为了让方案不依赖写死的 Redis,也方便你后续替换成其他的存储中间件。
public interface RateLimitCounter { /** * 对指定 key 做自增,并设置过期时间 * * @param key 计数器 key * @param windowMs 窗口大小(毫秒) * @return 自增后的计数 */ long incrementAndGet(String key, long windowMs); }本地实现用ConcurrentHashMap:
@Component @ConditionalOnProperty(name = "rate.limit.storage", havingValue = "local", matchIfMissing = true) public class LocalRateLimitCounter implements RateLimitCounter { private final ConcurrentHashMap<String, LocalCounter> cache = new ConcurrentHashMap<>(); @Override public long incrementAndGet(String key, long windowMs) { long now = System.currentTimeMillis(); LocalCounter counter = cache.computeIfAbsent(key, k -> new LocalCounter()); counter.cleanExpired(now); return counter.increment(now); } private static class LocalCounter { private long windowStart = System.currentTimeMillis(); private long count = 0L; private synchronized void cleanExpired(long now) { if (now - windowStart >= windowSize) { windowStart = now; count = 0L; } } private synchronized long increment(long now) { count++; return count; } } }注意这里用了synchronized来保证线程安全,因为固定窗口需要“先清理过期窗口再自增”两步,单纯的 ConcurrentHashMap 的compute操作没法一次性完成这种复合逻辑。本地实现主要服务开发环境,性能不需要极致,但正确性必须保证。
Redis 实现则简单得多:
@Component @ConditionalOnProperty(name = "rate.limit.storage", havingValue = "redis") public class RedisRateLimitCounter implements RateLimitCounter { private final StringRedisTemplate redisTemplate; public RedisRateLimitCounter(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } @Override public long incrementAndGet(String key, long windowMs) { Long count = redisTemplate.opsForValue().increment(key, 1L); if (count != null && count == 1L) { // 第一次自增时设置过期时间,单位是毫秒 redisTemplate.expire(key, windowMs, TimeUnit.MILLISECONDS); } return count == null ? 0L : count; } }生产环境没有任何锁竞争,每个请求一次 Redis INCR 命令,时间复杂度 O(1)。因为第一次 INCR 的值一定是 1,只有这个时候才需要调用 EXPIRE 设置过期时间,其余请求直接自增返回即可。
3.3 拦截器核心:拿到注解并判定是否超限
下面是整个方案最核心的部分——拦截器实现。
@Component public class RateLimitInterceptor implements HandlerInterceptor { private static final String KEY_PREFIX = "rate_limit:"; private static final String ERROR_MSG = "请求过于频繁,请稍后再试"; private final RateLimitCounter counter; public RateLimitInterceptor(RateLimitCounter counter) { this.counter = counter; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 非 Controller 方法直接放行,比如静态资源 if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class); if (rateLimit == null) { return true; } // 拼接限流维度 key String dimensionKey = buildDimensionKey(rateLimit.dimension(), request); String windowKey = KEY_PREFIX + handlerMethod.getBeanType().getSimpleName() + "_" + handlerMethod.getMethod().getName() + ":" + dimensionKey; long windowMs = rateLimit.unit().toMillis(rateLimit.time()); long currentCount = counter.incrementAndGet(windowKey, windowMs); if (currentCount > rateLimit.count()) { response.setStatus(429); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":429,\"msg\":\"" + ERROR_MSG + "\"}"); return false; } return true; } private String buildDimensionKey(RateLimit.Dimension dimension, HttpServletRequest request) { return switch (dimension) { case METHOD -> "method"; case USER -> { // 这里根据你项目的用户上下文实现来取,比如 SecurityContextHolder Object userId = request.getAttribute("currentUserId"); yield userId == null ? "anonymous" : String.valueOf(userId); } case IP -> getClientIp(request); }; } private String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isBlank() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } else { // X-Forwarded-For 可能包含多个 IP,取第一个即可 ip = ip.split(",")[0].trim(); } return ip; } }代码里有两个细节值得注意。
第一个是 key 的设计。handlerMethod.getBeanType().getSimpleName() + "_" + handlerMethod.getMethod().getName()这一段用来区分不同接口。如果你不加这一段,那么只要方法名相同,即使 Controller 类不同,也会共用一个计数器,这显然不对。加了类名之后,每个接口的限流是独立的,语义更清晰。
第二个是 IP 的获取。生产环境通常经过 Nginx 反向代理,直接getRemoteAddr()拿到的是 Nginx 的地址,不是客户端真实 IP。所以必须优先取X-Forwarded-For头,而且要处理多级代理的情况——这个头可能是一串 IP,用逗号分隔,取第一个才是真实客户端。另外补充一句:如果使用了X-Real-IP,也可以作为备选方案,不同团队习惯不同,但原理一致。
3.4 注册拦截器:排除路径和顺序问题
拦截器写了还得注册才生效。在 Spring Boot 里通过WebMvcConfigurer完成:
@Configuration public class WebConfig implements WebMvcConfigurer { private final RateLimitInterceptor rateLimitInterceptor; public WebConfig(RateLimitInterceptor rateLimitInterceptor) { this.rateLimitInterceptor = rateLimitInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(rateLimitInterceptor) .addPathPatterns("/**") .excludePathPatterns("/health", "/actuator/**"); } }这里有个容易犯的错:限速拦截器一定不要拦截静态资源和健康检查。/health、/actuator/**如果不排除,监控系统的探活请求也会被计数,导致误拦截。而静态资源的访问走的是另一套 handler,不是 HandlerMethod,拦截器里虽然做了判断直接放行,但能排除还是尽量排除,减少无谓逻辑。
3.5 效果演示:一个注解的“魔法”现场
写了这么多,来看一个实际使用的完整例子。假设我们有一个查询用户信息的接口,希望限制每个 IP 每秒钟只能访问 10 次:
@RestController @RequestMapping("/api/user") public class UserController { @GetMapping("/info") @RateLimit(dimension = Dimension.IP, time = 1, unit = TimeUnit.SECONDS, count = 10) public Result<UserInfo> getUserInfo(@RequestParam Long userId) { // 业务逻辑 return Result.ok(userService.getUserInfo(userId)); } }然后用curl模拟 13 次连续请求,看看效果:
for i in {1..13}; do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/api/user/info?userId=1 done上面的循环连续发 13 次请求,前 10 次正常返回 200,从第 11 次开始,系统直接返回 429,意味着限速生效。这就是“一个注解搞定”的全部过程,业务代码零侵入,一个字符的业务逻辑都不用改。
4. 进阶功能与扩展方案
4.1 注解参数加一个 fallback 方法,超限不走默认响应
固定写死 JSON 返回体对于很多团队来说不够灵活。有的团队希望超限时返回一个自定义的错误页面,有的希望切换到降级逻辑,比如返回缓存数据。这时候可以在注解里增加一个fallback参数:
public @interface RateLimit { // 其他参数省略 String fallback() default ""; }然后在拦截器中检查:如果超限且fallback非空,就用 Bean 名称解析对应的方法执行降级逻辑。这个实现需要借助ApplicationContext拿到 bean,再用反射调用对应方法。反射性能开销不大,因为限速本身就是低频异常分支,不影响正常请求路径。
4.2 升级滑动窗口:彻底干掉临界问题
前面提到过固定窗口在临界点的“穿墙”问题。如果你的业务真的不能接受这种毛刺,可以把 Redis 实现替换成 Lua 脚本,用 ZSET 实现滑动窗口:
local key = KEYS[1] local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local limit = tonumber(ARGV[3]) redis.call('ZREMRANGEBYSCORE', key, 0, now - window) local count = redis.call('ZCARD', key) if count < limit then redis.call('ZADD', key, now, now .. '-' .. math.random(1000000)) redis.call('PEXPIRE', key, window) return 1 else return 0 end这段脚本的核心是:每次请求到达时,先移除窗口之外的所有记录,然后统计窗口内还有多少条记录,如果没到上限就加入新记录并返回 1,否则返回 0。ZSET 的 score 用毫秒时间戳,member 用“时间戳-随机数”保证唯一。原子性由 Lua 脚本天然保证,Redis 单线程执行整个脚本,无需担心并发问题。
4.3 按用户限流时,如何优雅获取用户 ID
从request.getAttribute("currentUserId")取用户 ID 是我在上面示例代码里的临时方式,实际项目中一般会有现成的用户上下文方案。如果你用的是 Spring Security,可以这样改:
case USER -> { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (principal instanceof UserDetails userDetails) { yield userDetails.getUsername(); } yield "anonymous"; }如果你用的是 Sa-Token 之类的框架,取登录用户的方式类似,无非是从StpUtil.getLoginId()里取。核心思想一致:从你的认证体系中取出唯一标识用户的值,拼进 key 里。
4.4 注解 + AOP 的方式,什么时候更合适
前面我强烈推荐了 Interceptor,但有一种场景 AOP 更合适:你想限制的不是 Web 请求,而是某个 Service 方法的调用频率。举个例子,你有一个消息推送方法,上游系统可能会误调用,你想给它加上限速,这个方法和 HTTP 控制器完全无关,用 Interceptor 就够不到。这种时候可以基于同一个注解写一个 AOP 切面逻辑,思路完全一样,只是把“从 request 中取维度 key”换成“从方法参数中取”。
不过坦白讲,绝大多数业务限速需求都发生在 Web 入口,Interceptor 是首选。
5. 生产环境踩坑实录:这些坑我全替你踩过了
5.1 常见问题速查表
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 加了注解但不生效 | 拦截器没有注册到 WebMvcConfigurer | 检查 addInterceptors 方法是否执行 |
| 所有接口都被限了 | key 没有包含接口名或维度信息,多个接口共用一个 key | 在 key 中拼上类名、方法名 |
| Redis 存储模式下限速失效 | 多个实例 key 不一致,比如维度 key 生成逻辑不同 | 确保所有实例使用相同的 key 拼接规则 |
| 取不到用户 ID,匿名限速 | 未登录的用户走 USER 维度时无法获得 ID | 降级为 anonymous 或改用 IP 维度 |
| 上线后误杀正常用户 | count 设得太小,或 IP 获取逻辑取到了代理 IP | 观察日志中的真实 key 与计数变化,调大阈值;修正 X-Forwarded-For 解析 |
| KeY 永不清理,内存泄漏 | Redis 实现里没有设置 EXPIRE | 第一次 INCR 后必须调用 expire |
5.2 负载均衡场景下的隐蔽问题
单机限速用本地存储没问题,但一旦上了多节点,只用ConcurrentHashMap的本地实现就会出现每个节点独立计数的漏洞。如果负载均衡恰好是轮询模式,一个用户发 10 个请求,经过 Nginx 分发到 3 个节点,每个节点各放行 10 次,总共就放了 30 次,你的 10 次限制形同虚设。更隐蔽的是:就算你切了 Redis,如果 key 拼接逻辑里包含了节点信息(比如不小心把serverIp加进去了),同样会失效。所以进入生产环境之前,务必确认存储实现是 Redis,并且 key 的生成逻辑在所有节点上完全一致。
5.3 限速器本身的性能消耗
这是一个我经常被问的问题:每次请求都多一次 Redis 交互,性能还扛得住吗?答案是:一次 Redis INCR 的耗时通常在 0.1~0.5ms 之间,如果你的接口本身耗时 50ms,这 0.5ms 的开销只占 1%。而且 Redis 是纯内存操作,单实例每秒可以处理十万级以上的 INCR 命令,对于绝大多数业务体量来说毫无压力。
真正要担心的是 Redis 超时。如果 Redis 出现故障超时,你的业务接口也会跟着超时,这在极端情况下会放大故障。建议在RedisRateLimitCounter里做一层降级:捕获异常时直接放行(fail-open)。限速器挂掉不能拖垮业务,这是生产环境的一个默认原则。但你也要知道,fail-open 意味着 Redis 故障时限速会失效,这是可接受的——毕竟 Redis 故障本身已经是严重事故,限速失效属于次生灾害。
5.4 为什么必须把拦截器顺序放在最前面
Spring 拦截器是支持配置顺序的。如果你的项目里同时有认证、日志、限速等多个拦截器,建议把限速拦截器放在最前面或者至少放在耗时操作之前。原因很朴素:限速的目的是保护系统资源,如果一个请求注定要被限掉,那就不应该让它继续往下走认证、业务逻辑等重环节。先拦截,成本最低。
5.5 测试时别只测“超了会不会拦”
很多人在自测时只关注“我连发 20 个请求,第 11 个被拦截”,然后就觉得完事了。但真正上线前至少还要验证三个场景:
- 窗口滑动后能不能正常恢复。比如限速 10 次/秒,你连续打 15 次,前 10 成功 5 失败;等 1 秒后再打,应该恢复到 10 次成功。
- 不同 IP 之间会不会互相干扰。用一个伪造的
X-Forwarded-For头发送请求,验证不同 IP 的计数是隔离的。 - 换了维度后 key 是否正确。比如 METHOD 维度的接口,换 IP 再试,应该同样被限——因为 METHOD 维度只看接口,不看 IP。
把这三条验证一遍,才算真正测透了这个功能。
6. 写在最后:几点实操心得
这套方案我已经在多套系统中落地过,说几个真实的体会。
第一,注解参数的设计需要克制。Dimension、time、unit、count这四个参数已经覆盖了绝大多数场景,不需要一开始就堆上“预热时间”“冷启动比例”这些花哨概念——那是专门限流框架的事。保持注解简单,业务方才会愿意用。如果参数太复杂,大家宁可不用,直接写在代码里硬编码。
第二,做好超限时的可观测性。拦截器每次拒绝请求时,除了返回 429,最好用日志输出一下当前的 key 和 count。比如:
log.warn("Rate limit exceeded. key={}, count={}, limit={}", windowKey, currentCount, rateLimit.count());这样线上出了问题,比如“某个用户突然访问不了”,你可以快速查看日志确认是被限速了,而不是代码 bug。没有日志的限速器,在排障时就像黑盒,非常痛苦。
第三,限速阈值设置要留有冗余。很多人拍脑袋把 count 设为 100,但实际上线后连续几轮活动都会误杀。建议先分析正常场景下最高峰每秒请求量是多少,然后乘以 1.5~2 倍作为限速阈值。限速器是兜底保护,不是精确定额工具,留足余量比精确重要。
第四,如果你有时间,可以把这个方案扩展成更完整的“接口保护”体系:在注解里增加ids参数支持指定方法的多个限速规则,或者配合spring-boot-starter-actuator把每个接口的限流命中次数暴露成 Prometheus 指标。Micrometer 中可以用Counter记录命中次数、用Timer记录限速器耗时,这样 Grafana 面板上一目了然。
最后一个很强烈的建议:限速代码写完之后,一定要压测,不要只靠 curl 手点。用 jmeter 或 wrk 发几百个并发,观察限速器在并发环境下的表现。很多线程安全的问题,只有在并发下才会暴露。说实话,第一次写限速器时,我自己也在并发测试时发现过 synchronized 加错位置的问题。限速器这类基础组件,代码量不大,但正确性要求极高,多花时间测试永远值得。