Java Web 开发中基于 Spring Boot + Redis 实现高并发接口限流的实战方案
接口限流这件事,很多团队都是“出事了才想起来做”。
比如秒杀、登录、短信发送、活动报名、批量查询这类接口,只要流量一上来,没有限流保护,轻则数据库打满、Redis 飙高,重则整个服务雪崩。尤其在 Java Web 项目里,单机限流往往挡不住分布式场景,最后还得落到一个跨节点共享状态的方案上。
这篇文章我从实战角度,带你用 Spring Boot + Redis 做一个可运行的高并发接口限流方案,并重点讲清楚:
- 为什么单机内存计数不够用
- Redis 限流为什么适合分布式部署
- 如何用 Lua 保证限流原子性
- Spring Boot 中如何通过注解 + AOP 快速接入
- 高并发下常见坑怎么排查、怎么优化
这不是只讲概念的文章,后面会直接给出能跑起来的代码。
背景与问题
在 Web 服务里,接口限流通常有几类典型场景:
-
保护系统容量
- 防止突发流量把应用、数据库、下游服务压垮
-
防刷与风控
- 例如短信验证码接口、登录接口、抽奖接口,避免被恶意请求刷爆
-
公平性控制
- 避免单个用户或单个 IP 吃掉全部资源
-
削峰
- 把瞬时洪峰控制在系统能承受的范围内
为什么单机限流不够
很多人一开始会这么做:
- 用
ConcurrentHashMap计数 - 用 Guava RateLimiter
- 用本地令牌桶
在单实例项目里没问题,但一旦你的 Spring Boot 服务是多节点部署,就会立刻遇到几个问题:
- 每台机器计数不一致
- 请求被负载均衡打散
- 扩容后阈值失真
- 服务重启导致状态丢失
这时候就需要一个全局共享、性能高、支持原子操作的中间件,Redis 正好适合这个位置。
方案对比与取舍分析
在开始写代码前,先把几种常见方案摆清楚。
方案一:单机内存限流
优点:
- 实现简单
- 性能高
- 无外部依赖
缺点:
- 不适合分布式
- 重启后状态丢失
- 无法全局统一限流
适用场景:
- 单机服务
- 边缘节点本地保护
- 对精度要求不高
方案二:Nginx / 网关层限流
优点:
- 靠近流量入口
- 能提前拦截恶意请求
- 减少应用层压力
缺点:
- 规则颗粒度有限
- 业务维度不灵活
- 对“用户 ID、业务参数”等细粒度控制不方便
适用场景:
- 全局 QPS 控制
- IP 级防刷
- 基础防护
方案三:Redis 分布式限流
优点:
- 支持多实例共享限流状态
- 原子操作强
- 规则灵活,可按用户、IP、接口、租户等维度限流
缺点:
- 依赖 Redis
- 设计不当会有性能和精度问题
- 热点 key 需要关注
适用场景:
- Spring Boot 多实例部署
- 业务级限流
- 需要精细化控制
本文的选择
本文采用:
- Spring Boot
- Redis
- Lua 脚本
- 注解 + AOP
- 固定时间窗口限流
为什么先选固定窗口?
因为它够简单、够好落地,很多中型业务已经够用。如果后续要更平滑,可以演进到滑动窗口或令牌桶。
核心原理
固定窗口限流思路
核心规则很简单:
在一个时间窗口内,例如 60 秒,某个 key 最多允许访问 100 次。
流程如下:
- 根据规则生成限流 key,例如:
rate_limit:/api/sendCode:uid_1001
- Redis 对这个 key 进行计数
- 第一次访问时设置过期时间,例如 60 秒
- 每次请求计数加 1
- 如果计数超过阈值,则拒绝请求
这个方案的关键点,不是“会不会写 INCR”,而是:
计数 + 设置过期时间必须是原子操作
否则在高并发下会出现:
- 计数成功了,但没来得及设置过期时间
- key 变成永久存在
- 后续一直被限流
这类问题我在早期项目里真踩过,排查起来非常恶心。
为什么要用 Lua 脚本
如果你用 Java 分两步执行:
INCR keyEXPIRE key 60
那这两步之间可能被打断。
而 Redis Lua 脚本的好处是:
- 在 Redis 端一次执行
- 天然原子
- 减少网络往返
- 高并发场景下更稳
限流流程图
flowchart TD
A[客户端请求接口] --> B[Spring Boot AOP拦截]
B --> C[生成限流Key]
C --> D[执行Redis Lua脚本]
D --> E{是否超过阈值}
E -- 否 --> F[放行业务逻辑]
E -- 是 --> G[返回429或业务错误码]
时序图:一次请求如何被限流
sequenceDiagram
participant Client as 客户端
participant App as Spring Boot
participant AOP as 限流切面
participant Redis as Redis
Client->>App: HTTP 请求
App->>AOP: 进入被注解方法
AOP->>AOP: 解析限流规则与维度
AOP->>Redis: EVAL Lua(计数+过期)
Redis-->>AOP: 返回是否允许
alt 允许
AOP->>App: 放行
App-->>Client: 正常响应
else 拒绝
AOP-->>Client: 429/限流提示
end
容量估算:上线前别拍脑袋
做限流时,除了规则,还要想 Redis 会承受多少压力。
假设:
- 应用集群总流量:
20,000 QPS - 其中有
30%的请求需要走限流逻辑 - 每个请求做
1次 Lua 执行
那么 Redis 每秒要承受:
20,000 × 30% = 6,000 次限流操作/秒
如果是单个 Redis 实例,这通常还在可接受范围内;但如果你把大量高频接口、用户维度、IP 维度都压上去,就要关注:
- Redis CPU
- 网络 RTT
- 热点 key
- 慢查询
- 集群分片均衡
经验上:
- 中等规模项目,单实例 Redis 往往能扛住
- 大流量场景,建议做多维度拆分 + 网关预限流 + 应用层细粒度限流的组合方案
实战代码(可运行)
下面给一套简化但完整的 Spring Boot 实战代码。
项目依赖
如果你使用 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-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
</dependencies>
application.yml 配置
server:
port: 8080
spring:
redis:
host: 127.0.0.1
port: 6379
timeout: 2000ms
定义限流注解
package com.example.ratelimit.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 限流key前缀
*/
String key() default "";
/**
* 时间窗口,单位:秒
*/
int window() default 60;
/**
* 最大请求次数
*/
int max() default 100;
/**
* 限流维度:IP / USER / GLOBAL
*/
LimitType limitType() default LimitType.IP;
}
定义限流维度枚举
package com.example.ratelimit.annotation;
public enum LimitType {
IP,
USER,
GLOBAL
}
Redis Lua 脚本
这个脚本做三件事:
- 获取当前计数
- 未超限则自增
- 第一次自增时设置过期时间
local key = KEYS[1]
local max = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('GET', key)
if current and tonumber(current) >= max then
return 0
end
current = redis.call('INCR', key)
if tonumber(current) == 1 then
redis.call('EXPIRE', key, window)
end
return 1
Lua 脚本配置类
package com.example.ratelimit.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
@Configuration
public class RedisLuaConfig {
@Bean
public DefaultRedisScript<Long> rateLimitScript() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setLocation(new ClassPathResource("lua/rate_limit.lua"));
redisScript.setResultType(Long.class);
return redisScript;
}
}
将脚本放在:
src/main/resources/lua/rate_limit.lua
获取客户端 IP 工具类
这里做一个基础版本,生产环境通常还要结合网关和可信代理配置。
package com.example.ratelimit.util;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.StringUtils;
public class IpUtil {
public static String getIp(HttpServletRequest request) {
String[] headers = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"X-Real-IP"
};
for (String header : headers) {
String value = request.getHeader(header);
if (StringUtils.isNotBlank(value) && !"unknown".equalsIgnoreCase(value)) {
return value.split(",")[0].trim();
}
}
return request.getRemoteAddr();
}
}
Redis 限流服务
package com.example.ratelimit.service;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Collections;
@Service
public class RedisRateLimitService {
private final StringRedisTemplate stringRedisTemplate;
private final DefaultRedisScript<Long> rateLimitScript;
public RedisRateLimitService(StringRedisTemplate stringRedisTemplate,
DefaultRedisScript<Long> rateLimitScript) {
this.stringRedisTemplate = stringRedisTemplate;
this.rateLimitScript = rateLimitScript;
}
public boolean tryAcquire(String key, int max, int window) {
Long result = stringRedisTemplate.execute(
rateLimitScript,
Collections.singletonList(key),
String.valueOf(max),
String.valueOf(window)
);
return result != null && result == 1L;
}
}
AOP 切面实现
package com.example.ratelimit.aspect;
import com.example.ratelimit.annotation.LimitType;
import com.example.ratelimit.annotation.RateLimit;
import com.example.ratelimit.service.RedisRateLimitService;
import com.example.ratelimit.util.IpUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Aspect
@Component
public class RateLimitAspect {
private final RedisRateLimitService redisRateLimitService;
public RateLimitAspect(RedisRateLimitService redisRateLimitService) {
this.redisRateLimitService = redisRateLimitService;
}
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint point, RateLimit rateLimit) throws Throwable {
HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
String limitKey = buildKey(request, rateLimit);
boolean allowed = redisRateLimitService.tryAcquire(
limitKey,
rateLimit.max(),
rateLimit.window()
);
if (!allowed) {
throw new RuntimeException("请求过于频繁,请稍后再试");
}
return point.proceed();
}
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
String uri = request.getRequestURI();
String prefix = "rate_limit:" + uri;
if (rateLimit.key() != null && !rateLimit.key().isEmpty()) {
prefix = "rate_limit:" + rateLimit.key();
}
LimitType type = rateLimit.limitType();
switch (type) {
case IP:
return prefix + ":ip:" + IpUtil.getIp(request);
case USER:
String userId = request.getHeader("X-User-Id");
if (userId == null || userId.isEmpty()) {
userId = "anonymous";
}
return prefix + ":user:" + userId;
case GLOBAL:
default:
return prefix + ":global";
}
}
}
统一异常返回
实际项目里不要直接把 RuntimeException 原样抛给前端,建议统一包装。
package com.example.ratelimit.controller;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public Map<String, Object> handleRuntimeException(RuntimeException e) {
Map<String, Object> result = new HashMap<>();
result.put("code", 429);
result.put("message", e.getMessage());
return result;
}
}
测试控制器
package com.example.ratelimit.controller;
import com.example.ratelimit.annotation.LimitType;
import com.example.ratelimit.annotation.RateLimit;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
public class DemoController {
@GetMapping("/api/sms/send")
@RateLimit(key = "sms_send", window = 60, max = 5, limitType = LimitType.IP)
public Map<String, Object> sendSms() {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "短信发送成功");
return result;
}
@GetMapping("/api/order/query")
@RateLimit(key = "order_query", window = 10, max = 20, limitType = LimitType.USER)
public Map<String, Object> queryOrder(@RequestHeader(value = "X-User-Id", required = false) String userId) {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("userId", userId);
result.put("message", "订单查询成功");
return result;
}
}
启动类
package com.example.ratelimit;
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);
}
}
如何验证
测试 IP 限流接口
60 秒内访问 5 次:
curl http://localhost:8080/api/sms/send
多次执行后,你会看到:
{"code":429,"message":"请求过于频繁,请稍后再试"}
测试用户维度限流
curl -H "X-User-Id: 1001" http://localhost:8080/api/order/query
如果换一个用户 ID:
curl -H "X-User-Id: 1002" http://localhost:8080/api/order/query
它们会分别计数。
类关系图
classDiagram
class RateLimit {
+String key
+int window
+int max
+LimitType limitType
}
class RateLimitAspect {
+around(point, rateLimit) Object
-buildKey(request, rateLimit) String
}
class RedisRateLimitService {
+tryAcquire(key, max, window) boolean
}
class DemoController
RateLimitAspect --> RedisRateLimitService
DemoController ..> RateLimit
核心实现细节拆解
上面的代码能跑,但真正理解之后,后续扩展才不费劲。
1. 限流 key 设计比代码本身更重要
建议 key 结构保持统一,例如:
rate_limit:{业务}:{接口}:{维度}:{标识}
例子:
rate_limit:marketing:sms_send:ip:10.1.1.8
rate_limit:order:query:user:1001
rate_limit:login:submit:global
这样做的好处:
- 便于排查
- 便于监控
- 便于批量统计
- 便于后续迁移规则
2. 维度选择要结合业务
常见维度:
- IP 维度:适合匿名接口、防刷
- 用户维度:适合登录后接口、公平性控制
- 全局维度:适合保护系统总吞吐
- 租户维度:多租户系统常见
- 设备维度:移动端防刷常用
不要一上来全按 IP 限流,因为在 NAT、公司出口网络、移动网络场景下,很多用户可能共享一个出口 IP,会误伤。
3. 固定窗口的边界问题
固定窗口虽然简单,但存在一个经典问题:
- 在窗口结束前 1 秒来了 100 个请求
- 下一个窗口开始后 1 秒又来了 100 个请求
理论上 2 秒内通过了 200 个请求,瞬时并不平滑。
如果你的业务对平滑性特别敏感,可以升级为:
- 滑动窗口
- 漏桶
- 令牌桶
但对很多中级业务系统来说,固定窗口已经足够实用,尤其是防刷场景。
常见坑与排查
这一部分很关键。因为限流代码通常不复杂,复杂的是线上行为。
坑一:Redis key 没设置过期时间
现象
某些用户或 IP 被永久限流,过了很久都没恢复。
原因
用 INCR + EXPIRE 分开执行,执行中断导致只加计数没加过期。
排查
在 Redis 中查看:
TTL rate_limit:sms_send:ip:127.0.0.1
如果返回 -1,说明没有过期时间。
解决
必须改成 Lua 原子执行。
坑二:获取到的客户端 IP 不真实
现象
明明多个用户访问,结果全被算成同一个 IP。
原因
服务部署在 Nginx、网关、SLB 后面,直接取到的是代理层 IP。
排查
打印这些值:
X-Forwarded-ForX-Real-IPrequest.getRemoteAddr()
解决
- 让网关正确透传真实 IP
- 应用层只信任可信代理头
- 不要盲目信任客户端自带的转发头
这个问题很常见,我建议你上线前一定在测试环境把真实链路走一遍。
坑三:限流规则误伤正常用户
现象
用户频繁投诉“我没怎么点就被拦了”。
原因
- 阈值设置过低
- 时间窗口过长
- 维度选错,比如把共享 IP 当成个人身份
排查思路
查看:
- 限流 key 分布
- 某接口的请求峰值
- 某个 key 的累计命中次数
- 是否集中在特定出口网络
解决
- 对匿名接口用 IP 维度
- 对登录接口用用户维度
- 高价值操作加图形验证码或二次校验
- 用灰度方式逐步收紧阈值
坑四:热点 key 导致 Redis 压力异常
现象
某个接口做全局限流时,Redis 单 key 访问过于集中。
原因
例如:
rate_limit:submit_order:global
所有请求都打到这一个 key 上。
解决思路
- 只在确实需要时使用全局限流
- 更细粒度按用户、租户拆分
- 网关层先挡一层
- 超高并发时引入本地预限流
坑五:限流失败时直接抛 500
现象
前端看到的是服务错误,而不是“访问过快”。
原因
没区分业务异常和系统异常。
解决
建议定义专门的限流异常,并返回统一状态码,如:
- HTTP
429 Too Many Requests - 业务码如
RATE_LIMITED
安全/性能最佳实践
限流不只是“拦住请求”,更要考虑误伤率、可观测性和故障降级。
1. 返回 429 比返回 500 更合理
如果是 HTTP API,建议明确返回:
429 Too Many Requests
配合响应体说明:
- 限流原因
- 建议重试时间
- 业务错误码
这样前端和调用方更容易处理。
2. 重要接口建议多层限流
一个比较稳妥的架构是:
- 网关层:挡掉明显恶意流量
- 应用层 Redis 限流:做业务维度精细控制
- 下游服务保护:线程池、熔断、隔离
也就是说,不要把所有保护压力都压给 Redis 限流。
3. 限流失败策略要提前想好
如果 Redis 挂了,限流逻辑怎么办?
常见有两种策略:
策略 A:失败放行
优点:
- 保证主流程可用
缺点:
- 失去保护能力,可能流量冲垮系统
适合:
- 非核心限流
- 对可用性要求极高的读接口
策略 B:失败拒绝
优点:
- 更安全,能保护后端
缺点:
- Redis 故障会直接影响业务
适合:
- 短信发送
- 登录风控
- 支付敏感接口
我的建议是:按接口分级,不要一刀切。
4. 加监控,否则限流等于“黑盒”
建议至少监控这些指标:
- 限流总请求数
- 被拦截次数
- 各接口限流命中率
- Redis Lua 执行耗时
- Redis 异常次数
- 热点 key 排名
这样你才能判断:
- 是规则太严了
- 还是流量真的异常
- 还是 Redis 本身有瓶颈
5. 对高并发热点接口做本地 + Redis 双层限流
在超高并发下,可以考虑:
- 第一层:JVM 本地快速限流
- 第二层:Redis 全局限流
这么做的好处:
- 降低 Redis 压力
- 避免所有请求都远程打 Redis
- 在极端洪峰下更稳
边界条件是:
- 本地限流只是预过滤,不能替代全局限流
- 阈值设计要避免双重误伤
6. 敏感接口不要只靠限流
像短信、登录、注册、优惠券领取这类接口,单纯限流不够,最好组合:
- 验证码
- 签名校验
- 用户行为分析
- 设备指纹
- 黑名单
- 风控策略
限流只是基础防线,不是全部安全方案。
可进一步演进的方向
如果你已经把固定窗口跑通,后续可以继续优化。
1. 滑动窗口限流
优势:
- 更平滑
- 边界突刺更少
常见做法:
- Redis ZSet 记录时间戳
- 删除窗口外数据
- 统计窗口内请求数
代价:
- 实现更复杂
- Redis 开销更高
2. 令牌桶限流
优势:
- 更适合控制平均速率
- 允许一定突发流量
适合:
- API 平台
- 网关流控
- 下游依赖保护
3. 动态配置限流规则
把规则从注解硬编码演进成配置中心下发,比如:
- Nacos
- Apollo
- 数据库存储 + 本地缓存
这样就能做到:
- 不发版改限流阈值
- 临时收紧热点接口
- 分环境、分租户、分用户组控制
一个更贴近生产的建议
如果你问我“实际项目怎么落地最稳”,我的答案通常是:
- 先用注解 + AOP 把核心接口保护起来
- 按用户/IP 两种维度分别建规则
- 统一返回 429 和友好提示
- 把限流命中日志和监控补齐
- 热点接口再叠加网关限流
- 对短信、登录等敏感接口增加验证码/风控
不要一开始就追求最复杂的算法,先把能挡住 80% 风险的方案做稳,收益最高。
总结
基于 Spring Boot + Redis 做高并发接口限流,本质上是在分布式场景下,用 Redis 维护共享计数状态,再借助 Lua 保证操作原子性。这个方案之所以常用,是因为它有几个很现实的优点:
- 接入成本不高
- 对 Spring Boot 项目友好
- 能支持多实例部署
- 规则维度灵活
- 在大多数业务场景下足够实用
落地时最关键的几点,我建议你记住:
- 不要用非原子的
INCR + EXPIRE - 限流 key 设计要清晰可排查
- 维度选择要贴合业务,不要迷信 IP
- 返回码、监控、降级策略必须配套
- 超高并发场景考虑网关层 + 本地层 + Redis 层组合防护
如果你的项目目前还没有限流,我建议优先从以下接口开始:
- 登录
- 短信发送
- 验证码
- 订单提交
- 活动报名
- 导出查询
这些地方最容易被流量洪峰和恶意请求打穿,也是限流最能立刻见效的地方。
限流不是“写完一个注解就结束”,而是系统稳定性设计的一部分。把它做好,很多线上事故其实能提前挡在门外。