← 全部文章

SpringBoot 中小系统敏感接口限流的轻量级方案

应用层出发,不涉及网关层的个人开发者可用的接口限流方案设计

更新于 2026.08.07

本文目录 12 节
SpringBoot 中小系统敏感接口限流的轻量级方案封面

最近在整理自己的后端项目模板,整体技术栈基于 Spring Boot 3.x + JDK 21。至于 Spring Boot 4,暂时先观望一下,让子弹再飞一会儿。我打算将登录、注册等通用能力沉淀到后端项目模板中,作为开箱即用的基础模块,减少重复劳动,提高项目初始化效率。

背景

本文所讨论的接口限流,不涉及 Spring Cloud Gateway、Nginx、Kong 等网关层组件,而是聚焦于应用层内部的限流实现,更适合单体或轻量级服务的中小型系统。

限流要解决的问题与不解决的问题

在中小型系统中,引入接口限流的目的并不是构建一套复杂、精细的流量治理体系,而是以较低的实现和维护成本,解决最常见、最现实的安全与稳定性问题。因此,在具体实现之前,有必要先明确本方案要解决什么问题,以及刻意不去解决什么问题。

限流要解决的问题

  1. 防止核心接口被恶意滥用
    登录、注册、短信或邮箱验证码等接口,天然具备被攻击或刷接口的价值。一旦缺乏必要的限流保护,轻则浪费系统资源,重则导致账号被爆破、短信通道被封禁等问题。
    本方案的首要目标,是为这些高风险接口提供基础且有效的访问控制能力。

  2. 降低异常流量对系统稳定性的影响
    在中小型系统中,单个接口被集中请求,往往就足以对系统造成明显压力。通过对单位时间内的请求次数进行限制,可以在一定程度上削峰填谷,避免异常流量直接冲击业务逻辑和下游资源。

  3. 为通用业务场景提供统一的限流能力
    限流逻辑不应散落在各个接口实现中,而应以相对统一的方式进行封装,便于在登录、注册等通用场景中复用,并作为后端项目模板的一部分长期维护。

  4. 实现成本可控、易于理解和维护
    相比追求复杂和高度精确的流控算法,本方案更关注可读性和可维护性,使限流逻辑能够被快速理解、调试和调整,符合中小型系统对开发效率的要求。

限流不解决的问题

  1. 不解决大规模分布式系统的全局限流问题
    本方案不适用于多实例、多机房场景下的全局一致限流,也不尝试解决高并发环境中的精确流量调度问题。

  2. 不替代网关层或基础设施层的流量治理能力
    本文所讨论的限流方案,不涉及 Spring Cloud Gateway、Nginx、Kong 等网关层组件,也不承担统一入口、流量整形等职责,而是专注于应用层内部的接口保护。

  3. 不防御针对网络层或协议层的攻击
    限流主要面向业务接口层面的滥用行为,并不用于防御 DDoS 等网络层攻击,这类问题应由更底层的网络或基础设施组件解决。

  4. 不追求绝对精确的限流结果
    在应用层实现的限流机制,允许在一定范围内存在误差,更关注整体效果而非单次请求的严格准确性,以换取更简单的实现和更低的运行成本。

限流维度设计

在应用层实现接口限流时,限流维度的选择直接决定了限流效果与实现复杂度。对于中小型系统而言,限流维度不宜过多,否则不仅增加维护成本,也容易引入不必要的复杂性。

结合登录、注册等典型业务场景,本文主要采用以下几种限流维度。

按 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();
}

评论