尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

一个注解搞定接口限速:自定义注解+Spring拦截器+Redis实践

一个注解搞定接口限速:自定义注解+Spring拦截器+Redis实践 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 里拿用户 IDMETHOD 维度是接口整体限流适合防刷。timeunit合成一个窗口长度。拆成两个字段而不是直接用毫秒数是出于可读性考虑。业务方写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); }本地实现用ConcurrentHashMapComponent ConditionalOnProperty(name rate.limit.storage, havingValue local, matchIfMissing true) public class LocalRateLimitCounter implements RateLimitCounter { private final ConcurrentHashMapString, 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;charsetUTF-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 ResultUserInfo 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?userId1 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 后必须调用 expire5.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 加错位置的问题。限速器这类基础组件代码量不大但正确性要求极高多花时间测试永远值得。
返回列表