三级接口限流:基于 Guava RateLimiter 的单机令牌桶实现

Spring Boot / Guava RateLimiter / LoadingCache / Token Bucket


限流策略概览

项目对接口调用按风险等级做了三级限流,全部基于 Guava RateLimiter 的令牌桶算法在单机内存中完成。核心思路是:按 IP + URI + Method 维度隔离,不同接口使用不同的令牌生成速率,通过 Spring MVC 的 HandlerInterceptor 在请求进入 Controller 之前完成判断。

接口 限流策略 令牌生成速率 设计意图
POST /api/auth/login 5 次 / 分钟 RateLimiter.create(5.0/60.0) 防止暴力破解登录密码
POST /api/users 10 次 / 小时 RateLimiter.create(10.0/3600.0) 防止恶意批量注册
其他所有接口 100 QPS RateLimiter.create(100.0) 全局兜底,兜住异常流量

为什么用 Guava RateLimiter,不用 Redis + Lua

限流方案的选型取决于部署架构和业务场景,而不是简单地"哪个更好"。

Guava RateLimiter(本项目选用)

  • 纯内存操作,零网络开销,延迟在微秒级
  • 令牌桶算法原生支持突发流量
  • 不需要额外依赖中间件,部署成本低
  • 适合单实例部署、中小流量场景

Redis + Lua(分布式场景适用)

  • 跨实例共享限流状态,支持水平扩展
  • 可通过 Lua 脚本保证原子性(如滑动窗口)
  • 每次限流判断需要一次网络往返(约 0.5-2ms)
  • 引入 Redis 依赖,增加运维复杂度

本项目的决策理由: 当前为单实例部署,限流的核心目标是防暴力破解和恶意注册,这些攻击行为本质上集中在单个 IP 上。单机内存限流足以应对,不需要为假设中的分布式场景提前引入 Redis 依赖。

如果后续需要扩展到多实例部署,有两个平滑的迁移路径:

  • 方案 A — 使用 Redisson 的 RRateLimiter,API 与 Guava RateLimiter 高度相似,迁移成本低,底层由 Redis 承载状态
  • 方案 B — 直接用 Redis + Lua 实现滑动窗口或令牌桶,灵活度最高,但需要自行编写和调试 Lua 脚本

令牌桶参数:QPS 的换算与 RateLimiter.create 的含义

Guava RateLimiter 基于令牌桶算法(Token Bucket)RateLimiter.create(qps) 的参数 qps 表示每秒生成的令牌数。请求到达时,桶中有剩余令牌则立即放行并扣减;桶为空则拒绝(或阻塞等待)。

三种限流等级的参数推导

限流等级 业务需求 QPS 换算 令牌间隔 突发容量
登录 5 次/分钟 5 / 60 = 0.0833 ~12 秒/令牌 1 秒累积 ≈ 0.083 个(无实际突发)
注册 10 次/小时 10 / 3600 = 0.00278 ~6 分钟/令牌 同上,无实际突发
通用 100 次/秒 100.0 10 ms/令牌 1 秒累积 = 100 个令牌

关于突发流量(Burst)

令牌桶的核心优势在于允许一定程度的突发。RateLimiter 内部会保存桶中当前的令牌数量(存储为 storedPermits),桶的最大容量默认等于 maxBurstSeconds * qps,其中 maxBurstSeconds 默认为 1。

对于登录接口(QPS = 0.0833),桶的容量同样约 0.083 个——这意味着几乎不存在突发空间。每 12 秒才攒够 1 个令牌,连续两次登录请求之间必须间隔约 12 秒,与"每分钟 5 次"的业务语义完全吻合。

对于通用接口(QPS = 100),桶的容量为 100 个令牌。如果接口有一段时间没有请求,令牌会累积到 100。当突发流量到来时,这 100 个令牌可以瞬间消耗掉,起到缓冲作用。

注意: Guava RateLimiter 不支持设置独立的桶容量参数(maxTokens)。桶容量始终等于 maxBurstSeconds * qps,只能通过 RateLimiter.create(qps, warmupPeriod, TimeUnit.SECONDS) 的预热模式间接影响。如果需要更精细的突发控制(比如"允许瞬时 20 个,但稳态只有 5 QPS"),需要考虑其他实现如 Resilience4j RateLimiter 或 Bucket4j。


LoadingCache:按 IP 隔离限流状态

限流需要按 IP 地址做隔离——每个 IP 有独立的令牌桶。这意味着需要一个 Map<String, RateLimiter> 结构,其中 Key 是客户端 IP,Value 是该 IP 对应的 RateLimiter 实例。

如果手动维护这个 Map,需要处理以下几个问题:

手动 HashMap

  • 需要 computeIfAbsent 判断 Key 是否存在
  • 不活跃 IP 的 RateLimiter 对象永远不会被回收
  • Map 无限增长,最终 OOM
  • 需要额外编写定时清理逻辑

Guava LoadingCache(本项目选用)

  • get(ip) 自动创建不存在的 RateLimiter
  • expireAfterAccess 自动淘汰不活跃条目
  • maximumSize(10_000) 硬上限兜底
  • 线程安全,无需额外同步

三个 LoadingCache 实例分别管理三种限流策略的 IP 级别令牌桶。以登录限流为例:

// 登录接口限流 Cache:key=IP, value=RateLimiter
// 5次/60秒 = 0.0833 QPS
private final LoadingCache<String, RateLimiter> loginRateLimiters = CacheBuilder.newBuilder()
        .maximumSize(10_000)             // 最多缓存 1 万个 IP
        .expireAfterAccess(1, TimeUnit.HOURS)  // 1 小时无访问则清除
        .build(new CacheLoader<>() {
            @Override
            public RateLimiter load(String key) {
                return RateLimiter.create(5.0 / 60.0);
            }
        });

expireAfterAccess 的设计考虑:登录限流的缓存过期时间设为 1 小时,注册限流为 2 小时。这个差异并非随意——注册攻击的特征是低频持久(每小时 10 次已经足够注册大量账号),所以需要更长的缓存窗口来持续追踪。而登录暴力破解通常在短时间内高频尝试,1 小时的窗口已经能覆盖绝大多数攻击周期。

maximumSize(10_000) 意味着最多同时跟踪 1 万个独立 IP。如果并发访问的独立 IP 超过这个数,Guava 会基于 LRU 策略淘汰最近最少访问的条目。对于中小型项目,1 万已经是一个相当宽裕的上限。


客户端 IP 获取与伪造风险

请求经过 Nginx 等反向代理后,request.getRemoteAddr() 拿到的是代理服务器的 IP,而非客户端真实 IP。因此需要从 HTTP Header 中提取。

IP 提取优先级

X-Forwarded-For → X-Real-IP → getRemoteAddr()
private String getClientIp(HttpServletRequest request) {
    String ip = request.getHeader("X-Forwarded-For");
    if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
        // 多级代理时取第一个 IP(最原始的客户端 IP)
        return ip.split(",")[0].trim();
    }
    ip = request.getHeader("X-Real-IP");
    if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
        return ip;
    }
    return request.getRemoteAddr();
}

X-Forwarded-For 的格式为 clientIP, proxy1IP, proxy2IP,每经过一层代理追加一个 IP。代码中取第一个,即最原始的客户端地址。

伪造风险与应对

X-Forwarded-For 是客户端可以自行设置的 HTTP Header,攻击者可以通过伪造该 Header 绕过 IP 维度的限流。这是一个真实存在的安全隐患,应对方案取决于网络架构:

方案 做法 安全性
Nginx 覆盖写入 在 Nginx 的 proxy_set_header 中强制设置 X-Forwarded-For 为 $remote_addr,丢弃客户端原始值 高。客户端伪造的值在代理层被覆盖
信任最后一跳 X-Forwarded-For 取最后一个 IP(即与 Nginx 直连的 IP),而非第一个 中。客户端仍可伪造,但必须猜中代理 IP 才有效
直接用 RemoteAddr 不读 Header,直接使用 request.getRemoteAddr() 最高。但拿到的始终是代理 IP,所有用户共享一个限流桶

生产建议: 最佳实践是在 Nginx 配置中显式覆盖 X-Forwarded-For,确保应用层拿到的值是可信的。在 Nginx 的 location 块中添加:

proxy_set_header X-Forwarded-For $remote_addr;

这样客户端无论发送什么值,到达 Spring Boot 时 X-Forwarded-For 都是由 Nginx 设置的真实客户端 IP。


请求处理完整流程

HTTP Request
  → HandlerInterceptor.preHandle
    → 提取 IP + URI + Method
      → 匹配限流策略
        ├─ POST /api/auth/login  → loginRateLimiters.get(ip)   → tryAcquire()
        ├─ POST /api/users       → registerRateLimiters.get(ip) → tryAcquire()
        └─ 其他请求               → globalRateLimiters.get(ip)  → tryAcquire()

tryAcquire() = true  → Controller 处理请求
tryAcquire() = false → HTTP 429 + JSON 响应 → 前端倒计时禁用按钮

前端限流配合:倒计时与按钮禁用

后端返回 HTTP 429 时,前端需要给用户明确的反馈并防止重复请求。项目中的处理分为两层:

第一层:全局 Axios 拦截器 —— 捕获 429 状态码,弹出 Notification 提示,并在 Error 对象上标记 isRateLimit = true,供业务组件识别。

第二层:useRateLimit 组合式函数 —— 被限流时启动倒计时(默认 60 秒),通过 isLimited 计算属性控制按钮的 disabled 状态,通过 limitText 显示剩余秒数。

// 使用示例
const { isLimited, limitText, startCountdown } = useRateLimit()

async function handleLogin() {
  try {
    await loginApi(form)
  } catch (err) {
    if (err.isRateLimit) {
      startCountdown(60)
    }
  }
}

这个设计的优点在于将"限流后的 UI 状态管理"封装为可复用的组合式函数,任何表单提交按钮都可以通过 :disabled="isLimited" 一行绑定实现限流保护,不需要在每个页面重复编写倒计时逻辑。


拦截器注册

RateLimitInterceptor 需要在 Spring MVC 的 WebMvcConfigurer 中注册才能生效。限流拦截器应放在拦截器链的最前面,确保在其他业务拦截器(如鉴权)之前执行——被限流的请求不应该消耗鉴权资源。

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Resource
    private RateLimitInterceptor rateLimitInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor)
                .addPathPatterns("/api/**")       // 对所有 API 路径生效
                .order(Ordered.HIGHEST_PRECEDENCE); // 优先级最高
    }
}

当前方案的局限与扩展方向

任何技术选型都有边界,以下是当前单机限流方案需要关注的几个点:

  • 不适用于分布式部署 — 如果启用了多个实例,每个实例独立限流,攻击者轮询不同实例可以绕过单机限制。迁移到 Redisson RRateLimiter 是最低成本的解决方案
  • 限流粒度仅到 IP 维度 — 同一 NAT 网关下的多个用户共享一个 IP,会被误伤。可以通过降低限流阈值缓解,或在关键接口叠加用户维度的限流(如"同一账号 3 次/分钟")
  • 无法动态调整阈值 — RateLimiter 的 QPS 在创建时确定,无法在运行时修改。如果需要在管理后台动态配置限流策略,需要重新创建 RateLimiter 实例并替换 LoadingCache 中的值
  • 无分布式令牌共享 — 单机场景下每个 IP 的令牌桶是独立的,不存在一致性问题。但扩展到多实例后,就需要考虑 Redis 中的原子性操作和网络分区下的降级策略

总结: 当前方案的定位是"单实例部署下的轻量级接口防护",在成本、复杂度和安全性之间取一个平衡点。对于中小型项目,这个平衡点通常是合适的。

完整代码展示

package com.example.project.interceptor;


/**
 * 接口限流拦截器基于 Guava RatrLimiter + LoadingCache
 * 按 IP + URI 维度进行限流, 不同接口配置不同策略
 * 登录接口 /api/auth/login. 5次每分钟 IP 防止暴力破解
 * 注册接口 /api/users (POST): 10次每小时 放置恶意注册
 * 其他接口 100 QPS 全局限流
 */

import com.example.project.common.ErrorCode;
import com.example.project.common.Result;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.RateLimiter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import tools.jackson.databind.json.JsonMapper;
import org.springframework.http.MediaType;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;

@Slf4j
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
    private final JsonMapper objectMapper = JsonMapper.builder().build();

    /**
     * 登录接口限流 Cache :key=IP, value = RateLimiter 5 / 60 = 0.0833 QPS
     */
    private final LoadingCache<String, RateLimiter> loginRateLimiters = CacheBuilder.newBuilder()
            .maximumSize(10_000)
            .expireAfterAccess(1, TimeUnit.HOURS)
            .build(new CacheLoader<>() {
                @Override
                public RateLimiter load(String key) {
                    return RateLimiter.create(5.0 / 60.0);
                }
            });

    private final LoadingCache<String, RateLimiter> registerRateLimiters = CacheBuilder.newBuilder()
            .maximumSize(10_000)
            .expireAfterAccess(2, TimeUnit.HOURS)
            .build(new CacheLoader<>() {
                @Override
                public RateLimiter load(String key) {
                    return RateLimiter.create(10.0 / 3600.0);
                }
            });
    /**
     * 通用接口限流
     */
    private final LoadingCache<String, RateLimiter> globalRateLimiters = CacheBuilder.newBuilder()
            .maximumSize(10_000)
            .expireAfterAccess(1, TimeUnit.HOURS)
            .build(new CacheLoader<>() {
                @Override
                public RateLimiter load(String key) {
                    return RateLimiter.create(100.0);
                }
            });

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
              throws Exception {
        String clientIp = getClientIp(request);
        String uri = request.getRequestURI();
        String method = request.getMethod();

        boolean allowed = isAllowed(clientIp, uri, method);
        if(!allowed) {
            log.warn("限流触发:IP={}, URI={}, Method={}", clientIp, uri, method);
            writeRateLimitResponse(response);
            return false;
        }
        return true;
    }

    private boolean isAllowed(String ip, String uri, String method) throws ExecutionException {
        if("/api/auth/login".equals(uri) && "POST".equalsIgnoreCase(method)) {
            return loginRateLimiters.get(ip).tryAcquire();
        }
        if("/api/users".equals(uri) && "POST".equalsIgnoreCase(method)) {
            return registerRateLimiters.get(ip).tryAcquire();
        }
        return globalRateLimiters.get(ip).tryAcquire();
    }

    /**
     * 返回429 + Result 格式
     */
    private void writeRateLimitResponse(HttpServletResponse response) throws IOException {
        response.setStatus(429);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        String body = objectMapper.writeValueAsString(
                Result.error(ErrorCode.RATE_LIMIT_EXCEEDED));
        response.getWriter().write(body);
    }
    /**
     * 获取客户端真实IP 兼容反向代理
     */
    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if(ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
            //多级代理时获取第一个IP
            return ip.split(",")[0].trim();
        }
        ip = request.getHeader("X-Real_IP");
        if(ip != null && !ip.isEmpty() && !"unknow".equalsIgnoreCase(ip)){
            return ip;
        }
        return request.getRemoteAddr();
    }
}

前端限流倒计时组合式完整代码

import { ref, computed } from 'vue'

/**
 * 限流倒计时组合式函数
 * 当接口触发 429 限流时,启动倒计时,期间禁用按钮并显示剩余秒数
 *
 * 使用方式:
 *   const { isLimited, countdown, startCountdown, limitText } = useRateLimit()
 *   // 在 catch 里调用:if (isRateLimit) startCountdown(60)
 *   // 在按钮上绑定::disabled="isLimited" :loading="isLimited"
 */
export function useRateLimit() {
  const countdown = ref(0)
  const isLimited = computed(() => countdown.value > 0)
  const limitText = computed(() =>
    countdown.value > 0 ? `${countdown.value} 秒后重试` : ''
  )

  let timer: ReturnType<typeof setInterval> | null = null

  /**
   * 启动限流倒计时
   * @param seconds 等待秒数,默认60秒
   */
  function startCountdown(seconds = 60) {
    if (timer) clearInterval(timer)
    countdown.value = seconds
    timer = setInterval(() => {
      countdown.value--
      if (countdown.value <= 0) {
        clearInterval(timer!)
        timer = null
      }
    }, 1000)
  }

  function reset() {
    if (timer) clearInterval(timer)
    timer = null
    countdown.value = 0
  }

  return { isLimited, countdown, limitText, startCountdown, reset }
}
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐