索引 · 专题

Spring Boot 接口限流实战:注解 + AOP + Redis Lua 滑动窗口

DropFir
2026年6月8日
7 分钟阅读
9 次阅读

一、限流解决什么问题

在 AI 类应用中,很多接口既消耗资源,又容易被重复触发。例如:

  • 简历上传后触发 LLM 分析
  • 知识库文档向量化,也就是 Embedding
  • 大文件解析、落库与对象存储写入
  • 用户手动重试、批量导入等高频操作

如果这些接口不做访问控制,恶意请求或无意的连续点击都可能打满 CPU、Redis、数据库连接池,甚至消耗掉 AI API 配额。

限流的目标很简单:在业务逻辑执行之前,用尽可能小的成本拦住过量请求。越早拒绝,越能保护后面的昂贵资源。


二、为什么选择注解 + AOP + Redis Lua

常见限流方案可以分成三类:

方案优点不足
网关层限流(Nginx / API Gateway)统一入口、与业务解耦更适合粗粒度规则,难按具体方法和业务维度配置
第三方限流库(Bucket4j / Resilience4j)开箱即用,能力完整增加依赖,多维度组合与本地业务上下文可能不够贴合
自定义注解 + Redis Lua轻量、可控、能复用现有 Redis需要维护注解、切面和 Lua 脚本

本项目选择第三种方式,主要基于三点考虑:

  1. 项目已经依赖 Redis + Redisson,限流可以直接复用基础设施。
  2. @RateLimit 标注 Controller 方法,规则和接口放在一起,可读性更好。
  3. 将核心计数逻辑放到 Lua 脚本 中,可以在 Redis 内完成原子判断与扣减。

这套方案适合应用层精细限流。网关仍然可以继续承担 DDoS 防护、全站 QPS 上限等粗粒度能力。


三、整体流程

HTTP 请求
    ↓
进入带 @RateLimit 的 Controller 方法
    ↓
RateLimitAspect 环绕通知
    ↓
逐条执行限流规则
    ↓
Redisson 调用 rate_limit_single.lua
    ↓
Redis 完成滑动窗口计数
    ↓
通过:执行业务方法
拒绝:抛出 RateLimitExceededException,统一返回错误码 8001

核心文件如下:

文件职责
RateLimit.java定义限流注解和可选维度
RateLimitAspect.java拦截方法、生成 Redis Key、调用 Lua 脚本
rate_limit_single.lua执行滑动窗口计数和原子判断
RateLimitExceededException.java表示请求超过限流阈值

四、注解设计:一处声明,多维组合

@RateLimit 放在方法上,用来描述一条限流规则:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(RateLimit.Container.class)
public @interface RateLimit {

    enum Dimension { GLOBAL, IP, USER }

    Dimension dimension() default Dimension.GLOBAL;
    double count();
    long interval() default 1;
    TimeUnit timeUnit() default TimeUnit.SECONDS;
    long timeout() default 0;
    String fallback() default "";
}

设计上有三个关键点:

  1. 支持重复标注@Repeatable 允许同一个方法配置多条限流规则。
  2. 全部规则通过才放行:例如同时配置 GLOBALIP 时,两条规则都满足才会执行业务。
  3. 表达窗口语义count + interval + timeUnit 描述“某个时间窗口内最多允许多少次”。

示例:限制简历上传接口的全站频率和单 IP 频率。

@PostMapping("/api/resumes/upload")
@RateLimit(dimension = RateLimit.Dimension.GLOBAL, count = 5)
@RateLimit(dimension = RateLimit.Dimension.IP, count = 5)
public Result<Map<String, Object>> uploadAndAnalyze(@RequestParam("file") MultipartFile file) {
    // 业务逻辑
}

这段配置的含义是:

  • 该接口全站每秒最多 5 次
  • 每个客户端 IP 每秒最多 5 次
  • 任意一条规则超限,请求都会被拒绝

五、AOP 切面:在业务前做轻量拦截

RateLimitAspect 使用环绕通知,在目标方法执行前完成限流判断:

@Around("@annotation(RateLimit) || @annotation(RateLimit.Container)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
    RateLimit[] rules = method.getAnnotationsByType(RateLimit.class);

    for (RateLimit rule : rules) {
        String key = generateKey(className, methodName, rule.dimension());
        Long result = executeRateLimitScript(key, nowMs, requestId, intervalMs, rule.count());

        if (result == null || result == 0) {
            throw new RateLimitExceededException("请求过于频繁,请稍后再试");
        }
    }

    return joinPoint.proceed();
}

这段逻辑有两个好处:

  • Controller 不需要手写限流判断,业务代码保持干净。
  • 限流发生在业务执行前,可以避免文件解析、数据库写入、LLM 调用等高成本操作被触发。

六、Redis Key 设计

限流 Key 需要同时体现“接口”和“维度”。例如:

ratelimit:{ResumeController:uploadAndAnalyze}:global
ratelimit:{ResumeController:uploadAndAnalyze}:ip:192.168.1.10
ratelimit:{ResumeController:uploadAndAnalyze}:user:10001

设计细节:

  • {ResumeController:uploadAndAnalyze} 是 Redis Cluster 的 Hash Tag
  • 同一方法下的相关 Key 会落到同一个 slot,便于 Lua 脚本在集群模式下执行。
  • IP 维度优先从 X-Forwarded-ForX-Real-IP 等 Header 解析,兼容反向代理。
  • USER 维度可以从 X-User-Id 或 Request Attribute 中读取。

生产环境要注意:只有在可信代理后面,才应该信任 X-Forwarded-For。否则客户端可以伪造 Header 绕过 IP 限流。


七、核心算法:滑动时间窗口

7.1 为什么不用固定窗口

固定窗口会在边界处放大瞬时流量。例如限制每秒 5 次:

第 0.9 秒来了 5 个请求
第 1.0 秒又来了 5 个请求

从固定窗口看,两秒内都没有超限。但从真实流量看,短时间内已经进入 10 个请求。

滑动窗口只统计“最近 N 毫秒”内的请求数,边界更平滑,也更符合接口保护的目标。

7.2 Redis 数据结构

每条限流规则使用两个 Key:

Key类型作用
{key}:valueString记录当前剩余可用令牌数
{key}:permitsZSET记录每次扣减的时间戳和请求标识

ZSET 的 score 使用请求时间戳。每次请求到来时,脚本先清理已经滑出窗口的记录,再判断剩余令牌是否足够。

7.3 Lua 脚本逻辑

核心流程可以简化为四步:

-- 1. 读取当前令牌,首次请求时等于 max_tokens
local current_val = redis.call("get", value_key) or max_tokens

-- 2. 找出已经滑出窗口的旧记录,并归还对应令牌
local expired_values = redis.call("zrangebyscore", permits_key, 0, now_ms - interval)
-- zremrangebyscore 删除过期记录

-- 3. 令牌不足则拒绝
if current_val < permits then
    return 0
end

-- 4. 扣减令牌,并记录本次请求时间
redis.call("zadd", permits_key, now_ms, request_id .. ":" .. permits)
redis.call("set", value_key, current_val - permits)
return 1

这里的关键不是 Lua 语法,而是执行边界:查询、清理、判断、扣减和记录必须作为一个原子操作完成

如果这些步骤拆成多条 Redis 命令,高并发下可能出现多个请求同时看到“还有余额”,最终造成超卖。

7.4 为什么必须用 Lua

Redis 执行 Lua 脚本时是单线程的。脚本执行期间,不会被其他命令插入打断。因此,它天然适合做这种“先判断再扣减”的原子逻辑。

Java 侧通过 Redisson 预加载脚本,并优先使用 evalSha 执行。这样可以减少每次传输脚本文本的开销。Redis 重启后如果出现 NOSCRIPT,再重新 scriptLoad 即可。


八、超限后的处理

默认行为是抛出 RateLimitExceededException

  • 错误码:8001
  • 提示信息:请求过于频繁,请稍后再试

如果某些接口希望降级而不是直接报错,可以通过 fallback 指定同类中的降级方法:

@RateLimit(dimension = Dimension.IP, count = 10, fallback = "uploadFallback")
public Result<?> upload(...) {
    // 正常上传逻辑
}

private Result<?> uploadFallback(...) {
    return Result.error("系统繁忙,请稍后再试");
}

fallback 方法建议保持两点约束:

  • 参数列表与原方法一致,或设计为无参方法
  • 返回值类型与原方法兼容

九、一个完整的限流过程

配置如下:

@RateLimit(count = 5)

默认窗口是 1 秒,表示最近 1 秒内最多允许 5 次请求。

时刻事件最近 1 秒内请求数结果
T+0.2s请求 A1通过
T+0.5s请求 B2通过
T+0.7s请求 C3通过
T+0.8s请求 D4通过
T+0.9s请求 E5通过
T+0.95s请求 F5拒绝
T+1.3s请求 G4(请求 A 已滑出窗口)通过

请求 G 能通过,是因为 T+0.2s 的请求 A 已经不在最近 1 秒窗口内。


十、实践建议

10.1 如何设置限流粒度

不同接口的成本不同,阈值不应该一刀切。

接口类型建议
普通查询接口GLOBAL / IP 可适当放宽,例如 30/s
文件上传 + AI 分析建议收紧,例如 3-5/s
手动重试、重新生成建议更严,例如 2/s
后台批处理触发建议叠加 USER 或业务 ID 维度

经验上,越靠近外部付费资源或大文件处理链路,限流越应该靠前、越应该收紧。

10.2 如何与网关限流配合

推荐保留两层限流:

  • 网关层:负责粗粒度防护,例如 DDoS、全站 QPS、黑白名单。
  • 应用层 @RateLimit:负责接口级、用户级、IP 级的精细控制。

两层职责不同,不冲突。网关保护入口,应用保护业务资源。

10.3 上线前需要关注什么

  1. Redis 可用性:Redis 不可用时,限流逻辑也会受影响,需要监控连接、延迟和错误率。
  2. Header 可信边界:生产环境应只在可信代理后解析 X-Forwarded-For
  3. 脚本缓存恢复:Redis 重启后可能出现 NOSCRIPT,需要自动重新加载脚本。
  4. Key 过期策略:限流 Key 应设置合理 TTL,避免长期积累无用数据。
  5. 观测指标:建议记录通过量、拒绝量、接口名、维度和触发阈值,便于调参。

十一、方案边界

这套方案适合“应用层接口保护”,但不是所有场景都应该放在应用里解决。

如果你的目标是抵挡大规模恶意流量,应优先使用网关、WAF、CDN 或云厂商防护。因为请求到达应用时,连接、线程和部分网络资源已经被消耗。

如果你的业务需要复杂的配额体系,例如按套餐、组织、计费周期、资源类型组合计量,建议把限流和配额系统拆开设计。@RateLimit 更适合保护瞬时频率,而不是承担完整计费逻辑。


十二、总结

这套 Spring Boot 限流方案的核心思路是:

  1. 用注解声明规则,把限流语义放在接口旁边。
  2. 用 AOP 统一拦截,避免在每个 Controller 中重复写判断。
  3. 用 Redis Lua 做原子计数,保证分布式环境下的限流准确性。
  4. 用多维度组合规则,同时支持全局、IP、用户等不同控制粒度。

如果你的 Spring Boot 项目已经有 Redis,这种实现成本很低:一个注解、一个切面、一段 Lua 脚本,就能给高成本接口加上一层可靠的流量保护。


附录:文件与依赖

核心文件:

common/annotation/RateLimit.java          # 注解
common/aspect/RateLimitAspect.java        # AOP 切面
resources/scripts/rate_limit_single.lua   # Lua 脚本
common/exception/RateLimitExceededException.java

依赖:

implementation "org.redisson:redisson-spring-boot-starter:..."

测试建议:

app/src/test/java/.../RateLimitIntegrationTest.java
app/src/test/java/.../RateLimitScriptTest.java

集成测试需要本地或测试环境 Redis。重点验证:

  • 单维度限流是否生效
  • 多个 @RateLimit 是否全部通过才放行
  • 超限后是否返回统一错误码
  • Redis 重启后脚本是否能重新加载

评论

加载中…
加载评论…

关于作者

DropFir

写代码、记笔记。欢迎在 关于页了解更多。

标签

Spring BootRedisLuaAOP接口限流后端架构

延伸阅读