Java Web 开发中基于 Spring Boot + Redis 实现接口限流与防刷的实战指南
在 Java Web 项目里,“接口被刷”几乎不是会不会发生的问题,而是什么时候发生。
我自己做后台接口时,最早踩过一个很典型的坑:登录短信验证码接口没做限流,结果有人半夜写脚本高频调用,短信平台费用直接飙升。后来我们把接口限流、防重复提交、防恶意刷接口这几件事统一收口到 Spring Boot + Redis 上,效果很稳,成本也低。
这篇文章就按能落地、能运行、能排查的思路,带你从 0 到 1 做一套适合中型项目的接口限流方案。
背景与问题
在实际业务中,接口限流和防刷通常对应下面几类场景:
- 登录接口:防暴力破解密码
- 短信验证码接口:防恶意轰炸
- 下单/抢购接口:防瞬时流量压垮服务
- 公开查询接口:防爬虫、薅资源
- 支付回调或幂等接口:防重复提交
如果不处理,常见后果包括:
- 应用线程被打满,正常用户也不可用
- 数据库连接耗尽,整站雪崩
- 第三方资源被恶意消耗,比如短信、邮件、OCR 次数
- 被脚本绕过前端校验,持续高频调用接口
为什么选 Redis?
很多人第一反应是:在 Java 代码里加个 ConcurrentHashMap 计数不就行了?
开发环境可以,生产通常不够。原因很直接:
- 单机内存方案不支持集群共享
- 应用重启后状态丢失
- 不适合多实例部署
- 过期控制、原子递增、Lua 脚本这些 Redis 天然更合适
所以,Spring Boot 负责接入与拦截,Redis 负责计数、过期和原子控制,这是很常见也很实用的组合。
前置知识与环境准备
本文示例环境:
- JDK 17
- Spring Boot 3.x
- Redis 7.x
- Maven
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.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
application.yml
spring:
data:
redis:
host: 127.0.0.1
port: 6379
timeout: 3000ms
server:
port: 8080
核心原理
接口限流的核心,不是“拦多少”,而是“按什么维度、在什么时间窗、用什么算法拦”。
常见限流维度
通常可以按以下维度组合:
- 按 IP
- 按用户 ID
- 按接口路径
- 按设备标识
- 按业务参数,比如手机号、订单号
在实际项目里,我更建议用组合 key,例如:
rate_limit:login:ip:192.168.1.10
rate_limit:sms:user:10001
rate_limit:sms:mobile:13800000000
常见限流算法
1. 固定窗口
比如“1 分钟内最多 5 次”。
优点:
- 简单
- 好实现
- Redis
INCR + EXPIRE就能做
缺点:
- 窗口边界容易突刺
比如 00:59 调 5 次,01:00 再调 5 次,短时间内相当于 10 次
2. 滑动窗口
更平滑,统计最近一段时间内的请求数。
优点:
- 更接近真实控制
- 比固定窗口更公平
缺点:
- 实现复杂一点
- Redis 需要
ZSET或 Lua 脚本
3. 令牌桶/漏桶
适合更通用的流量整形。
优点:
- 平滑吞吐
- 适合网关层
缺点:
- 业务接口单独实现时复杂度更高
本文选型
这篇文章会给出两种实战方案:
- 固定窗口限流:最适合先落地
- Lua 脚本原子限流:避免并发下
INCR和EXPIRE分离带来的边界问题
整体设计思路
我们先做一个适合业务接口层使用的方案:
- 自定义注解
@RateLimit - AOP 切面统一拦截
- Redis 记录计数
- 超限时直接返回 429
- 支持按 IP、用户 ID、自定义 key 限流
设计流程图
flowchart TD
A[请求进入 Spring Boot] --> B[匹配到控制器方法]
B --> C{是否标注 @RateLimit}
C -- 否 --> D[正常执行业务逻辑]
C -- 是 --> E[构建限流Key]
E --> F[调用 Redis Lua 脚本计数]
F --> G{是否超限}
G -- 否 --> D
G -- 是 --> H[返回 429 Too Many Requests]
时序图
sequenceDiagram
participant Client as 客户端
participant App as Spring Boot
participant AOP as RateLimitAspect
participant Redis as Redis
Client->>App: HTTP 请求
App->>AOP: 进入带 @RateLimit 的方法
AOP->>AOP: 生成限流 key
AOP->>Redis: 执行 Lua 脚本(INCR + EXPIRE)
Redis-->>AOP: 返回当前次数/是否超限
alt 未超限
AOP->>App: 放行
App-->>Client: 正常响应
else 已超限
AOP-->>Client: 429 请求过于频繁
end
实战代码(可运行)
下面我们直接搭一套完整代码。
第一步:定义限流注解
package com.example.demo.ratelimit;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* key 前缀,用于区分不同业务
*/
String key() default "";
/**
* 时间窗口内最大请求次数
*/
long limit();
/**
* 时间窗口大小
*/
long window();
/**
* 时间单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 限流类型:IP / USER / CUSTOM
*/
LimitType limitType() default LimitType.IP;
/**
* 自定义标识,limitType=CUSTOM 时使用
*/
String customKey() default "";
}
第二步:定义限流类型枚举
package com.example.demo.ratelimit;
public enum LimitType {
IP,
USER,
CUSTOM
}
第三步:统一异常类
package com.example.demo.ratelimit;
public class RateLimitException extends RuntimeException {
public RateLimitException(String message) {
super(message);
}
}
第四步:Redis Lua 脚本配置
这里用 Lua 是因为它能保证 Redis 内部执行的原子性。
这是线上项目里很关键的一点,我建议不要把 INCR 和 EXPIRE 拆成两次 Java 调用。
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.script.DefaultRedisScript;
@Configuration
public class RedisLuaConfig {
@Bean
public DefaultRedisScript<Long> rateLimitScript() {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText("""
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local expireTime = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, expireTime)
end
if current > limit then
return 0
else
return current
end
""");
redisScript.setResultType(Long.class);
return redisScript;
}
}
返回值说明:
- 返回
0:表示超限- 返回
1..limit:表示当前计数,允许通过
第五步:获取用户/IP 信息的工具类
为了演示简单,我这里优先从请求头取真实 IP,再回退到 remoteAddr。
package com.example.demo.ratelimit;
import jakarta.servlet.http.HttpServletRequest;
public class RequestUtil {
public static String getClientIp(HttpServletRequest request) {
String[] headers = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_CLIENT_IP"
};
for (String header : headers) {
String ip = request.getHeader(header);
if (ip != null && !ip.isBlank() && !"unknown".equalsIgnoreCase(ip)) {
return ip.split(",")[0].trim();
}
}
return request.getRemoteAddr();
}
/**
* 示例:实际项目中应从 Spring Security / JWT / Session 中获取用户ID
*/
public static String getCurrentUserId(HttpServletRequest request) {
String userId = request.getHeader("X-User-Id");
return (userId == null || userId.isBlank()) ? "anonymous" : userId;
}
}
第六步:实现 AOP 切面
package com.example.demo.ratelimit;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
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;
import java.util.concurrent.TimeUnit;
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {
private final StringRedisTemplate stringRedisTemplate;
private final DefaultRedisScript<Long> rateLimitScript;
@Before("@annotation(rateLimit)")
public void doBefore(JoinPoint joinPoint, RateLimit rateLimit) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.currentRequestAttributes()).getRequest();
String limitKey = buildKey(request, rateLimit);
long expireSeconds = rateLimit.timeUnit().toSeconds(rateLimit.window());
Long result = stringRedisTemplate.execute(
rateLimitScript,
Collections.singletonList(limitKey),
String.valueOf(rateLimit.limit()),
String.valueOf(expireSeconds)
);
if (result == null || result == 0L) {
throw new RateLimitException("请求过于频繁,请稍后再试");
}
}
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
String prefix = "rate_limit";
String bizKey = rateLimit.key().isBlank() ? request.getRequestURI() : rateLimit.key();
return switch (rateLimit.limitType()) {
case IP -> prefix + ":" + bizKey + ":ip:" + RequestUtil.getClientIp(request);
case USER -> prefix + ":" + bizKey + ":user:" + RequestUtil.getCurrentUserId(request);
case CUSTOM -> prefix + ":" + bizKey + ":custom:" + rateLimit.customKey();
};
}
}
第七步:统一异常处理
package com.example.demo.web;
import com.example.demo.ratelimit.RateLimitException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RateLimitException.class)
public ResponseEntity<?> handleRateLimitException(RateLimitException e) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of(
"timestamp", LocalDateTime.now().toString(),
"code", 429,
"message", e.getMessage()
));
}
}
第八步:写一个测试控制器
package com.example.demo.web;
import com.example.demo.ratelimit.LimitType;
import com.example.demo.ratelimit.RateLimit;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@RestController
public class DemoController {
/**
* 按IP限流:10秒内最多访问3次
*/
@GetMapping("/api/test/ip")
@RateLimit(key = "test_ip", limit = 3, window = 10, timeUnit = TimeUnit.SECONDS, limitType = LimitType.IP)
public Map<String, Object> ipLimit() {
return Map.of(
"success", true,
"message", "IP限流接口访问成功"
);
}
/**
* 按用户限流:60秒内最多访问5次
* 测试时带请求头 X-User-Id
*/
@GetMapping("/api/test/user")
@RateLimit(key = "test_user", limit = 5, window = 60, timeUnit = TimeUnit.SECONDS, limitType = LimitType.USER)
public Map<String, Object> userLimit() {
return Map.of(
"success", true,
"message", "用户限流接口访问成功"
);
}
/**
* 模拟短信接口:60秒内同一个接口最多1次
*/
@GetMapping("/api/test/sms")
@RateLimit(key = "sms_send", limit = 1, window = 60, timeUnit = TimeUnit.SECONDS, limitType = LimitType.IP)
public Map<String, Object> sendSms() {
return Map.of(
"success", true,
"message", "短信发送成功(模拟)"
);
}
}
第九步:启动类
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);
}
}
逐步验证清单
代码写完后,别急着说“应该能跑”。建议按下面步骤验证。
1. 启动 Redis
如果你本地有 Docker,可以直接这样起:
docker run -d --name redis-test -p 6379:6379 redis:7
2. 启动 Spring Boot
mvn spring-boot:run
3. 测试按 IP 限流接口
curl http://localhost:8080/api/test/ip
curl http://localhost:8080/api/test/ip
curl http://localhost:8080/api/test/ip
curl http://localhost:8080/api/test/ip
前三次正常,第四次应返回 429。
4. 测试按用户限流
curl -H "X-User-Id: 1001" http://localhost:8080/api/test/user
连续调用 5 次后,第 6 次应被拦截。
5. 查看 Redis 中的 key
redis-cli
keys rate_limit:*
你会看到类似:
rate_limit:test_ip:ip:127.0.0.1
rate_limit:test_user:user:1001
进一步增强:短信/验证码防刷不能只按 IP
这里要特别提醒一句:真正的防刷不能只看 IP。
因为攻击者可能:
- 使用代理池切换 IP
- 多人共用同一出口 IP
- 手机号被单独恶意轰炸
所以验证码或短信接口,通常至少要叠加几层限制:
- 同 IP 每分钟次数限制
- 同用户每日次数限制
- 同手机号发送间隔限制
- 图形验证码/行为验证码
- 设备指纹校验
- 黑名单策略
一个更贴近业务的防刷策略图
flowchart LR
A[短信发送请求] --> B{图形验证码通过?}
B -- 否 --> X[拒绝]
B -- 是 --> C{手机号60秒内是否已发送}
C -- 是 --> X
C -- 否 --> D{同IP分钟级次数是否超限}
D -- 是 --> X
D -- 否 --> E{同用户日发送次数是否超限}
E -- 是 --> X
E -- 否 --> F[发送短信]
常见坑与排查
这一部分非常重要。很多限流“看起来写完了”,但线上不生效,大多栽在这些点上。
1. 获取 IP 不准确
现象
明明不同用户访问,结果都被识别成同一个 IP。
原因
项目部署在 Nginx、网关、负载均衡后面,直接拿到的是代理服务器 IP。
排查方法
打印:
System.out.println(request.getHeader("X-Forwarded-For"));
System.out.println(request.getRemoteAddr());
同时检查 Nginx 是否透传真实 IP:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
我之前就遇到过这个坑:AOP 写得没问题,但所有请求都被网关 IP 命中,结果整批用户误伤。
2. INCR 和 EXPIRE 分开写导致计数脏数据
错误写法示例
Long count = stringRedisTemplate.opsForValue().increment(key);
if (count != null && count == 1) {
stringRedisTemplate.expire(key, 60, TimeUnit.SECONDS);
}
问题
如果 INCR 成功后应用挂了,EXPIRE 没执行,key 可能变成永久存在。
建议
- 用 Lua 脚本保证原子性
- 或使用 Redis 原生命令组合方案
3. CUSTOM 维度写死,导致限流失效
如果 customKey = "abc" 是固定值,那么所有请求共用一个计数器,可能不是你想要的效果。
正确思路
CUSTOM 应该来自业务动态值,例如:
- 手机号
- 订单号
- 设备号
更成熟的做法是支持 SpEL 表达式,例如:
@RateLimit(key = "sms", limit = 1, window = 60, limitType = LimitType.CUSTOM, customKey = "#mobile")
本文先不展开 SpEL 实现,但你要知道这一步在企业项目里很常见。
4. Redis 序列化问题
如果你混用了 RedisTemplate<Object, Object> 和 StringRedisTemplate,key 序列化可能不一致,导致:
- 程序里能写
redis-cli里看不懂- 或者脚本执行 key 对不上
建议
限流场景优先使用:
StringRedisTemplate
简单直接,最不容易出错。
5. 限流返回码不规范
很多项目直接返回 200,然后 body 里写“请求过于频繁”。
这不算错,但对前端、网关、监控不友好。
建议
优先返回:
429 Too Many Requests
这样日志、APM、前端处理都更清晰。
安全/性能最佳实践
限流不是简单“加个注解”就结束了,下面这些建议更接近真实项目。
1. 分层限流,不要只在 Controller 层处理
推荐至少分两层:
- 网关层限流:挡住明显恶意流量
- 应用层限流:处理业务维度,如用户、手机号、订单
如果只有应用层限流,恶意流量依然会打到你的服务实例上。
2. 区分“限流”和“防刷”
两者相关,但不完全一样:
- 限流:控制访问频率
- 防刷:识别恶意行为并阻断
防刷往往还需要结合:
- 验证码
- 签名校验
- Token
- 设备指纹
- 黑白名单
- 风控规则
别指望 Redis 计数器单独解决所有问题。
3. 针对不同接口设置不同阈值
不要全站一个配置。
例如:
- 登录接口:
1 分钟 5 次 - 短信接口:
1 分钟 1 次,1 天 10 次 - 查询接口:
10 秒 20 次 - 导出接口:
5 分钟 2 次
业务价值越高、资源越贵,阈值就该越严格。
4. Redis key 设计要可观测
建议规范 key:
rate_limit:{biz}:{dimension}:{value}
比如:
rate_limit:login:ip:10.0.0.8
rate_limit:sms:user:10086
rate_limit:sms:mobile:13800138000
这样你排查时一眼就能看懂,而不是留下一堆莫名其妙的 key。
5. 热点接口注意 Redis 压力
如果是超高频接口,所有请求都打 Redis,也会有压力。
可考虑:
- 本地缓存 + Redis 双层限流
- 网关侧先挡一层
- 对只读查询场景做边缘缓存
- 使用 Lua 减少网络往返
- 限流 key 设置合理 TTL,避免积压
6. 失败策略要明确
当 Redis 不可用时,系统怎么办?
这不是技术细节,而是业务决策。
常见两种策略:
Fail-Open:放行
优点:
- 不影响主业务可用性
缺点:
- Redis 宕机时限流失效
Fail-Close:拒绝
优点:
- 更安全
缺点:
- Redis 故障会影响可用性
我的建议:
- 登录、短信、验证码等高风险接口:偏向 Fail-Close
- 普通查询接口:偏向 Fail-Open
可扩展方向
如果你准备把这套方案继续做深,可以考虑这些升级点:
1. 支持 SpEL 动态取参数
例如按手机号限流:
@RateLimit(key = "sms", limit = 1, window = 60, limitType = LimitType.CUSTOM, customKey = "#phone")
2. 支持滑动窗口
适合对公平性要求更高的接口。常见实现:
- Redis
ZADD - 记录时间戳
- 定期清理窗口外数据
ZCOUNT判断次数
3. 支持分布式黑名单
比如某个 IP 连续多次触发限流后,自动拉黑一段时间。
4. 与 Spring Security 联动
针对未登录用户按 IP,已登录用户按用户 ID,组合使用效果更好。
一个简单的类关系图
classDiagram
class RateLimit {
+String key()
+long limit()
+long window()
+TimeUnit timeUnit()
+LimitType limitType()
+String customKey()
}
class LimitType {
<<enumeration>>
IP
USER
CUSTOM
}
class RateLimitAspect {
-StringRedisTemplate stringRedisTemplate
-DefaultRedisScript~Long~ rateLimitScript
+doBefore(JoinPoint, RateLimit)
-buildKey(HttpServletRequest, RateLimit) String
}
class RequestUtil {
+getClientIp(HttpServletRequest) String
+getCurrentUserId(HttpServletRequest) String
}
class RateLimitException {
+RateLimitException(String)
}
RateLimitAspect --> RateLimit
RateLimitAspect --> LimitType
RateLimitAspect --> RequestUtil
RateLimitAspect --> RateLimitException
总结
如果你想在 Spring Boot 项目里快速落地接口限流与防刷,我建议按这个顺序来:
- 先用注解 + AOP + Redis Lua 落地固定窗口限流
- 优先覆盖高风险接口:登录、短信、验证码、下单
- 限流维度不要只看 IP,至少叠加用户、手机号等业务维度
- 统一返回 429,方便前端和监控系统识别
- 线上一定验证真实 IP 获取是否正确
- 高风险场景把限流和验证码、黑名单、风控规则组合使用
一句话概括:
Redis 适合做“计数与过期”,Spring Boot 适合做“接入与业务拦截”,两者结合能很高效地实现一套可维护的接口限流方案。
如果你的项目现在还没有任何限流,我建议先把本文这版固定窗口方案用起来。它不一定是最强的,但绝对是投入产出比很高、上线速度很快的一种实现。等业务量和攻击面上来,再逐步升级到滑动窗口、网关限流和风控联动,会更稳。