背景与问题
做 Java Web 接口时,大家通常先关注“能不能用”,但服务一上线,很快就会遇到另一个更现实的问题:有没有人把它打挂。
常见场景包括:
- 登录接口被暴力破解
- 短信验证码接口被恶意刷取
- 秒杀、抢券接口被瞬时高并发冲垮
- 某些公开查询接口被爬虫高频调用
- 内部接口因为调用方 bug 进入“死循环重试”
如果没有限流和防刷,后果往往不是“接口慢一点”这么简单,而是:
- Redis、MySQL 连接池被打满
- 应用线程阻塞,服务雪崩
- 验证码、短信等第三方成本飙升
- 用户体验急剧下降
- 安全风险被放大
在单机时代,我们可能会先想到用 synchronized、本地计数器、Guava RateLimiter。但一旦进入多实例部署,这些方案就不够了。分布式环境下,Redis 是非常适合做接口限流与防刷的基础设施。
这篇文章我会用一个比较实战的角度,带你从 0 到 1 做出一个:
- 支持按 IP / 用户 / 接口维度限流
- 基于 Spring Boot 接入
- 基于 Redis 保证多实例下计数一致
- 代码可运行、易扩展
- 适合中小型业务直接落地
前置知识与环境准备
本文默认你已经了解:
- Spring Boot 基础使用
- Redis 基本命令
- Java 注解、AOP、拦截器的概念
- Maven 或 Gradle 依赖管理
本文示例环境:
- JDK 8+
- Spring Boot 2.x
- Redis 5.x / 6.x
- Maven
一个典型调用链如下:
flowchart LR
A[客户端请求] --> B[Spring Boot Controller]
B --> C[限流拦截器/切面]
C --> D[Redis 计数]
D --> E{是否超限}
E -- 否 --> F[业务逻辑]
E -- 是 --> G[返回 429 或业务错误码]
核心原理
接口限流常见算法有几种:
- 固定窗口
- 滑动窗口
- 漏桶
- 令牌桶
在 Web 接口防刷里,如果目标是“快速上手、低成本落地、能覆盖大多数业务场景”,我通常建议先用:
- 固定窗口计数:实现最简单
- 配合 Redis 的
INCR+EXPIRE - 对登录、验证码、公开查询接口已经足够实用
1. 固定窗口计数的思路
比如要求:
- 同一 IP
- 在 60 秒内
- 最多访问
/api/sms/send5 次
实现方式:
- 用 Redis Key 记录计数,例如
rate_limit:ip:127.0.0.1:/api/sms/send - 第一次访问时:
INCR keyEXPIRE key 60
- 后续每次访问继续
INCR - 如果计数值大于 5,则拒绝请求
这个方法的好处是:
- 简单
- 性能高
- 容易接入 Spring Boot
不足也要知道:
- 存在“窗口临界突刺”问题
比如某用户在第 59 秒访问 5 次,第 61 秒再访问 5 次,短时间内可能打到 10 次
对于大多数后台接口、验证码接口、防爬接口,这个问题通常是可接受的。真要更精细,再升级滑动窗口或令牌桶。
2. 为什么 Redis 很适合做限流
Redis 的优势主要有三点:
- 原子性强:
INCR天然适合计数 - 性能高:内存操作,QPS 非常可观
- 天然分布式共享:多台 Spring Boot 实例看到的是同一份计数
3. 限流维度怎么选
实践中不要只考虑“按 IP 限流”,而是要根据接口类型组合维度:
| 场景 | 推荐维度 |
|---|---|
| 登录接口 | IP + 用户名 |
| 短信发送 | 手机号 + IP |
| 下单接口 | 用户 ID |
| 公开查询接口 | IP |
| 管理后台接口 | 用户 ID + URI |
我踩过一个比较典型的坑:只按 IP 限流。结果公司出口 NAT 下很多正常用户共用一个公网 IP,被一起误伤。
所以真正落地时,建议根据业务特征做多维组合。
方案设计
这里我们做一个可复用方案,设计目标是:
- 用注解声明限流规则
- 用 AOP 在方法执行前拦截
- 用 Redis 做统一计数
- 支持自定义 Key:IP、用户、URI
- 超限时抛出统一异常
整体结构如下:
classDiagram
class RateLimit {
+int max
+int windowSeconds
+String keyPrefix
+LimitType limitType
+String message
}
class LimitType {
<<enumeration>>
IP
USER
URI
CUSTOM
}
class RateLimitAspect {
+Object around()
-String buildKey()
}
class RedisRateLimiterService {
+boolean tryAcquire(String key, int windowSeconds, int max)
}
class DemoController {
+String sendSms()
}
RateLimitAspect --> RedisRateLimiterService
DemoController --> RateLimit
RateLimit --> LimitType
实战代码(可运行)
下面直接给出一套可落地的示例。
1. 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>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
2. application.yml
server:
port: 8080
spring:
redis:
host: 127.0.0.1
port: 6379
timeout: 3000
3. 限流维度枚举
package com.example.demo.ratelimit;
public enum LimitType {
IP,
USER,
URI,
CUSTOM
}
4. 自定义注解
package com.example.demo.ratelimit;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
int max();
int windowSeconds();
String keyPrefix() default "rate_limit";
LimitType limitType() default LimitType.IP;
String message() default "请求过于频繁,请稍后再试";
}
5. Redis 限流服务
先用最容易理解的实现:INCR 后首次设置过期时间。
package com.example.demo.ratelimit;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class RedisRateLimiterService {
private final StringRedisTemplate stringRedisTemplate;
public RedisRateLimiterService(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public boolean tryAcquire(String key, int windowSeconds, int max) {
Long current = stringRedisTemplate.opsForValue().increment(key);
if (current == null) {
return false;
}
if (current == 1L) {
stringRedisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS);
}
return current <= max;
}
}
说明:
严格来说,increment和expire分成两步,极端情况下可能出现原子性问题。本文先带你跑通,后面“安全/性能最佳实践”里会给出 Lua 脚本版。
6. 获取请求信息工具类
package com.example.demo.ratelimit;
import javax.servlet.http.HttpServletRequest;
public class RequestUtil {
public static String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) {
return ip.split(",")[0].trim();
}
ip = request.getHeader("X-Real-IP");
if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) {
return ip;
}
return request.getRemoteAddr();
}
}
7. 限流异常
package com.example.demo.ratelimit;
public class RateLimitException extends RuntimeException {
public RateLimitException(String message) {
super(message);
}
}
8. 全局异常处理
package com.example.demo.ratelimit;
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(RateLimitException.class)
public Map<String, Object> handleRateLimitException(RateLimitException e) {
Map<String, Object> result = new HashMap<>();
result.put("code", 429);
result.put("message", e.getMessage());
return result;
}
}
9. AOP 切面实现
package com.example.demo.ratelimit;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Aspect
@Component
public class RateLimitAspect {
private final RedisRateLimiterService redisRateLimiterService;
public RateLimitAspect(RedisRateLimiterService redisRateLimiterService) {
this.redisRateLimiterService = redisRateLimiterService;
}
@Around("@annotation(com.example.demo.ratelimit.RateLimit)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RateLimit rateLimit = method.getAnnotation(RateLimit.class);
HttpServletRequest request = getRequest();
String key = buildKey(rateLimit, request);
boolean allowed = redisRateLimiterService.tryAcquire(
key,
rateLimit.windowSeconds(),
rateLimit.max()
);
if (!allowed) {
throw new RateLimitException(rateLimit.message());
}
return joinPoint.proceed();
}
private HttpServletRequest getRequest() {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
if (attributes == null) {
throw new IllegalStateException("无法获取当前请求上下文");
}
return ((ServletRequestAttributes) attributes).getRequest();
}
private String buildKey(RateLimit rateLimit, HttpServletRequest request) {
String prefix = rateLimit.keyPrefix();
String uri = request.getRequestURI();
switch (rateLimit.limitType()) {
case IP:
return prefix + ":ip:" + RequestUtil.getClientIp(request) + ":" + uri;
case USER:
String userId = request.getHeader("X-User-Id");
if (userId == null || userId.trim().isEmpty()) {
userId = "anonymous";
}
return prefix + ":user:" + userId + ":" + uri;
case URI:
return prefix + ":uri:" + uri;
case CUSTOM:
String custom = request.getParameter("key");
if (custom == null || custom.trim().isEmpty()) {
custom = "default";
}
return prefix + ":custom:" + custom + ":" + uri;
default:
return prefix + ":unknown:" + uri;
}
}
}
10. Controller 示例
package com.example.demo.controller;
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;
@RestController
public class DemoController {
@GetMapping("/api/sms/send")
@RateLimit(max = 5, windowSeconds = 60, limitType = LimitType.IP, message = "短信发送过于频繁")
public String sendSms() {
return "短信发送成功";
}
@GetMapping("/api/login")
@RateLimit(max = 10, windowSeconds = 60, limitType = LimitType.IP, message = "登录请求过于频繁")
public String login() {
return "登录接口访问成功";
}
@GetMapping("/api/order/query")
@RateLimit(max = 20, windowSeconds = 60, limitType = LimitType.USER, message = "查询过于频繁")
public String queryOrder() {
return "订单查询成功";
}
}
11. 启动类
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
redis-server
或者本地已有 Redis 服务,确保 6379 可连通。
2. 启动 Spring Boot
mvn spring-boot:run
3. 连续调用测试接口
curl http://localhost:8080/api/sms/send
连续调用 6 次,前 5 次应该成功,第 6 次返回类似:
{
"code": 429,
"message": "短信发送过于频繁"
}
4. 查看 Redis Key
redis-cli keys "rate_limit:*"
你会看到类似:
rate_limit:ip:127.0.0.1:/api/sms/send
再看计数值:
redis-cli get "rate_limit:ip:127.0.0.1:/api/sms/send"
5. 模拟用户维度限流
curl -H "X-User-Id: 1001" http://localhost:8080/api/order/query
curl -H "X-User-Id: 1002" http://localhost:8080/api/order/query
两个用户应该使用不同的 Redis Key,不互相影响。
调用时序图
为了更直观看清楚整个流程,可以看下面这张时序图:
sequenceDiagram
participant C as Client
participant A as RateLimitAspect
participant R as Redis
participant S as Service/Controller
C->>A: 发起 HTTP 请求
A->>A: 解析 @RateLimit 注解
A->>A: 构造限流 Key
A->>R: INCR key
alt 首次访问
A->>R: EXPIRE key windowSeconds
end
R-->>A: 返回当前计数
alt 未超限
A->>S: 放行业务请求
S-->>C: 正常响应
else 已超限
A-->>C: 抛出限流异常/返回429
end
常见坑与排查
这一部分很重要,因为很多限流方案不是“不会写”,而是“写完发现和预期不一样”。
1. 代理层拿不到真实 IP
现象
明明不同用户访问,却都命中了同一个 IP 限流。
原因
请求经过 Nginx、SLB、网关转发后,request.getRemoteAddr() 可能拿到的是代理服务器 IP。
排查方法
打印以下请求头:
X-Forwarded-ForX-Real-IP
解决建议
Nginx 配置真实 IP 透传,例如:
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:8080;
}
注意:如果你的应用直接信任
X-Forwarded-For,要确保它来自可信代理,否则可能被伪造。
2. Key 设计过粗,误伤正常用户
现象
接口限流后,大量用户反馈“没怎么点就被拦了”。
常见原因
- 只按 URI 限流,所有人共享一个窗口
- 公司出口统一 NAT,多个用户共用公网 IP
- APP 网关统一出口,导致 IP 维度失真
建议
不同场景用不同粒度:
- 登录:IP + 用户名
- 短信:手机号 + IP
- 下单:用户 ID
- 匿名公开接口:IP
- 后台管理:用户 ID + URI
3. AOP 不生效
现象
注解加上了,但请求没有被限流。
排查方向
- 是否引入
spring-boot-starter-aop - 切面类是否被 Spring 扫描到
- 注解是否加在
public方法上 - 是否发生了同类内部调用,导致 AOP 代理失效
说明
如果一个类里 a() 方法内部直接调用同类的 b() 方法,而 b() 上有注解,AOP 可能不会生效。
这个坑在 Spring 里非常常见,我自己第一次做权限和限流时就踩过。
4. Redis Key 不过期,数量越来越多
现象
Redis 里堆积了很多限流 Key。
原因
INCR 和 EXPIRE 不是原子操作,如果应用在 INCR 后崩溃,可能导致部分 Key 没设置 TTL。
解决思路
- 用 Lua 脚本把
INCR + EXPIRE封装成原子操作 - 定期巡检 TTL 异常 Key
后面会给 Lua 版实现。
5. 集群环境下时间窗口不一致?
这个问题在固定窗口计数里一般不是“系统时间不同步”,而是:
- 不同实例各自用本地内存限流,导致统计不统一
- 多个 Redis 分片策略不一致,Key 跑到了错误节点
如果用统一 Redis,并且 Key 规则一致,这类问题通常比较少见。
安全/性能最佳实践
真正上线时,不建议只停留在 demo 级实现。下面这些是更稳妥的做法。
1. 用 Lua 脚本保证原子性
我们把 INCR 和 EXPIRE 放进 Lua 脚本,一次性执行。
Lua 脚本
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return 0
else
return 1
end
Spring Boot 调用方式
package com.example.demo.ratelimit;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Service;
import java.util.Collections;
@Service
public class RedisRateLimiterService {
private final StringRedisTemplate stringRedisTemplate;
private final DefaultRedisScript<Long> redisScript;
public RedisRateLimiterService(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
this.redisScript = new DefaultRedisScript<>();
this.redisScript.setResultType(Long.class);
this.redisScript.setScriptSource(
new ResourceScriptSource(new ClassPathResource("lua/rate_limit.lua"))
);
}
public boolean tryAcquire(String key, int windowSeconds, int max) {
Long result = stringRedisTemplate.execute(
redisScript,
Collections.singletonList(key),
String.valueOf(windowSeconds),
String.valueOf(max)
);
return result != null && result == 1L;
}
}
资源文件位置
src/main/resources/lua/rate_limit.lua
这样即使服务在执行过程中异常退出,也不会留下没有过期时间的脏 Key。
2. 区分“限流”和“防刷”
很多团队会把这两个概念混在一起,但实际目标不同:
- 限流:保护系统,防止流量过大
- 防刷:防止恶意重复操作,比如刷短信、刷券、撞库
所以实际策略应该分层:
- 网关层:按 IP / URI 做粗粒度限流
- 应用层:按业务身份做精细限流
- 风控层:设备指纹、验证码、黑名单、行为分析
也就是说,Redis 限流是很重要的一层,但不是全部。
3. 不同接口,不要一个阈值打天下
一个常见错误是:
- 所有接口统一设成 “60 秒 100 次”
这看似省事,实际上非常不靠谱。
建议按接口敏感度分级:
| 接口类型 | 建议策略 |
|---|---|
| 登录接口 | 严格,按 IP + 用户名 |
| 短信验证码 | 很严格,按手机号 + IP |
| 普通查询接口 | 中等,按用户或 IP |
| 静态资源/公开接口 | 宽松或交给网关 |
| 后台管理操作 | 严格,按用户 ID |
4. 返回明确错误码与剩余信息
如果前后端协作比较完整,可以考虑返回:
- 错误码:429
- 提示信息
- 剩余等待时间
- 当前限流规则
例如:
{
"code": 429,
"message": "请求过于频繁,请 30 秒后重试"
}
这样前端能更友好地处理,也方便排查。
5. 给限流打监控
别让限流逻辑成为“黑盒”。建议至少监控:
- 每分钟触发限流次数
- 被限流最多的接口 TopN
- 被限流最多的 IP / 用户 TopN
- Redis 调用耗时
- Redis Key 总量变化
很多时候,限流告警本身就是攻击或异常流量的第一信号。
6. 热点接口优先放在网关层兜底
如果你的流量特别大,仅靠应用层 AOP 做限流,应用本身已经接收到请求了,保护成本偏高。更合理的做法是:
- 网关/Nginx 层做第一道粗限流
- Spring Boot 应用层做第二道精细业务限流
- Redis 做共享状态
这样更稳。
7. 注意 Redis 自身可用性
如果 Redis 挂了怎么办?
这是一个必须提前想清楚的问题。通常有两种策略:
失败放行(Fail Open)
- Redis 不可用时,接口继续请求
- 优点:业务不中断
- 缺点:保护失效,可能被刷爆
失败拒绝(Fail Close)
- Redis 不可用时,直接拒绝请求
- 优点:系统更安全
- 缺点:影响正常用户
我的建议:
- 普通查询接口:可以考虑失败放行
- 短信、登录、下单、支付类高风险接口:更倾向失败拒绝或降级处理
边界条件一定要提前定好,不要等线上出故障时再拍脑袋决定。
进阶:什么时候该升级到滑动窗口或令牌桶
如果你已经发现固定窗口不够用了,通常有以下信号:
- 临界窗口突刺带来明显流量抖动
- 需要更平滑地控制速率
- 需要支持突发流量和平均速率并存
- 需要更接近网关级别的精细限流
简单判断:
- 固定窗口:实现简单,适合大多数业务接口
- 滑动窗口:统计更精细,适合对公平性要求更高的场景
- 令牌桶:更适合控制平均速率并允许有限突发
教程第一版我建议先把固定窗口方案落稳,再根据业务指标决定是否升级,别一上来就堆复杂度。
总结
这篇文章我们完整走了一遍 Spring Boot + Redis 实现接口限流与防刷的实战方案,核心点可以归纳成 5 句话:
- 分布式限流优先考虑 Redis,因为它天然适合做共享计数。
- 固定窗口是最容易落地的第一步,对登录、短信、查询等场景很实用。
- 限流维度比算法本身更重要,别只会按 IP 一刀切。
- 上线要用 Lua 保证原子性,并配合监控、异常处理、代理 IP 识别。
- 限流不是全部安全方案,它应该和验证码、黑名单、风控策略一起使用。
如果你现在要在项目里真正落地,我建议按这个顺序做:
- 先接入本文的注解 + AOP + Redis 版固定窗口
- 先覆盖最容易被刷的 2~3 个接口:登录、短信、公开查询
- 再补 Lua 原子脚本
- 再做监控与日志埋点
- 最后根据误伤率和业务峰值,决定是否升级算法
这套方案不追求“理论最完美”,但它的优点是:实现成本低、效果直接、适合真实项目快速上线。
对大多数中型 Java Web 项目来说,这已经是一个非常好的起点。