跳转到内容
123xiao | 无名键客

《Java Web 开发中基于 Spring Boot + Redis 的接口限流实战与性能调优》

字数: 0 阅读时长: 1 分钟

Java Web 开发中基于 Spring Boot + Redis 的接口限流实战与性能调优

接口限流这件事,平时看着像“锦上添花”,但真到线上流量抖一下、有人恶意刷接口、或者某个下游慢得像蜗牛的时候,它往往就是“保命机制”。

这篇文章我不打算只讲概念,而是带你从一个 Spring Boot + Redis 的可运行限流方案 走一遍:为什么要限流、怎么设计、代码怎么写、怎么验证、线上容易踩哪些坑,以及怎么做性能调优。

适合已经做过 Spring Boot Web 开发、对 Redis 有基础使用经验的同学。


背景与问题

在 Java Web 项目里,接口限流常见的触发场景有这些:

  • 登录、短信发送、验证码校验,容易被恶意刷
  • 秒杀、抢券、下单类接口,会出现流量瞬时激增
  • 下游依赖能力有限,比如调用第三方支付、库存服务、风控服务
  • 某些“高频但低价值”的接口,如果不控,容易拖垮整体系统

很多项目一开始的做法很简单:

  • 在 JVM 内存里用 ConcurrentHashMap 计数
  • 在网关上配一个固定 QPS
  • 用 Nginx 做粗粒度限流

这些方案不是不能用,而是各有边界:

  • 单机内存计数:多实例部署后数据不共享,限流不准
  • 网关限流:适合统一入口,但不容易做“按用户/按接口/按业务参数”的细粒度控制
  • Nginx 限流:简单高效,但对业务语义支持有限

所以在很多中型 Java Web 项目里,Spring Boot + Redis 是一个非常实用的组合:

  • Spring Boot 负责业务接入和注解式使用
  • Redis 负责分布式计数和原子性控制
  • 实现成本低,扩展性也不错

前置知识与环境准备

本文示例环境:

  • JDK 17
  • Spring Boot 3.x
  • Redis 7.x
  • Maven
  • Spring Web
  • Spring Data Redis

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.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

application.yml

server:
  port: 8080

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      timeout: 3000ms

logging:
  level:
    root: info

核心原理

接口限流常见算法有几种:

  1. 固定窗口
  2. 滑动窗口
  3. 漏桶
  4. 令牌桶

如果你是做业务接口保护,而不是网络层流控,我建议先把这两个吃透:

  • 固定窗口:实现最简单,适合快速落地
  • 令牌桶:更平滑,适合高并发和突发流量

本文先落地一个 固定窗口限流,再讲怎么优化。

1. 固定窗口限流思路

假设规则是:

  • /api/order/create
  • 按用户 ID 限流
  • 10 秒内最多 5 次请求

做法:

  • Redis key 设计成:rate_limit:{接口}:{用户}
  • 每次请求先对 key 做 INCR
  • 第一次出现时设置 EXPIRE 10
  • 如果计数值 > 5,则拒绝请求

优点:

  • 实现简单
  • Redis 原子自增性能好
  • 很适合中小规模业务限流

缺点:

  • 存在“窗口边界突刺”问题
    比如 9.9 秒请求 5 次,10.1 秒再请求 5 次,短时间内可能打到 10 次

2. 为什么要用 Lua 脚本

如果你把逻辑拆成多步:

  1. INCR
  2. 判断是否首次
  3. EXPIRE
  4. 判断是否超限

在高并发下可能出现两个问题:

  • 原子性不完整
  • 某些异常路径下 key 没设过期,形成“脏 key”

所以更稳妥的方式是:

  • INCR + EXPIRE + 返回计数 放到一个 Lua 脚本里执行
  • Redis 保证脚本执行期间的原子性

限流整体流程

flowchart TD
    A[客户端请求接口] --> B[Spring Boot 拦截器/AOP]
    B --> C[构造限流Key]
    C --> D[执行Redis Lua脚本]
    D --> E{是否超限}
    E -- 否 --> F[放行业务处理]
    E -- 是 --> G[返回429或业务错误码]

方案设计:从“按 IP”升级到“按业务主体”

很多初学实现限流时,第一反应是按 IP 限流。但实际项目里,只按 IP 往往不够

  • 用户共享 NAT 出口,误伤正常用户
  • 移动网络环境 IP 变化频繁
  • 某些内部调用没有稳定客户端 IP

更实用的做法是分层:

  1. 登录前接口:按 IP + URI
  2. 登录后接口:按用户 ID + URI
  3. 敏感接口:按用户 ID + 业务动作
  4. 内部接口:按调用方 appId / clientId

我自己的经验是:
限流 key 一定要体现“谁在调用 + 调哪个接口”,否则不是太松,就是误杀太多。


实战代码(可运行)

下面实现一个可运行版本,包含:

  • 自定义注解
  • AOP 切面
  • Redis Lua 脚本
  • 示例 Controller
  • 全局异常处理

1. 自定义限流注解

package com.example.demo.ratelimit;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {

    /**
     * 每个时间窗口内允许的最大请求数
     */
    int limit();

    /**
     * 时间窗口,单位:秒
     */
    int windowSeconds();

    /**
     * 限流维度:
     * ip / user / custom
     */
    String keyType() default "ip";

    /**
     * 自定义前缀,便于区分业务
     */
    String prefix() default "rate_limit";
}

2. 自定义异常

package com.example.demo.ratelimit;

public class RateLimitException extends RuntimeException {
    public RateLimitException(String message) {
        super(message);
    }
}

3. Redis Lua 脚本配置

这里用 DefaultRedisScript<Long> 加载脚本。

package com.example.demo.ratelimit;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;

@Configuration
public class RedisLuaConfig {

    @Bean
    public DefaultRedisScript<Long> rateLimitScript() {
        String luaScript = """
                local key = KEYS[1]
                local current = redis.call('INCR', key)
                if current == 1 then
                    redis.call('EXPIRE', key, ARGV[1])
                end
                return current
                """;

        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setScriptSource(new org.springframework.scripting.support.ResourceScriptSource(
                new ByteArrayResource(luaScript.getBytes())
        ));
        script.setResultType(Long.class);
        return script;
    }
}

4. 获取客户端标识工具类

这里演示按 IP 和按用户 ID 两种方式。
为了让示例能直接跑起来,我用请求头 X-User-Id 模拟登录用户。

package com.example.demo.ratelimit;

import jakarta.servlet.http.HttpServletRequest;

public class RateLimitKeyUtil {

    public static String getClientIp(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isBlank()) {
            return xForwardedFor.split(",")[0].trim();
        }

        String realIp = request.getHeader("X-Real-IP");
        if (realIp != null && !realIp.isBlank()) {
            return realIp;
        }

        return request.getRemoteAddr();
    }

    public static String getUserId(HttpServletRequest request) {
        String userId = request.getHeader("X-User-Id");
        return (userId == null || userId.isBlank()) ? "anonymous" : userId;
    }
}

5. AOP 切面实现限流

package com.example.demo.ratelimit;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
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;

@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {

    private final StringRedisTemplate stringRedisTemplate;
    private final DefaultRedisScript<Long> rateLimitScript;

    @Around("@annotation(rateLimit)")
    public Object doRateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
                .getRequest();

        String uri = request.getRequestURI();
        String keyPart = buildKeyPart(request, rateLimit.keyType());
        String redisKey = String.format("%s:%s:%s", rateLimit.prefix(), uri, keyPart);

        Long currentCount = stringRedisTemplate.execute(
                rateLimitScript,
                Collections.singletonList(redisKey),
                String.valueOf(rateLimit.windowSeconds())
        );

        if (currentCount != null && currentCount > rateLimit.limit()) {
            throw new RateLimitException("请求过于频繁,请稍后再试");
        }

        return joinPoint.proceed();
    }

    private String buildKeyPart(HttpServletRequest request, String keyType) {
        return switch (keyType) {
            case "user" -> RateLimitKeyUtil.getUserId(request);
            case "ip" -> RateLimitKeyUtil.getClientIp(request);
            default -> RateLimitKeyUtil.getClientIp(request);
        };
    }
}

6. 全局异常处理

package com.example.demo.web;

import com.example.demo.ratelimit.RateLimitException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RateLimitException.class)
    @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
    public Map<String, Object> handleRateLimit(RateLimitException ex) {
        return Map.of(
                "code", 429,
                "message", ex.getMessage()
        );
    }
}

7. 示例接口

package com.example.demo.web;

import com.example.demo.ratelimit.RateLimit;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.Map;

@RestController
public class DemoController {

    @GetMapping("/api/public/ping")
    @RateLimit(limit = 3, windowSeconds = 10, keyType = "ip", prefix = "rl")
    public Map<String, Object> ping() {
        return Map.of(
                "success", true,
                "time", LocalDateTime.now().toString(),
                "message", "pong"
        );
    }

    @GetMapping("/api/user/profile")
    @RateLimit(limit = 5, windowSeconds = 20, keyType = "user", prefix = "rl")
    public Map<String, Object> profile() {
        return Map.of(
                "success", true,
                "message", "user profile data"
        );
    }
}

8. 启动类

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

调用时序

sequenceDiagram
    participant C as Client
    participant A as Spring AOP
    participant R as Redis
    participant S as Service/Controller

    C->>A: 请求 /api/user/profile
    A->>A: 解析 @RateLimit
    A->>R: 执行 Lua(INCR + EXPIRE)
    R-->>A: 返回当前计数
    alt 未超限
        A->>S: 放行执行业务
        S-->>C: 200 OK
    else 超限
        A-->>C: 429 Too Many Requests
    end

逐步验证清单

代码跑起来后,建议按下面步骤验证,不要一上来就压测。

1. 验证 Redis key 是否生成

先请求:

curl http://localhost:8080/api/public/ping

再看 Redis:

redis-cli keys "rl:*"

如果能看到类似 key,说明 AOP 已经生效。

2. 验证限流是否生效

连续调用 4 次:

for i in {1..4}; do curl http://localhost:8080/api/public/ping; echo; done

预期:

  • 前 3 次成功
  • 第 4 次返回 429

3. 验证按用户维度限流

for i in {1..6}; do
  curl -H "X-User-Id: 1001" http://localhost:8080/api/user/profile
  echo
done

再换一个用户:

curl -H "X-User-Id: 1002" http://localhost:8080/api/user/profile

预期:

  • 用户 1001 超限
  • 用户 1002 不受影响

4. 验证过期恢复

等待窗口时间结束后再发请求,应恢复正常。


进一步优化:从固定窗口到更稳的限流模型

上面的方案已经能用于不少业务接口,但如果你对流量波峰比较敏感,可以继续优化。

优化方向一:滑动窗口

思路是把请求时间戳记录下来,只统计最近 N 秒内的请求数。
常见实现用 Redis 的 ZSET

  • score:时间戳
  • member:唯一请求 ID
  • 每次请求先删掉过期元素
  • 再统计窗口内元素数量
  • 超限则拒绝

优点:

  • 比固定窗口更平滑
  • 边界突刺问题小很多

缺点:

  • Redis 操作更多
  • 内存成本更高
  • 高并发下需要注意 key 膨胀

优化方向二:令牌桶

如果你的接口存在突发流量,但又不希望直接“硬拦”,令牌桶会更合适:

  • 系统按固定速率往桶里放令牌
  • 请求来时先取令牌
  • 取到则通过,取不到则限流

它对“偶尔突发、整体稳定”的场景很友好,比如:

  • 查询类接口
  • 活动页接口
  • 某些读多写少的业务

常见坑与排查

这一部分我建议认真看,因为很多限流功能“看起来写完了”,但真正的问题都在这里。

1. AOP 不生效

现象:

  • 加了 @RateLimit,请求却没被拦截

排查点:

  • 是否引入了 spring-boot-starter-aop
  • 注解是否加在 public 方法上
  • 是否发生了类内部自调用
    比如同一个类里 this.xxx() 调另一个标了注解的方法,AOP 可能失效

建议:

  • 限流注解优先加在 Controller 层入口方法
  • 不要依赖类内自调用触发切面

2. 获取真实 IP 不准确

现象:

  • 所有请求都显示成网关 IP 或 127.0.0.1

原因:

  • 部署在 Nginx、Ingress、网关后面,没有正确透传真实 IP

排查:

  • 看请求头是否包含 X-Forwarded-For
  • Nginx 是否配置了:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;

提醒:

  • 不要盲信客户端自己传的 IP 头
  • 只信任你自己的反向代理层追加的头

3. Redis key 没过期,数量越来越多

现象:

  • Redis 中限流 key 持续增长

原因:

  • INCREXPIRE 没做原子化
  • 某些异常流程导致只自增没过期

解决:

  • 用 Lua 脚本
  • 定期抽样检查 TTL

可用下面命令排查:

redis-cli ttl "rl:/api/public/ping:127.0.0.1"

4. 多实例下限流不准

如果你已经用了同一个 Redis,一般不会有这个问题。
真正容易出问题的是:

  • 测试环境每个实例连的 Redis 不是同一个
  • 有些接口走 Redis 限流,有些接口还保留本地计数逻辑
  • key 设计不统一,导致统计维度不一致

建议:

  • 限流规则统一收口
  • key 命名规范固定下来
  • 不要线上同时混用多个不兼容的限流实现

5. Redis 成为瓶颈

现象:

  • 接口 RT 升高
  • Redis CPU 飙升
  • 大量热点 key

这个问题我见过,尤其是“超热点接口 + 单一维度限流”的时候很明显。

比如:

  • 所有人都访问同一个公共接口
  • 你只按 URI 限流
  • 导致所有请求都打同一个 key

优化方式:

  • 增加 key 维度,比如 URI + IPURI + userId
  • 对超热点公共接口在网关层先做一层粗限流
  • Redis 连接池参数合理配置
  • 避免对每个请求做复杂序列化/反序列化

安全/性能最佳实践

这部分是落地时最值钱的地方,很多项目差距就在这些细节里。

1. 限流要分层,不要“一个规则打天下”

建议你这样分:

  • 网关层:做全局粗粒度限流,拦掉明显异常流量
  • 应用层:做细粒度业务限流,按用户、接口、业务动作控制
  • 下游层:必要时再做线程池/熔断保护

也就是说,Redis 限流不要承担全部防护职责。


2. key 设计尽量短,但语义要清楚

推荐格式:

rl:{env}:{app}:{uri}:{subject}

例如:

rl:prod:order:/api/order/create:1001

注意点:

  • key 太长会浪费内存
  • key 太短又不好排查
  • URI 如果包含动态路径,最好归一化
    否则 /order/1/order/2 会变成不同 key

3. 返回明确的错误码和提示

HTTP 层建议返回:

  • 429 Too Many Requests

业务体里可带:

  • 错误码
  • 友好提示
  • 建议重试时间

例如:

{
  "code": 429,
  "message": "请求过于频繁,请 10 秒后重试"
}

这样前端、调用方、监控系统都更容易识别。


4. 给限流加监控,不要“静默失败”

至少统计这些指标:

  • 限流命中次数
  • 按接口的限流分布
  • 按用户/IP 的高频命中情况
  • Redis 脚本调用耗时
  • 429 响应占比

如果没有监控,你很难区分:

  • 是真的挡住了攻击
  • 还是错误地误伤了正常用户

5. 对登录、验证码、短信接口单独加严

这类接口不是“普通高频”,而是“高风险高敏感”。

建议:

  • 短周期限流:例如 1 分钟 5 次
  • 长周期限流:例如 1 小时 20 次
  • 叠加设备指纹、账号、IP、多维限制
  • 对触发阈值的对象做黑名单或二次验证

单一维度限流,防普通误用可以,防恶意刷接口通常不够。


6. Redis 不可用时要有降级策略

这个非常重要。
限流组件如果自己成了单点故障,那就很尴尬。

常见降级策略:

  • 放行优先:Redis 不可用时直接放行
    适合核心交易接口,避免误杀
  • 拒绝优先:Redis 不可用时拒绝请求
    适合高风险接口,比如短信发送
  • 本地兜底:临时退化到单机限流
    精度差一点,但比完全失控强

我个人建议:不同接口采用不同策略,不要全站统一一个开关。


性能调优建议

如果你准备把这套方案真正扔到生产环境,下面这些优化很值得做。

1. 减少对象创建和字符串拼接

限流是高频路径,别小看这些小开销。

可优化点:

  • key 构造使用更轻量的方式
  • URI 做预处理缓存
  • 避免每次请求都创建复杂中间对象

如果接口 QPS 很高,这些小优化加起来是有体感的。


2. 使用连接池并观察 Redis RT

Spring Boot 默认已经比较方便,但你仍然要关注:

  • 最大连接数是否足够
  • 超时时间是否合理
  • 是否存在连接等待

如果 Redis RT 已经明显抖动,限流本身会反向拖慢业务。


3. 热点接口前移到网关

应用层限流足够灵活,但每个请求都要进应用、走 AOP、查 Redis。
对于特别热点的接口,可以考虑:

  • 网关先做一层粗过滤
  • 应用层再做精细控制

这样能明显降低应用实例和 Redis 的压力。


4. 使用 Lua 而不是多次往返 Redis

这一点前面提过,但值得单独强调:

  • 少一次网络往返,就少一次延迟
  • 原子性更完整
  • 代码逻辑也更集中

高并发下,这种差异会被放大。


进阶结构示意

classDiagram
    class RateLimit {
        +int limit()
        +int windowSeconds()
        +String keyType()
        +String prefix()
    }

    class RateLimitAspect {
        -StringRedisTemplate stringRedisTemplate
        -DefaultRedisScript~Long~ rateLimitScript
        +doRateLimit(joinPoint, rateLimit)
        -buildKeyPart(request, keyType)
    }

    class RateLimitKeyUtil {
        +getClientIp(request) String
        +getUserId(request) String
    }

    class RedisLuaConfig {
        +rateLimitScript() DefaultRedisScript~Long~
    }

    class DemoController {
        +ping()
        +profile()
    }

    RateLimitAspect --> RateLimit
    RateLimitAspect --> RateLimitKeyUtil
    RateLimitAspect --> RedisLuaConfig
    DemoController ..> RateLimit

边界条件与适用范围

这套 Spring Boot + Redis 限流方案很适合:

  • 中小型 Java Web 应用
  • 需要按用户/IP/接口做细粒度限流
  • 已经有 Redis 基础设施
  • 希望快速落地、可持续优化

但如果你的场景是下面这些,就要再往上升级:

  • 超大规模 API 网关统一治理
  • 多地域多活,限流状态要跨机房协调
  • 需要复杂配额、租户级资源隔离
  • 要做毫秒级精准流控和突发整形

这时可能要考虑:

  • 网关原生限流能力
  • Sentinel、Envoy、APISIX、Kong 等方案
  • 专门的流控中间件

也就是说,Redis 限流很好用,但不是所有问题的最终答案。


总结

我们这篇文章做了几件事:

  • 说明了为什么 Java Web 接口需要限流
  • 用 Spring Boot + Redis 落地了一个可运行的固定窗口方案
  • 用 Lua 保证了计数与过期的原子性
  • 给出了验证步骤、常见排查点和生产调优建议

如果你现在要把它用到项目里,我建议按这个顺序推进:

  1. 先挑 1~2 个高风险接口接入限流
  2. 优先做“按用户/按 IP + 接口”的细粒度规则
  3. 接入 429 监控和 Redis 耗时监控
  4. 观察误伤率和命中率
  5. 再决定是否升级到滑动窗口或令牌桶

最后给一个很实用的经验:
限流不是越严越好,而是要在“保护系统”和“减少误伤”之间找平衡。
你真正要保护的,不只是接口本身,而是整个系统在异常流量下还能稳定活着。


分享到:

上一篇
《前端中级实战:用 Vite + TypeScript 搭建可扩展的组件库工程化方案》
下一篇
《Node.js 中基于 Worker Threads 与消息队列的高并发任务处理实践》