Spring Boot 接口防刷限流实战——令牌桶、滑动窗口、分布式限流三套方案
Spring Boot 接口防刷限流实战——令牌桶、滑动窗口、分布式限流三套方案
适读人群:需要对接口做限流保护的 Java 工程师 | 阅读时长:约20分钟 | 核心价值:掌握令牌桶、滑动窗口、基于 Redis 的分布式限流三套方案,根据场景选择合适的限流策略
一、那次被刷接口的经历
两年前,我们的短信验证码接口在某天晚上被恶意刷了。从凌晨 12 点开始,大量请求涌来,每个手机号每分钟被请求几十次,短信服务商的费用从日常几百元直接飙到了 1.2 万元,等运维发现时已经损失了不少。
更麻烦的是,短信服务商对高频发送有黑名单机制,我们的号码被临时封了,正常用户也发不出去短信,当天有大量用户投诉注册失败。
事后我复盘,发现当时这个接口根本没有任何限流措施,完全裸奔。任何人拿到接口地址,用脚本跑几秒就能产生几百条请求。
这件事之后,我把限流列为接口安全的基础配置,跟认证鉴权一样,每个对外接口必须有。这篇文章把三套限流方案完整讲清楚。
二、限流算法对比
先明确三种常用限流算法的适用场景:
| 算法 | 特点 | 适用场景 |
|---|---|---|
| 固定窗口计数 | 实现最简单,有窗口边界突刺问题 | 精度要求不高的简单场景 |
| 滑动窗口计数 | 解决了突刺问题,更平滑 | 对精度有要求的 API 限流 |
| 令牌桶 | 允许短暂突发流量,平均速率受限 | 允许突发的接口(如搜索) |
| 漏桶 | 严格恒定速率,不允许突发 | 对下游有恒定速率要求的场景 |
我的判断是:对大多数业务接口,选滑动窗口或令牌桶。需要允许短时突发(比如白天偶尔高峰的正常业务)选令牌桶;需要严格按速率发送(比如调用第三方 API)选漏桶。
三、方案一:单机限流(Guava RateLimiter + AOP)
适合单实例或不需要分布式一致性的场景。
引入依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>自定义限流注解:
package com.example.ratelimit;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* 接口限流注解。
* 加在 Controller 方法上,通过 AOP 拦截,使用 Guava RateLimiter 令牌桶限流。
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/** 每秒允许的请求数(令牌桶速率) */
double permitsPerSecond() default 10.0;
/** 获取令牌的超时时间,超过就拒绝 */
long timeout() default 0;
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/** 限流时返回的错误信息 */
String message() default "请求太频繁,请稍后重试";
}AOP 切面:
package com.example.ratelimit;
import com.google.common.util.concurrent.RateLimiter;
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.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.concurrent.ConcurrentHashMap;
/**
* 单机限流切面,使用 Guava RateLimiter 实现令牌桶算法。
* 为每个被 @RateLimit 标注的方法维护独立的 RateLimiter 实例。
*/
@Aspect
@Component
public class RateLimitAspect {
/** 每个方法对应一个 RateLimiter,key 是方法全限定名 */
private final ConcurrentHashMap<String, RateLimiter> rateLimiterMap = new ConcurrentHashMap<>();
@Around("@annotation(com.example.ratelimit.RateLimit)")
public Object rateLimit(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RateLimit rateLimit = method.getAnnotation(RateLimit.class);
// 获取或创建 RateLimiter(线程安全)
String methodKey = method.getDeclaringClass().getName() + "#" + method.getName();
RateLimiter rateLimiter = rateLimiterMap.computeIfAbsent(methodKey,
k -> RateLimiter.create(rateLimit.permitsPerSecond()));
// 尝试获取令牌
boolean acquired = rateLimiter.tryAcquire(rateLimit.timeout(), rateLimit.timeUnit());
if (!acquired) {
// 限流:返回 429 Too Many Requests
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletResponse response = attributes.getResponse();
if (response != null) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"code\":429,\"msg\":\"" + rateLimit.message() + "\"}");
return null;
}
}
throw new RuntimeException(rateLimit.message());
}
return joinPoint.proceed();
}
}使用示例:
@RestController
@RequestMapping("/api")
public class SmsController {
/**
* 短信验证码接口:每秒最多 1 次请求,超过直接拒绝(timeout=0)。
*/
@PostMapping("/sms/send")
@RateLimit(permitsPerSecond = 1.0, message = "发送太频繁,请 1 秒后重试")
public Result sendSms(@RequestParam String phone) {
// 发送逻辑
return Result.success();
}
/**
* 搜索接口:每秒 50 次,允许最多等待 100ms 获取令牌(允许短暂排队)。
*/
@GetMapping("/search")
@RateLimit(permitsPerSecond = 50.0, timeout = 100)
public Result search(@RequestParam String keyword) {
return Result.success();
}
}四、方案二:基于 Redis 的分布式滑动窗口限流
多节点部署时,单机限流不够——每个节点各自计数,全局总请求量是各节点之和,实际放过的请求远超限制。需要用 Redis 做全局计数。
package com.example.ratelimit;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.Collections;
/**
* 基于 Redis Sorted Set 的滑动窗口限流。
* 原理:
* - Key 是限流资源标识(如 "rate_limit:sms:13800138000")
* - Value 是一个 Sorted Set,score 是请求的时间戳,member 是请求的唯一 ID
* - 每次请求时:
* 1. 删除窗口之外的旧数据(ZREMRANGEBYSCORE)
* 2. 统计窗口内的请求数(ZCARD)
* 3. 如果超过限制,拒绝;否则写入当前请求,续期 Key
* 用 Lua 脚本保证原子性。
*/
@Component
public class SlidingWindowRateLimiter {
private final RedisTemplate<String, Object> redisTemplate;
/**
* Lua 脚本:原子化执行删除过期 + 计数 + 写入操作。
* KEYS[1]: 限流 key
* ARGV[1]: 当前时间戳(毫秒)
* ARGV[2]: 窗口大小(毫秒)
* ARGV[3]: 最大请求数
* ARGV[4]: 当前请求的唯一 member(时间戳+随机数)
* ARGV[5]: Key 的过期时间(秒)
* 返回 1 表示允许,返回 0 表示被限流
*/
private static final String SLIDING_WINDOW_SCRIPT =
"local key = KEYS[1]\n" +
"local now = tonumber(ARGV[1])\n" +
"local windowMs = tonumber(ARGV[2])\n" +
"local maxRequests = tonumber(ARGV[3])\n" +
"local member = ARGV[4]\n" +
"local expireSeconds = tonumber(ARGV[5])\n" +
// 删除窗口之外的旧数据
"redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)\n" +
// 统计当前窗口内的请求数
"local count = redis.call('ZCARD', key)\n" +
"if count < maxRequests then\n" +
" redis.call('ZADD', key, now, member)\n" +
" redis.call('EXPIRE', key, expireSeconds)\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
public SlidingWindowRateLimiter(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 判断是否允许本次请求。
*
* @param limitKey 限流 key(建议格式:rate_limit:{接口}:{用户标识})
* @param windowMs 滑动窗口大小(毫秒),如 60000 表示 1 分钟
* @param maxRequests 窗口内最大请求数
* @return true 表示允许,false 表示被限流
*/
public boolean isAllowed(String limitKey, long windowMs, int maxRequests) {
long now = Instant.now().toEpochMilli();
String member = now + "-" + Thread.currentThread().getId();
long expireSeconds = windowMs / 1000 + 2; // Key 过期时间略大于窗口
DefaultRedisScript<Long> script = new DefaultRedisScript<>(SLIDING_WINDOW_SCRIPT, Long.class);
Long result = redisTemplate.execute(script,
Collections.singletonList(limitKey),
String.valueOf(now),
String.valueOf(windowMs),
String.valueOf(maxRequests),
member,
String.valueOf(expireSeconds));
return Long.valueOf(1).equals(result);
}
}在 Controller 中使用:
@PostMapping("/sms/send")
public Result sendSms(@RequestParam String phone,
HttpServletRequest request) {
// 按用户手机号限流:每分钟最多发 3 条
String limitKey = "rate_limit:sms:" + phone;
if (!slidingWindowRateLimiter.isAllowed(limitKey, 60_000, 3)) {
return Result.error(429, "短信发送过于频繁,每分钟最多发 3 条");
}
// 按 IP 限流:每分钟最多 10 次(防止换号轰炸)
String ip = getClientIp(request);
String ipLimitKey = "rate_limit:sms_ip:" + ip;
if (!slidingWindowRateLimiter.isAllowed(ipLimitKey, 60_000, 10)) {
return Result.error(429, "操作过于频繁,请稍后重试");
}
// 发送短信
smsService.send(phone);
return Result.success();
}五、踩坑实录
坑1:单机限流在多实例部署下形同虚设
现象:每个实例单独限流每秒 10 次,但系统部署了 5 个实例,实际允许每秒 50 次,达不到限流目的。
原因:Guava RateLimiter 是 JVM 内部的,每个实例各自计数,没有全局状态。
解法:多实例部署必须用分布式限流(Redis 方案)。单机限流只适合单实例或者"防止单个 Pod 过载"的场景。这个坑我也踩过,当时认为"每个节点 10 QPS 够了",结果因为多实例导致限流配置完全没起作用。
坑2:Redis 限流 Lua 脚本时钟漂移问题
现象:集群里某个节点的时钟比其他节点慢了 5 秒,导致同一用户的请求在这个节点上滑动窗口计算不准确,能绕过限流。
原因:滑动窗口计数依赖客户端传入的时间戳(ARGV[1]),如果客户端时钟不同步,窗口计算会出错。
解法:在 Lua 脚本里用 redis.call('TIME') 获取 Redis 服务器时间,代替客户端传入的时间戳,确保时钟一致性。
坑3:限流 Key 设计不当导致误限
现象:用 IP 限流,但公司内网用户都在同一个出口 IP,一个正常用户触发了限流,全公司内网用户都被限了。
原因:限流 Key 只用了 IP,没有区分用户,内网用户共享一个 IP,实际上是合并计数的。
解法:根据业务场景设计限流 Key:对于需要登录的接口,用 userId 限流;对于匿名接口(登录、注册、发短信),组合用 IP + 设备指纹;纯公网场景才单独用 IP。多维度组合限流比单一维度更精准。
六、三套方案的选择建议
我自己的决策矩阵:
| 场景 | 推荐方案 |
|---|---|
| 单实例 / 开发测试 | Guava RateLimiter(简单,无需额外组件) |
| 多实例 / 生产 | Redis 滑动窗口(准确,分布式一致) |
| 高性能要求(每秒万次限流判断) | Sentinel(生产级限流框架,支持熔断降级) |
| 网关层全局限流 | Spring Cloud Gateway + Redis 令牌桶 |
对于大多数中小型业务系统,Redis 滑动窗口方案是最实用的选择。如果系统已经引入了 Sentinel 或 Spring Cloud,优先用框架自带的限流功能,不要重复造轮子。
