Java Web 开发中基于 Spring Boot + Redis 的接口限流实战与性能调优
接口限流这件事,平时看着像“锦上添花”,但真到线上流量抖一下、有人恶意刷接口、或者某个下游慢得像蜗牛的时候,它往往就是“保命机制”。
这篇文章我不打算只讲概念,而是带你从一个 Spring Boot + Redis 的可运行限流方案 走一遍:为什么要限流、怎么设计、代码怎么写、怎么验证、线上容易踩哪些坑,以及怎么做性能调优。
适合已经做过 Spring Boot Web 开发、对 Redis 有基础使用经验的同学。
背景与问题
在 Java Web 项目里,接口限流常见的触发场景有这些:
- 登录、短信发送、验证码校验,容易被恶意刷
- 秒杀、抢券、下单类接口,会出现流量瞬时激增
- 下游依赖能力有限,比如调用第三方支付、库存服务、风控服务
- 某些“高频但低价值”的接口,如果不控,容易拖垮整体系统
很多项目一开始的做法很简单:
- 在 JVM 内存里用
ConcurrentHashMap计数 - 在网关上配一个固定 QPS
- 用 Nginx 做粗粒度限流
这些方案不是不能用,而是各有边界:
- 单机内存计数:多实例部署后数据不共享,限流不准
- 网关限流:适合统一入口,但不容易做“按用户/按接口/按业务参数”的细粒度控制
- Nginx 限流:简单高效,但对业务语义支持有限
所以在很多中型 Java Web 项目里,Spring Boot + Redis 是一个非常实用的组合:
- Spring Boot 负责业务接入和注解式使用
- Redis 负责分布式计数和原子性控制
- 实现成本低,扩展性也不错
前置知识与环境准备
本文示例环境:
- JDK 17
- Spring Boot 3.x
- Redis 7.x
- Maven
- Spring Web
- Spring Data Redis
Maven 依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
application.yml
server:
port: 8080
spring:
data:
redis:
host: 127.0.0.1
port: 6379
timeout: 3000ms
logging:
level:
root: info
核心原理
接口限流常见算法有几种:
- 固定窗口
- 滑动窗口
- 漏桶
- 令牌桶
如果你是做业务接口保护,而不是网络层流控,我建议先把这两个吃透:
- 固定窗口:实现最简单,适合快速落地
- 令牌桶:更平滑,适合高并发和突发流量
本文先落地一个 固定窗口限流,再讲怎么优化。
1. 固定窗口限流思路
假设规则是:
- 对
/api/order/create - 按用户 ID 限流
- 10 秒内最多 5 次请求
做法:
- Redis key 设计成:
rate_limit:{接口}:{用户} - 每次请求先对 key 做
INCR - 第一次出现时设置
EXPIRE 10 - 如果计数值 > 5,则拒绝请求
优点:
- 实现简单
- Redis 原子自增性能好
- 很适合中小规模业务限流
缺点:
- 存在“窗口边界突刺”问题
比如 9.9 秒请求 5 次,10.1 秒再请求 5 次,短时间内可能打到 10 次
2. 为什么要用 Lua 脚本
如果你把逻辑拆成多步:
INCR- 判断是否首次
EXPIRE- 判断是否超限
在高并发下可能出现两个问题:
- 原子性不完整
- 某些异常路径下 key 没设过期,形成“脏 key”
所以更稳妥的方式是:
- 把
INCR + EXPIRE + 返回计数放到一个 Lua 脚本里执行 - Redis 保证脚本执行期间的原子性
限流整体流程
flowchart TD
A[客户端请求接口] --> B[Spring Boot 拦截器/AOP]
B --> C[构造限流Key]
C --> D[执行Redis Lua脚本]
D --> E{是否超限}
E -- 否 --> F[放行业务处理]
E -- 是 --> G[返回429或业务错误码]
方案设计:从“按 IP”升级到“按业务主体”
很多初学实现限流时,第一反应是按 IP 限流。但实际项目里,只按 IP 往往不够:
- 用户共享 NAT 出口,误伤正常用户
- 移动网络环境 IP 变化频繁
- 某些内部调用没有稳定客户端 IP
更实用的做法是分层:
- 登录前接口:按 IP + URI
- 登录后接口:按用户 ID + URI
- 敏感接口:按用户 ID + 业务动作
- 内部接口:按调用方 appId / clientId
我自己的经验是:
限流 key 一定要体现“谁在调用 + 调哪个接口”,否则不是太松,就是误杀太多。
实战代码(可运行)
下面实现一个可运行版本,包含:
- 自定义注解
- AOP 切面
- Redis Lua 脚本
- 示例 Controller
- 全局异常处理
1. 自定义限流注解
package com.example.demo.ratelimit;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 每个时间窗口内允许的最大请求数
*/
int limit();
/**
* 时间窗口,单位:秒
*/
int windowSeconds();
/**
* 限流维度:
* ip / user / custom
*/
String keyType() default "ip";
/**
* 自定义前缀,便于区分业务
*/
String prefix() default "rate_limit";
}
2. 自定义异常
package com.example.demo.ratelimit;
public class RateLimitException extends RuntimeException {
public RateLimitException(String message) {
super(message);
}
}
3. Redis Lua 脚本配置
这里用 DefaultRedisScript<Long> 加载脚本。
package com.example.demo.ratelimit;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
@Configuration
public class RedisLuaConfig {
@Bean
public DefaultRedisScript<Long> rateLimitScript() {
String luaScript = """
local key = KEYS[1]
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, ARGV[1])
end
return current
""";
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new org.springframework.scripting.support.ResourceScriptSource(
new ByteArrayResource(luaScript.getBytes())
));
script.setResultType(Long.class);
return script;
}
}
4. 获取客户端标识工具类
这里演示按 IP 和按用户 ID 两种方式。
为了让示例能直接跑起来,我用请求头 X-User-Id 模拟登录用户。
package com.example.demo.ratelimit;
import jakarta.servlet.http.HttpServletRequest;
public class RateLimitKeyUtil {
public static String getClientIp(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isBlank()) {
return xForwardedFor.split(",")[0].trim();
}
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp;
}
return request.getRemoteAddr();
}
public static String getUserId(HttpServletRequest request) {
String userId = request.getHeader("X-User-Id");
return (userId == null || userId.isBlank()) ? "anonymous" : userId;
}
}
5. AOP 切面实现限流
package com.example.demo.ratelimit;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Collections;
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {
private final StringRedisTemplate stringRedisTemplate;
private final DefaultRedisScript<Long> rateLimitScript;
@Around("@annotation(rateLimit)")
public Object doRateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest();
String uri = request.getRequestURI();
String keyPart = buildKeyPart(request, rateLimit.keyType());
String redisKey = String.format("%s:%s:%s", rateLimit.prefix(), uri, keyPart);
Long currentCount = stringRedisTemplate.execute(
rateLimitScript,
Collections.singletonList(redisKey),
String.valueOf(rateLimit.windowSeconds())
);
if (currentCount != null && currentCount > rateLimit.limit()) {
throw new RateLimitException("请求过于频繁,请稍后再试");
}
return joinPoint.proceed();
}
private String buildKeyPart(HttpServletRequest request, String keyType) {
return switch (keyType) {
case "user" -> RateLimitKeyUtil.getUserId(request);
case "ip" -> RateLimitKeyUtil.getClientIp(request);
default -> RateLimitKeyUtil.getClientIp(request);
};
}
}
6. 全局异常处理
package com.example.demo.web;
import com.example.demo.ratelimit.RateLimitException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RateLimitException.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public Map<String, Object> handleRateLimit(RateLimitException ex) {
return Map.of(
"code", 429,
"message", ex.getMessage()
);
}
}
7. 示例接口
package com.example.demo.web;
import com.example.demo.ratelimit.RateLimit;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.Map;
@RestController
public class DemoController {
@GetMapping("/api/public/ping")
@RateLimit(limit = 3, windowSeconds = 10, keyType = "ip", prefix = "rl")
public Map<String, Object> ping() {
return Map.of(
"success", true,
"time", LocalDateTime.now().toString(),
"message", "pong"
);
}
@GetMapping("/api/user/profile")
@RateLimit(limit = 5, windowSeconds = 20, keyType = "user", prefix = "rl")
public Map<String, Object> profile() {
return Map.of(
"success", true,
"message", "user profile data"
);
}
}
8. 启动类
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RateLimitApplication {
public static void main(String[] args) {
SpringApplication.run(RateLimitApplication.class, args);
}
}
调用时序
sequenceDiagram
participant C as Client
participant A as Spring AOP
participant R as Redis
participant S as Service/Controller
C->>A: 请求 /api/user/profile
A->>A: 解析 @RateLimit
A->>R: 执行 Lua(INCR + EXPIRE)
R-->>A: 返回当前计数
alt 未超限
A->>S: 放行执行业务
S-->>C: 200 OK
else 超限
A-->>C: 429 Too Many Requests
end
逐步验证清单
代码跑起来后,建议按下面步骤验证,不要一上来就压测。
1. 验证 Redis key 是否生成
先请求:
curl http://localhost:8080/api/public/ping
再看 Redis:
redis-cli keys "rl:*"
如果能看到类似 key,说明 AOP 已经生效。
2. 验证限流是否生效
连续调用 4 次:
for i in {1..4}; do curl http://localhost:8080/api/public/ping; echo; done
预期:
- 前 3 次成功
- 第 4 次返回 429
3. 验证按用户维度限流
for i in {1..6}; do
curl -H "X-User-Id: 1001" http://localhost:8080/api/user/profile
echo
done
再换一个用户:
curl -H "X-User-Id: 1002" http://localhost:8080/api/user/profile
预期:
- 用户
1001超限 - 用户
1002不受影响
4. 验证过期恢复
等待窗口时间结束后再发请求,应恢复正常。
进一步优化:从固定窗口到更稳的限流模型
上面的方案已经能用于不少业务接口,但如果你对流量波峰比较敏感,可以继续优化。
优化方向一:滑动窗口
思路是把请求时间戳记录下来,只统计最近 N 秒内的请求数。
常见实现用 Redis 的 ZSET:
- score:时间戳
- member:唯一请求 ID
- 每次请求先删掉过期元素
- 再统计窗口内元素数量
- 超限则拒绝
优点:
- 比固定窗口更平滑
- 边界突刺问题小很多
缺点:
- Redis 操作更多
- 内存成本更高
- 高并发下需要注意 key 膨胀
优化方向二:令牌桶
如果你的接口存在突发流量,但又不希望直接“硬拦”,令牌桶会更合适:
- 系统按固定速率往桶里放令牌
- 请求来时先取令牌
- 取到则通过,取不到则限流
它对“偶尔突发、整体稳定”的场景很友好,比如:
- 查询类接口
- 活动页接口
- 某些读多写少的业务
常见坑与排查
这一部分我建议认真看,因为很多限流功能“看起来写完了”,但真正的问题都在这里。
1. AOP 不生效
现象:
- 加了
@RateLimit,请求却没被拦截
排查点:
- 是否引入了
spring-boot-starter-aop - 注解是否加在
public方法上 - 是否发生了类内部自调用
比如同一个类里this.xxx()调另一个标了注解的方法,AOP 可能失效
建议:
- 限流注解优先加在 Controller 层入口方法
- 不要依赖类内自调用触发切面
2. 获取真实 IP 不准确
现象:
- 所有请求都显示成网关 IP 或
127.0.0.1
原因:
- 部署在 Nginx、Ingress、网关后面,没有正确透传真实 IP
排查:
- 看请求头是否包含
X-Forwarded-For - Nginx 是否配置了:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
提醒:
- 不要盲信客户端自己传的 IP 头
- 只信任你自己的反向代理层追加的头
3. Redis key 没过期,数量越来越多
现象:
- Redis 中限流 key 持续增长
原因:
INCR和EXPIRE没做原子化- 某些异常流程导致只自增没过期
解决:
- 用 Lua 脚本
- 定期抽样检查 TTL
可用下面命令排查:
redis-cli ttl "rl:/api/public/ping:127.0.0.1"
4. 多实例下限流不准
如果你已经用了同一个 Redis,一般不会有这个问题。
真正容易出问题的是:
- 测试环境每个实例连的 Redis 不是同一个
- 有些接口走 Redis 限流,有些接口还保留本地计数逻辑
- key 设计不统一,导致统计维度不一致
建议:
- 限流规则统一收口
- key 命名规范固定下来
- 不要线上同时混用多个不兼容的限流实现
5. Redis 成为瓶颈
现象:
- 接口 RT 升高
- Redis CPU 飙升
- 大量热点 key
这个问题我见过,尤其是“超热点接口 + 单一维度限流”的时候很明显。
比如:
- 所有人都访问同一个公共接口
- 你只按 URI 限流
- 导致所有请求都打同一个 key
优化方式:
- 增加 key 维度,比如
URI + IP或URI + userId - 对超热点公共接口在网关层先做一层粗限流
- Redis 连接池参数合理配置
- 避免对每个请求做复杂序列化/反序列化
安全/性能最佳实践
这部分是落地时最值钱的地方,很多项目差距就在这些细节里。
1. 限流要分层,不要“一个规则打天下”
建议你这样分:
- 网关层:做全局粗粒度限流,拦掉明显异常流量
- 应用层:做细粒度业务限流,按用户、接口、业务动作控制
- 下游层:必要时再做线程池/熔断保护
也就是说,Redis 限流不要承担全部防护职责。
2. key 设计尽量短,但语义要清楚
推荐格式:
rl:{env}:{app}:{uri}:{subject}
例如:
rl:prod:order:/api/order/create:1001
注意点:
- key 太长会浪费内存
- key 太短又不好排查
- URI 如果包含动态路径,最好归一化
否则/order/1、/order/2会变成不同 key
3. 返回明确的错误码和提示
HTTP 层建议返回:
429 Too Many Requests
业务体里可带:
- 错误码
- 友好提示
- 建议重试时间
例如:
{
"code": 429,
"message": "请求过于频繁,请 10 秒后重试"
}
这样前端、调用方、监控系统都更容易识别。
4. 给限流加监控,不要“静默失败”
至少统计这些指标:
- 限流命中次数
- 按接口的限流分布
- 按用户/IP 的高频命中情况
- Redis 脚本调用耗时
- 429 响应占比
如果没有监控,你很难区分:
- 是真的挡住了攻击
- 还是错误地误伤了正常用户
5. 对登录、验证码、短信接口单独加严
这类接口不是“普通高频”,而是“高风险高敏感”。
建议:
- 短周期限流:例如 1 分钟 5 次
- 长周期限流:例如 1 小时 20 次
- 叠加设备指纹、账号、IP、多维限制
- 对触发阈值的对象做黑名单或二次验证
单一维度限流,防普通误用可以,防恶意刷接口通常不够。
6. Redis 不可用时要有降级策略
这个非常重要。
限流组件如果自己成了单点故障,那就很尴尬。
常见降级策略:
- 放行优先:Redis 不可用时直接放行
适合核心交易接口,避免误杀 - 拒绝优先:Redis 不可用时拒绝请求
适合高风险接口,比如短信发送 - 本地兜底:临时退化到单机限流
精度差一点,但比完全失控强
我个人建议:不同接口采用不同策略,不要全站统一一个开关。
性能调优建议
如果你准备把这套方案真正扔到生产环境,下面这些优化很值得做。
1. 减少对象创建和字符串拼接
限流是高频路径,别小看这些小开销。
可优化点:
- key 构造使用更轻量的方式
- URI 做预处理缓存
- 避免每次请求都创建复杂中间对象
如果接口 QPS 很高,这些小优化加起来是有体感的。
2. 使用连接池并观察 Redis RT
Spring Boot 默认已经比较方便,但你仍然要关注:
- 最大连接数是否足够
- 超时时间是否合理
- 是否存在连接等待
如果 Redis RT 已经明显抖动,限流本身会反向拖慢业务。
3. 热点接口前移到网关
应用层限流足够灵活,但每个请求都要进应用、走 AOP、查 Redis。
对于特别热点的接口,可以考虑:
- 网关先做一层粗过滤
- 应用层再做精细控制
这样能明显降低应用实例和 Redis 的压力。
4. 使用 Lua 而不是多次往返 Redis
这一点前面提过,但值得单独强调:
- 少一次网络往返,就少一次延迟
- 原子性更完整
- 代码逻辑也更集中
高并发下,这种差异会被放大。
进阶结构示意
classDiagram
class RateLimit {
+int limit()
+int windowSeconds()
+String keyType()
+String prefix()
}
class RateLimitAspect {
-StringRedisTemplate stringRedisTemplate
-DefaultRedisScript~Long~ rateLimitScript
+doRateLimit(joinPoint, rateLimit)
-buildKeyPart(request, keyType)
}
class RateLimitKeyUtil {
+getClientIp(request) String
+getUserId(request) String
}
class RedisLuaConfig {
+rateLimitScript() DefaultRedisScript~Long~
}
class DemoController {
+ping()
+profile()
}
RateLimitAspect --> RateLimit
RateLimitAspect --> RateLimitKeyUtil
RateLimitAspect --> RedisLuaConfig
DemoController ..> RateLimit
边界条件与适用范围
这套 Spring Boot + Redis 限流方案很适合:
- 中小型 Java Web 应用
- 需要按用户/IP/接口做细粒度限流
- 已经有 Redis 基础设施
- 希望快速落地、可持续优化
但如果你的场景是下面这些,就要再往上升级:
- 超大规模 API 网关统一治理
- 多地域多活,限流状态要跨机房协调
- 需要复杂配额、租户级资源隔离
- 要做毫秒级精准流控和突发整形
这时可能要考虑:
- 网关原生限流能力
- Sentinel、Envoy、APISIX、Kong 等方案
- 专门的流控中间件
也就是说,Redis 限流很好用,但不是所有问题的最终答案。
总结
我们这篇文章做了几件事:
- 说明了为什么 Java Web 接口需要限流
- 用 Spring Boot + Redis 落地了一个可运行的固定窗口方案
- 用 Lua 保证了计数与过期的原子性
- 给出了验证步骤、常见排查点和生产调优建议
如果你现在要把它用到项目里,我建议按这个顺序推进:
- 先挑 1~2 个高风险接口接入限流
- 优先做“按用户/按 IP + 接口”的细粒度规则
- 接入 429 监控和 Redis 耗时监控
- 观察误伤率和命中率
- 再决定是否升级到滑动窗口或令牌桶
最后给一个很实用的经验:
限流不是越严越好,而是要在“保护系统”和“减少误伤”之间找平衡。
你真正要保护的,不只是接口本身,而是整个系统在异常流量下还能稳定活着。