SpringBoot 中小系统敏感接口限流的轻量级方案
应用层出发,不涉及网关层的个人开发者可用的接口限流方案设计
更新于 2026.08.07
本文目录 12 节

最近在整理自己的后端项目模板,整体技术栈基于 Spring Boot 3.x + JDK 21。至于 Spring Boot 4,暂时先观望一下,让子弹再飞一会儿。我打算将登录、注册等通用能力沉淀到后端项目模板中,作为开箱即用的基础模块,减少重复劳动,提高项目初始化效率。
背景
本文所讨论的接口限流,不涉及 Spring Cloud Gateway、Nginx、Kong 等网关层组件,而是聚焦于应用层内部的限流实现,更适合单体或轻量级服务的中小型系统。
限流要解决的问题与不解决的问题
在中小型系统中,引入接口限流的目的并不是构建一套复杂、精细的流量治理体系,而是以较低的实现和维护成本,解决最常见、最现实的安全与稳定性问题。因此,在具体实现之前,有必要先明确本方案要解决什么问题,以及刻意不去解决什么问题。
限流要解决的问题
-
防止核心接口被恶意滥用
登录、注册、短信或邮箱验证码等接口,天然具备被攻击或刷接口的价值。一旦缺乏必要的限流保护,轻则浪费系统资源,重则导致账号被爆破、短信通道被封禁等问题。
本方案的首要目标,是为这些高风险接口提供基础且有效的访问控制能力。 -
降低异常流量对系统稳定性的影响
在中小型系统中,单个接口被集中请求,往往就足以对系统造成明显压力。通过对单位时间内的请求次数进行限制,可以在一定程度上削峰填谷,避免异常流量直接冲击业务逻辑和下游资源。 -
为通用业务场景提供统一的限流能力
限流逻辑不应散落在各个接口实现中,而应以相对统一的方式进行封装,便于在登录、注册等通用场景中复用,并作为后端项目模板的一部分长期维护。 -
实现成本可控、易于理解和维护
相比追求复杂和高度精确的流控算法,本方案更关注可读性和可维护性,使限流逻辑能够被快速理解、调试和调整,符合中小型系统对开发效率的要求。
限流不解决的问题
-
不解决大规模分布式系统的全局限流问题
本方案不适用于多实例、多机房场景下的全局一致限流,也不尝试解决高并发环境中的精确流量调度问题。 -
不替代网关层或基础设施层的流量治理能力
本文所讨论的限流方案,不涉及Spring Cloud Gateway、Nginx、Kong等网关层组件,也不承担统一入口、流量整形等职责,而是专注于应用层内部的接口保护。 -
不防御针对网络层或协议层的攻击
限流主要面向业务接口层面的滥用行为,并不用于防御DDoS等网络层攻击,这类问题应由更底层的网络或基础设施组件解决。 -
不追求绝对精确的限流结果
在应用层实现的限流机制,允许在一定范围内存在误差,更关注整体效果而非单次请求的严格准确性,以换取更简单的实现和更低的运行成本。
限流维度设计
在应用层实现接口限流时,限流维度的选择直接决定了限流效果与实现复杂度。对于中小型系统而言,限流维度不宜过多,否则不仅增加维护成本,也容易引入不必要的复杂性。
结合登录、注册等典型业务场景,本文主要采用以下几种限流维度。
按 IP 限流
按 IP 限流是最基础、也是成本最低的一种方式,适用于尚未建立用户身份的接口,例如登录、注册、验证码申请等。
该方式可以在一定程度上防止单个来源在短时间内发起大量请求,但也存在 NAT 或代理环境下误伤正常用户的情况。因此,更适合作为第一道防线,而非唯一手段。
按用户限流
在用户完成身份识别之后,可以基于用户 ID 进行限流。这种方式比 IP 限流更加精确,适合用于已登录状态下的接口。
对于登录接口本身,可以在账号维度上进行限流,用于防止针对单个账号的暴力破解行为。
按接口维度限流
不同接口的业务价值和风险等级不同,因此有必要在接口维度上设置不同的限流规则。例如,验证码申请接口通常需要比普通查询接口更严格的访问频率限制。
通过接口维度的限流,可以更灵活地针对不同业务场景进行控制,而不会一刀切地影响所有接口。
应用层接口限流的实现
在明确了限流的目标与边界之后,接下来需要回答一个更现实的问题:
在不引入网关、不依赖复杂限流框架的前提下,如何在应用层实现一套可用、可维护的接口限流方案?
本文的实现背景非常克制,也非常明确:
- 单实例
Spring Boot应用,面向中小型系统 - 使用 Redis 作为限流计数存储
- 不考虑多实例部署下的全局一致性问题
- 不引入
Spring Cloud Gateway、Sentinel、Guava等组件
在这样的前提下,限流方案的核心目标只有一个: 用最小的复杂度,保护最容易被滥用的接口。
为什么选择应用层拦截器实现限流
对于单体或轻量级服务而言,请求进入系统后,最合适的限流切入点就是 Spring MVC 的 HandlerInterceptor。
拦截器位于 Controller 之前,具备以下几个优势:
-
可以在请求进入业务逻辑前完成限流判断
-
不侵入
Controller,业务代码完全无感知 -
易于集中管理限流逻辑
-
非常适合作为项目模板中的通用能力
因此,本方案选择在 HandlerInterceptor#preHandle 中完成所有限流判断,一旦触发限流,直接中断请求。
解决HandlerInterceptor的Body只能读取一次的问题
在拦截器中实现限流时,一个绕不开的问题是:如何获取请求中的业务参数(如手机号、邮箱)。
由于 HttpServletRequest 的 InputStream / Reader 只能读取一次,如果在 HandlerInterceptor 中直接读取请求体,那么后续 Controller 中的 @RequestBody 将无法正常工作。这也是在拦截器中做限流、鉴权等逻辑时非常容易踩到的坑。
针对这个问题,常见的解决方式大致有以下三种。
URL参数传递
对于短信、邮箱验证码等接口,其请求参数本身非常简单,通常只包含手机号或邮箱地址。在这种场景下,可以选择通过 URL 参数来传递。
POST /api/sms/send?mobile=13800000000
使用可重复读的 HttpServletRequestWrapper
如果接口必须使用 JSON 请求体(例如前端统一使用 application/json),那么就需要解决请求体只能读取一次的问题。
常见做法是通过自定义 HttpServletRequestWrapper,在请求进入系统时缓存请求体内容,并在后续读取时返回缓存数据。
该方案通常需要在 Filter 层 对请求进行包装,而不是在拦截器中直接处理。 关于此方法的更多介绍:在 Spring Web 中实现可重复读取的 HttpServletRequest
在 Controller 中提前解析并传递参数
这种方式破坏了我们的集中限流和代码解耦的理念,完全不考虑。
第一种方案非常适合我们的场景,因为参数简单,实现简单。但是第二种方案明显可拓展性更高。通过自定义 HttpServletRequestWrapper,我们可以完全忽略提前读取请求体对后续参数解析的影响,这样可以在请求进入Controller之前做更多事,例如记录全局日志等等。
在这里,我选择第二种方案,第二种方案的实现。这是因为如果要处理其他业务,那么只有第二种方法能满足业务的需求。
具体方案

限流方式
- 自定义
@RateLimit注解,用以标记需要限流的接口。 - 一个接口允许配置多个限流规则(
@Repeatable) - 限流逻辑统一放在拦截器层
- 使用Redis+Lua脚本实现固定窗口计数器,确保原子性
限流维度支持
- IP
- USER(用户账户标识,可从
Header中获取,但要确保安全性,防止伪造,建议用TOKEN验签) - PARAM(URL查询参数,如mobile/email)
核心实现逻辑
Key 生成规则
- Redis 中的 Key 采用分段设计:
prefix:limitType:identifier:period。
确保不同接口、不同维度、不同周期的限流计数互不干扰。
Lua 脚本流程
- 首先通过
GET获取当前计数值,若已达到上限则直接返回。 - 若未达到上限,调用
INCR。 - 若是首次访问(计数结果为 1),则调用
EXPIRE为该窗口设置过期时间。 - 注意:多级代理 IP 获取: 通过遍历
X-Forwarded-For、X-Real-IP等多个Header,并对逗号分隔的 IP 列表取首位,确保获取到的是最原始的客户端 IP,适用于通过CDN和Nginx代理的情况。
触发限流
- 当 Lua 脚本返回的计数值大于预设阈值时,拦截器直接抛出自定义异常
RateLimitException - 响应状态:遵循 HTTP 标准,返回
429 Too Many Requests状态码 - 在全局异常处理器中捕获异常,统一响应提示信息
拦截器示例
package cn.edu.tyut.backendtyutoj.interceptor;
import cn.dev33.satoken.stp.StpUtil;
import cn.edu.tyut.backendtyutoj.common.annotation.RateLimit;
import cn.edu.tyut.backendtyutoj.common.annotation.RateLimits;
import cn.edu.tyut.backendtyutoj.common.constant.MessageConstant;
import cn.edu.tyut.backendtyutoj.common.enums.LimitType;
import cn.edu.tyut.backendtyutoj.common.exception.RateLimitException;
import cn.edu.tyut.backendtyutoj.common.wrapper.CachedBodyHttpServletRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 接口限流拦截器
* <p>
* 基于 Redis + Lua 脚本实现固定窗口限流
* </p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
/**
* 固定窗口限流 Lua 脚本
* <p>
* KEYS[1]: Redis Key
* ARGV[1]: 过期时间(秒)
* ARGV[2]: 最大允许次数
* </p>
*/
private static final String RATE_LIMIT_LUA_SCRIPT = """
local key = KEYS[1]
local period = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, period)
end
return count
""";
private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT;
static {
RATE_LIMIT_SCRIPT = new DefaultRedisScript<>();
RATE_LIMIT_SCRIPT.setScriptText(RATE_LIMIT_LUA_SCRIPT);
RATE_LIMIT_SCRIPT.setResultType(Long.class);
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 仅处理 Controller 方法
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
Method method = handlerMethod.getMethod();
List<RateLimit> rateLimits = collectRateLimits(method);
if (rateLimits.isEmpty()) {
return true;
}
// 逐个校验限流规则
for (RateLimit rateLimit : rateLimits) {
checkRateLimit(request, rateLimit);
}
return true;
}
/**
* 收集方法上的所有限流注解
*/
private List<RateLimit> collectRateLimits(Method method) {
List<RateLimit> rateLimits = new ArrayList<>();
// 检查 @RateLimits 容器注解
RateLimits rateLimitsAnnotation = method.getAnnotation(RateLimits.class);
if (rateLimitsAnnotation != null) {
Collections.addAll(rateLimits, rateLimitsAnnotation.value());
}
// 检查单个 @RateLimit 注解
RateLimit rateLimitAnnotation = method.getAnnotation(RateLimit.class);
if (rateLimitAnnotation != null) {
rateLimits.add(rateLimitAnnotation);
}
return rateLimits;
}
/**
* 执行限流检查
*/
private void checkRateLimit(HttpServletRequest request, RateLimit rateLimit) {
String key = buildRedisKey(request, rateLimit);
if (key == null) {
log.debug("无法生成限流Key,跳过限流检查: prefix={}, limitType={}",
rateLimit.prefix(), rateLimit.limitType());
return;
}
log.info("执行限流检查: key={}, period={}s, limit={}", key, rateLimit.period(), rateLimit.count());
Long currentCount = stringRedisTemplate.execute(
RATE_LIMIT_SCRIPT,
Collections.singletonList(key),
String.valueOf(rateLimit.period()),
String.valueOf(rateLimit.count())
);
log.info("限流检查结果: key={}, currentCount={}, limit={}", key, currentCount, rateLimit.count());
if (currentCount != null && currentCount > rateLimit.count()) {
log.warn("触发限流: key={}, count={}, limit={}",
key, currentCount, rateLimit.count());
// 使用自定义消息,如果未设置则使用默认消息
String message = StringUtils.hasText(rateLimit.message())
? rateLimit.message()
: MessageConstant.RATE_LIMIT;
throw new RateLimitException(message);
}
log.info("限流检查通过: key={}, count={}/{}", key, currentCount, rateLimit.count());
}
/**
* 构建 Redis Key
* <p>
* 格式: prefix:limitType:identifier:period
* </p>
*/
private String buildRedisKey(HttpServletRequest request, RateLimit rateLimit) {
String identifier = getIdentifier(request, rateLimit);
if (!StringUtils.hasText(identifier)) {
return null;
}
return String.join(":",
rateLimit.prefix(),
rateLimit.limitType().name().toLowerCase(),
identifier,
String.valueOf(rateLimit.period())
);
}
/**
* 根据限流类型获取标识符
*/
private String getIdentifier(HttpServletRequest request, RateLimit rateLimit) {
return switch (rateLimit.limitType()) {
case IP -> getClientIp(request);
case USER -> getUserAccount(request);
case PARAM -> getParamValue(request, rateLimit.paramName());
};
}
/**
* 获取客户端真实 IP
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (isValidIp(ip)) {
// 多个代理时取第一个
int index = ip.indexOf(',');
String result = index != -1 ? ip.substring(0, index).trim() : ip.trim();
log.info("获取客户端IP: {} (来源: X-Forwarded-For)", result);
return result;
}
ip = request.getHeader("X-Real-IP");
if (isValidIp(ip)) {
log.info("获取客户端IP: {} (来源: X-Real-IP)", ip.trim());
return ip.trim();
}
ip = request.getHeader("Proxy-Client-IP");
if (isValidIp(ip)) {
log.info("获取客户端IP: {} (来源: Proxy-Client-IP)", ip.trim());
return ip.trim();
}
ip = request.getHeader("WL-Proxy-Client-IP");
if (isValidIp(ip)) {
log.info("获取客户端IP: {} (来源: WL-Proxy-Client-IP)", ip.trim());
return ip.trim();
}
ip = request.getHeader("HTTP_CLIENT_IP");
if (isValidIp(ip)) {
log.info("获取客户端IP: {} (来源: HTTP_CLIENT_IP)", ip.trim());
return ip.trim();
}
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
if (isValidIp(ip)) {
log.info("获取客户端IP: {} (来源: HTTP_X_FORWARDED_FOR)", ip.trim());
return ip.trim();
}
String remoteAddr = request.getRemoteAddr();
log.info("获取客户端IP: {} (来源: RemoteAddr)", remoteAddr);
return remoteAddr;
}
/**
* 判断 IP 是否有效
*/
private boolean isValidIp(String ip) {
return StringUtils.hasText(ip) && !"unknown".equalsIgnoreCase(ip);
}
/**
* 通过 Token 验签获取用户ID(安全可信)
* <p>
* 使用 SaToken 从当前会话中获取已登录的用户ID
* </p>
*/
private String getUserAccount(HttpServletRequest request) {
try {
// 尝试获取当前登录的用户ID
Object loginId = StpUtil.getLoginIdDefaultNull();
if (loginId != null) {
return String.valueOf(loginId);
}
} catch (Exception e) {
log.debug("获取登录用户ID失败: {}", e.getMessage());
}
return null;
}
/**
* 获取请求参数值
* <p>
* 仅支持从请求Body中获取参数(JSON格式)
* 当参数不存在或为空时,返回特殊标识符以避免绕过限流
* </p>
*/
private String getParamValue(HttpServletRequest request, String paramName) {
if (!StringUtils.hasText(paramName)) {
log.warn("限流类型为 PARAM,但未配置 paramName");
return null;
}
// 从请求Body中获取(JSON格式)
if (request instanceof CachedBodyHttpServletRequest cachedRequest) {
String value = getParamFromBody(cachedRequest, paramName);
if (StringUtils.hasText(value)) {
return value.trim();
}
// 参数不存在或为空时,使用特殊标识符,防止通过空参数绕过限流
log.debug("Body中参数为空或不存在: paramName={}, 使用<empty>作为限流标识", paramName);
return "<empty>";
} else {
log.debug("请求未被包装为CachedBodyHttpServletRequest,无法读取Body参数: paramName={}", paramName);
}
return null;
}
/**
* 从请求Body中提取参数值
* <p>
* 支持JSON格式的请求体,可以提取嵌套字段(使用点号分隔,如:user.email)
* </p>
*/
private String getParamFromBody(CachedBodyHttpServletRequest request, String paramName) {
try {
String bodyString = request.getCachedBodyString();
if (!StringUtils.hasText(bodyString)) {
return null;
}
JsonNode rootNode = objectMapper.readTree(bodyString);
// 支持嵌套字段访问,如:user.email
String[] fieldPath = paramName.split("\\.");
JsonNode currentNode = rootNode;
for (String field : fieldPath) {
if (currentNode == null || !currentNode.has(field)) {
return null;
}
currentNode = currentNode.get(field);
}
// 返回字段的文本值
return currentNode != null && !currentNode.isNull() ? currentNode.asText() : null;
} catch (Exception e) {
log.debug("从Body中解析参数失败: paramName={}, error={}", paramName, e.getMessage());
return null;
}
}
}
@RateLimit注解
package cn.edu.tyut.backendtyutoj.common.annotation;
import cn.edu.tyut.backendtyutoj.common.enums.LimitType;
import java.lang.annotation.*;
/**
* 接口限流注解
* <p>
* 支持多种限流维度:IP、用户、请求参数
* </p>
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Repeatable(RateLimits.class)
public @interface RateLimit {
/**
* Redis Key 前缀
*/
String prefix() default "rate_limit";
/**
* 时间窗口(单位:秒)
*/
int period() default 60;
/**
* 时间窗口内允许的最大请求次数
*/
int count() default 100;
/**
* 限流维度类型
*/
LimitType limitType() default LimitType.IP;
/**
* 当 limitType = PARAM 时使用的请求参数名(从Body中读取)
* <p>
* 支持嵌套字段访问,使用点号分隔,如:user.email
* </p>
*/
String paramName() default "";
/**
* 触发限流时的提示消息
* <p>
* 如果不设置,使用默认的限流提示
* </p>
*/
String message() default "";
}
@RateLimits注解
package cn.edu.tyut.backendtyutoj.common.annotation;
import java.lang.annotation.*;
/**
* 限流注解容器
* <p>
* 用于支持同一方法上配置多个 @RateLimit 注解
* </p>
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimits {
/**
* 限流规则数组
*/
RateLimit[] value();
}
评论
无需登录,审核通过后公开。只有博主可以回复。
正在加载…
已公开的评论