Spring Boot 接口限流手记:固定窗口计数器的实现与注意点

问题:接口被频繁请求怎么办

一个对外的接口一旦被脚本高频调用,轻则拖慢正常请求,重则打满线程池、拖垮数据库。登录、短信验证码、搜索、导出这类接口尤其容易成为目标。限流就是在入口处给每个调用方设定单位时间内的请求上限,超出的请求直接拒绝,保护后端资源。

本文用固定窗口计数器实现一个轻量限流过滤器,只依赖 JDK 与 Spring Boot 自带 API,适合单实例服务快速落地。

适用场景

  • 登录、注册、找回密码等防暴力破解的接口;
  • 短信、邮件验证码发送接口,防止被刷造成费用损失;
  • 导出、报表、大查询等耗时接口,避免并发过高拖垮服务;
  • 对接第三方回调或开放 API,需要按调用方做配额控制。

如果服务是多实例部署,或者限流规则需要动态调整、按用户等级区分额度,本文的本机计数方案就不够用了,应改用 Redis 集中计数。

方案选型:三种常见算法

  • 固定窗口计数器:按固定时间窗口计数,窗口结束清零。实现最简单,缺点是窗口边界处可能出现两倍流量尖峰;
  • 滑动窗口:把窗口切成多个小格滚动统计,平滑边界尖峰,实现稍复杂;
  • 令牌桶:按固定速率往桶里放令牌,请求消耗令牌,允许短时突发,适合允许突发但要控制平均速率的场景。

多数内部接口先上固定窗口就够用,边界尖峰问题在窗口较短、阈值留有余量时影响有限。

关键代码:限流过滤器

用 Spring 的 OncePerRequestFilter 实现,Spring Boot 3.x 使用 jakarta.servlet 包,2.x 对应 javax.servlet 包,其余写法相同。过滤器按客户端 IP 加请求路径做 key,窗口内计数超过上限返回 429。

@Component
@Order(1)
public class RateLimitFilter extends OncePerRequestFilter {

    private static final int MAX_REQUESTS = 100;
    private static final long WINDOW_MILLIS = 60_000L;

    private final ConcurrentHashMap<String, Window> windows = new ConcurrentHashMap<>();

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String key = request.getRemoteAddr() + ":" + request.getRequestURI();
        Window window = windows.computeIfAbsent(key, k -> new Window());
        if (!window.tryAcquire()) {
            response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"code\":429,\"message\":\"请求过于频繁,请稍后再试\"}");
            return;
        }
        chain.doFilter(request, response);
    }

    @Scheduled(fixedDelay = 60_000)
    public void cleanUp() {
        long now = System.currentTimeMillis();
        windows.entrySet().removeIf(e -> now - e.getValue().start > WINDOW_MILLIS);
    }

    private static class Window {
        volatile long start = System.currentTimeMillis();
        final AtomicLong count = new AtomicLong(0);

        synchronized boolean tryAcquire() {
            long now = System.currentTimeMillis();
            if (now - start >= WINDOW_MILLIS) {
                start = now;
                count.set(0);
            }
            return count.incrementAndGet() <= MAX_REQUESTS;
        }
    }
}

需要补充两处配置:定时清理要生效,需在配置类上加 @EnableScheduling 开启定时任务;阈值与窗口长度建议抽到 application.properties,用 @Value 注入,不要写死在代码里多人共用同一份阈值。

注意点

  • 过滤器顺序:@Order 值越小越先执行,限流过滤器应排在认证过滤器之后、业务逻辑之前;排在认证之前会把未登录的探测请求也计入同一 key;
  • key 的粒度:按 IP 限流简单但会误伤同一出口 IP 下的正常用户,登录类接口建议按“IP + 用户名”组合限流,已登录接口可按用户 ID 限流;
  • 代理后的真实 IP:服务在反向代理或负载均衡之后时,getRemoteAddr 拿到的是代理地址,需从 X-Forwarded-For 取首个 IP,且只信任可信代理传来的该请求头;
  • 内存增长:每个 key 对应一个窗口对象,key 空间无界时会持续增长,定时清理过期窗口是必须的;key 本身也不要拼接请求参数全文;
  • 拒绝响应要明确:返回 429 状态码并在响应体里说明原因与重试建议,前端与调用方才能正确处理;不要用 200 包一个错误码冒充成功;
  • 不要对所有接口一刀切:健康检查、静态资源与内部回调接口应排除在限流之外,可在过滤器里按路径白名单放行;
  • 窗口边界尖峰:固定窗口在边界处最多放过约两倍阈值的请求,阈值设定时按后端实际承受能力留余量,窗口建议不短于 10 秒。

小结

固定窗口计数器用一个过滤器加一个 ConcurrentHashMap 就能落地,适合单实例服务的登录、验证码、导出等高频接口做第一道防护。它的边界是单机内存与固定窗口的尖峰问题;当服务扩到多实例,或需要按用户等级动态调阈值时,再升级为 Redis 集中计数或令牌桶方案。