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

《Java Web 开发中基于 Spring Boot + Redis 实现高并发接口限流的实战方案》

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

Java Web 开发中基于 Spring Boot + Redis 实现高并发接口限流的实战方案

接口限流这件事,很多团队都是“出事了才想起来做”。

比如秒杀、登录、短信发送、活动报名、批量查询这类接口,只要流量一上来,没有限流保护,轻则数据库打满、Redis 飙高,重则整个服务雪崩。尤其在 Java Web 项目里,单机限流往往挡不住分布式场景,最后还得落到一个跨节点共享状态的方案上。

这篇文章我从实战角度,带你用 Spring Boot + Redis 做一个可运行的高并发接口限流方案,并重点讲清楚:

  • 为什么单机内存计数不够用
  • Redis 限流为什么适合分布式部署
  • 如何用 Lua 保证限流原子性
  • Spring Boot 中如何通过注解 + AOP 快速接入
  • 高并发下常见坑怎么排查、怎么优化

这不是只讲概念的文章,后面会直接给出能跑起来的代码。


背景与问题

在 Web 服务里,接口限流通常有几类典型场景:

  1. 保护系统容量

    • 防止突发流量把应用、数据库、下游服务压垮
  2. 防刷与风控

    • 例如短信验证码接口、登录接口、抽奖接口,避免被恶意请求刷爆
  3. 公平性控制

    • 避免单个用户或单个 IP 吃掉全部资源
  4. 削峰

    • 把瞬时洪峰控制在系统能承受的范围内

为什么单机限流不够

很多人一开始会这么做:

  • ConcurrentHashMap 计数
  • 用 Guava RateLimiter
  • 用本地令牌桶

在单实例项目里没问题,但一旦你的 Spring Boot 服务是多节点部署,就会立刻遇到几个问题:

  • 每台机器计数不一致
  • 请求被负载均衡打散
  • 扩容后阈值失真
  • 服务重启导致状态丢失

这时候就需要一个全局共享、性能高、支持原子操作的中间件,Redis 正好适合这个位置。


方案对比与取舍分析

在开始写代码前,先把几种常见方案摆清楚。

方案一:单机内存限流

优点:

  • 实现简单
  • 性能高
  • 无外部依赖

缺点:

  • 不适合分布式
  • 重启后状态丢失
  • 无法全局统一限流

适用场景:

  • 单机服务
  • 边缘节点本地保护
  • 对精度要求不高

方案二:Nginx / 网关层限流

优点:

  • 靠近流量入口
  • 能提前拦截恶意请求
  • 减少应用层压力

缺点:

  • 规则颗粒度有限
  • 业务维度不灵活
  • 对“用户 ID、业务参数”等细粒度控制不方便

适用场景:

  • 全局 QPS 控制
  • IP 级防刷
  • 基础防护

方案三:Redis 分布式限流

优点:

  • 支持多实例共享限流状态
  • 原子操作强
  • 规则灵活,可按用户、IP、接口、租户等维度限流

缺点:

  • 依赖 Redis
  • 设计不当会有性能和精度问题
  • 热点 key 需要关注

适用场景:

  • Spring Boot 多实例部署
  • 业务级限流
  • 需要精细化控制

本文的选择

本文采用:

  • Spring Boot
  • Redis
  • Lua 脚本
  • 注解 + AOP
  • 固定时间窗口限流

为什么先选固定窗口?

因为它够简单、够好落地,很多中型业务已经够用。如果后续要更平滑,可以演进到滑动窗口或令牌桶。


核心原理

固定窗口限流思路

核心规则很简单:

在一个时间窗口内,例如 60 秒,某个 key 最多允许访问 100 次。

流程如下:

  1. 根据规则生成限流 key,例如:
    • rate_limit:/api/sendCode:uid_1001
  2. Redis 对这个 key 进行计数
  3. 第一次访问时设置过期时间,例如 60 秒
  4. 每次请求计数加 1
  5. 如果计数超过阈值,则拒绝请求

这个方案的关键点,不是“会不会写 INCR”,而是:

计数 + 设置过期时间必须是原子操作

否则在高并发下会出现:

  • 计数成功了,但没来得及设置过期时间
  • key 变成永久存在
  • 后续一直被限流

这类问题我在早期项目里真踩过,排查起来非常恶心。

为什么要用 Lua 脚本

如果你用 Java 分两步执行:

  1. INCR key
  2. EXPIRE key 60

那这两步之间可能被打断。

而 Redis Lua 脚本的好处是:

  • 在 Redis 端一次执行
  • 天然原子
  • 减少网络往返
  • 高并发场景下更稳

限流流程图

flowchart TD
    A[客户端请求接口] --> B[Spring Boot AOP拦截]
    B --> C[生成限流Key]
    C --> D[执行Redis Lua脚本]
    D --> E{是否超过阈值}
    E -- 否 --> F[放行业务逻辑]
    E -- 是 --> G[返回429或业务错误码]

时序图:一次请求如何被限流

sequenceDiagram
    participant Client as 客户端
    participant App as Spring Boot
    participant AOP as 限流切面
    participant Redis as Redis

    Client->>App: HTTP 请求
    App->>AOP: 进入被注解方法
    AOP->>AOP: 解析限流规则与维度
    AOP->>Redis: EVAL Lua(计数+过期)
    Redis-->>AOP: 返回是否允许
    alt 允许
        AOP->>App: 放行
        App-->>Client: 正常响应
    else 拒绝
        AOP-->>Client: 429/限流提示
    end

容量估算:上线前别拍脑袋

做限流时,除了规则,还要想 Redis 会承受多少压力。

假设:

  • 应用集群总流量:20,000 QPS
  • 其中有 30% 的请求需要走限流逻辑
  • 每个请求做 1 次 Lua 执行

那么 Redis 每秒要承受:

20,000 × 30% = 6,000 次限流操作/秒

如果是单个 Redis 实例,这通常还在可接受范围内;但如果你把大量高频接口、用户维度、IP 维度都压上去,就要关注:

  • Redis CPU
  • 网络 RTT
  • 热点 key
  • 慢查询
  • 集群分片均衡

经验上:

  • 中等规模项目,单实例 Redis 往往能扛住
  • 大流量场景,建议做多维度拆分 + 网关预限流 + 应用层细粒度限流的组合方案

实战代码(可运行)

下面给一套简化但完整的 Spring Boot 实战代码。

项目依赖

如果你使用 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>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
        <version>3.12.0</version>
    </dependency>
</dependencies>

application.yml 配置

server:
  port: 8080

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    timeout: 2000ms

定义限流注解

package com.example.ratelimit.annotation;

import java.lang.annotation.*;

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

    /**
     * 限流key前缀
     */
    String key() default "";

    /**
     * 时间窗口,单位:秒
     */
    int window() default 60;

    /**
     * 最大请求次数
     */
    int max() default 100;

    /**
     * 限流维度:IP / USER / GLOBAL
     */
    LimitType limitType() default LimitType.IP;
}

定义限流维度枚举

package com.example.ratelimit.annotation;

public enum LimitType {
    IP,
    USER,
    GLOBAL
}

Redis Lua 脚本

这个脚本做三件事:

  1. 获取当前计数
  2. 未超限则自增
  3. 第一次自增时设置过期时间
local key = KEYS[1]
local max = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('GET', key)

if current and tonumber(current) >= max then
    return 0
end

current = redis.call('INCR', key)

if tonumber(current) == 1 then
    redis.call('EXPIRE', key, window)
end

return 1

Lua 脚本配置类

package com.example.ratelimit.config;

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

@Configuration
public class RedisLuaConfig {

    @Bean
    public DefaultRedisScript<Long> rateLimitScript() {
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setLocation(new ClassPathResource("lua/rate_limit.lua"));
        redisScript.setResultType(Long.class);
        return redisScript;
    }
}

将脚本放在:

src/main/resources/lua/rate_limit.lua

获取客户端 IP 工具类

这里做一个基础版本,生产环境通常还要结合网关和可信代理配置。

package com.example.ratelimit.util;

import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.StringUtils;

public class IpUtil {

    public static String getIp(HttpServletRequest request) {
        String[] headers = {
                "X-Forwarded-For",
                "Proxy-Client-IP",
                "WL-Proxy-Client-IP",
                "X-Real-IP"
        };

        for (String header : headers) {
            String value = request.getHeader(header);
            if (StringUtils.isNotBlank(value) && !"unknown".equalsIgnoreCase(value)) {
                return value.split(",")[0].trim();
            }
        }

        return request.getRemoteAddr();
    }
}

Redis 限流服务

package com.example.ratelimit.service;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import java.util.Collections;

@Service
public class RedisRateLimitService {

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

    public RedisRateLimitService(StringRedisTemplate stringRedisTemplate,
                                 DefaultRedisScript<Long> rateLimitScript) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.rateLimitScript = rateLimitScript;
    }

    public boolean tryAcquire(String key, int max, int window) {
        Long result = stringRedisTemplate.execute(
                rateLimitScript,
                Collections.singletonList(key),
                String.valueOf(max),
                String.valueOf(window)
        );
        return result != null && result == 1L;
    }
}

AOP 切面实现

package com.example.ratelimit.aspect;

import com.example.ratelimit.annotation.LimitType;
import com.example.ratelimit.annotation.RateLimit;
import com.example.ratelimit.service.RedisRateLimitService;
import com.example.ratelimit.util.IpUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Aspect
@Component
public class RateLimitAspect {

    private final RedisRateLimitService redisRateLimitService;

    public RateLimitAspect(RedisRateLimitService redisRateLimitService) {
        this.redisRateLimitService = redisRateLimitService;
    }

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

        String limitKey = buildKey(request, rateLimit);

        boolean allowed = redisRateLimitService.tryAcquire(
                limitKey,
                rateLimit.max(),
                rateLimit.window()
        );

        if (!allowed) {
            throw new RuntimeException("请求过于频繁,请稍后再试");
        }

        return point.proceed();
    }

    private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
        String uri = request.getRequestURI();
        String prefix = "rate_limit:" + uri;

        if (rateLimit.key() != null && !rateLimit.key().isEmpty()) {
            prefix = "rate_limit:" + rateLimit.key();
        }

        LimitType type = rateLimit.limitType();
        switch (type) {
            case IP:
                return prefix + ":ip:" + IpUtil.getIp(request);
            case USER:
                String userId = request.getHeader("X-User-Id");
                if (userId == null || userId.isEmpty()) {
                    userId = "anonymous";
                }
                return prefix + ":user:" + userId;
            case GLOBAL:
            default:
                return prefix + ":global";
        }
    }
}

统一异常返回

实际项目里不要直接把 RuntimeException 原样抛给前端,建议统一包装。

package com.example.ratelimit.controller;

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(RuntimeException.class)
    public Map<String, Object> handleRuntimeException(RuntimeException e) {
        Map<String, Object> result = new HashMap<>();
        result.put("code", 429);
        result.put("message", e.getMessage());
        return result;
    }
}

测试控制器

package com.example.ratelimit.controller;

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

import java.util.HashMap;
import java.util.Map;

@RestController
public class DemoController {

    @GetMapping("/api/sms/send")
    @RateLimit(key = "sms_send", window = 60, max = 5, limitType = LimitType.IP)
    public Map<String, Object> sendSms() {
        Map<String, Object> result = new HashMap<>();
        result.put("success", true);
        result.put("message", "短信发送成功");
        return result;
    }

    @GetMapping("/api/order/query")
    @RateLimit(key = "order_query", window = 10, max = 20, limitType = LimitType.USER)
    public Map<String, Object> queryOrder(@RequestHeader(value = "X-User-Id", required = false) String userId) {
        Map<String, Object> result = new HashMap<>();
        result.put("success", true);
        result.put("userId", userId);
        result.put("message", "订单查询成功");
        return result;
    }
}

启动类

package com.example.ratelimit;

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

如何验证

测试 IP 限流接口

60 秒内访问 5 次:

curl http://localhost:8080/api/sms/send

多次执行后,你会看到:

{"code":429,"message":"请求过于频繁,请稍后再试"}

测试用户维度限流

curl -H "X-User-Id: 1001" http://localhost:8080/api/order/query

如果换一个用户 ID:

curl -H "X-User-Id: 1002" http://localhost:8080/api/order/query

它们会分别计数。


类关系图

classDiagram
    class RateLimit {
        +String key
        +int window
        +int max
        +LimitType limitType
    }

    class RateLimitAspect {
        +around(point, rateLimit) Object
        -buildKey(request, rateLimit) String
    }

    class RedisRateLimitService {
        +tryAcquire(key, max, window) boolean
    }

    class DemoController

    RateLimitAspect --> RedisRateLimitService
    DemoController ..> RateLimit

核心实现细节拆解

上面的代码能跑,但真正理解之后,后续扩展才不费劲。

1. 限流 key 设计比代码本身更重要

建议 key 结构保持统一,例如:

rate_limit:{业务}:{接口}:{维度}:{标识}

例子:

rate_limit:marketing:sms_send:ip:10.1.1.8
rate_limit:order:query:user:1001
rate_limit:login:submit:global

这样做的好处:

  • 便于排查
  • 便于监控
  • 便于批量统计
  • 便于后续迁移规则

2. 维度选择要结合业务

常见维度:

  • IP 维度:适合匿名接口、防刷
  • 用户维度:适合登录后接口、公平性控制
  • 全局维度:适合保护系统总吞吐
  • 租户维度:多租户系统常见
  • 设备维度:移动端防刷常用

不要一上来全按 IP 限流,因为在 NAT、公司出口网络、移动网络场景下,很多用户可能共享一个出口 IP,会误伤。

3. 固定窗口的边界问题

固定窗口虽然简单,但存在一个经典问题:

  • 在窗口结束前 1 秒来了 100 个请求
  • 下一个窗口开始后 1 秒又来了 100 个请求

理论上 2 秒内通过了 200 个请求,瞬时并不平滑。

如果你的业务对平滑性特别敏感,可以升级为:

  • 滑动窗口
  • 漏桶
  • 令牌桶

但对很多中级业务系统来说,固定窗口已经足够实用,尤其是防刷场景。


常见坑与排查

这一部分很关键。因为限流代码通常不复杂,复杂的是线上行为。

坑一:Redis key 没设置过期时间

现象

某些用户或 IP 被永久限流,过了很久都没恢复。

原因

INCR + EXPIRE 分开执行,执行中断导致只加计数没加过期。

排查

在 Redis 中查看:

TTL rate_limit:sms_send:ip:127.0.0.1

如果返回 -1,说明没有过期时间。

解决

必须改成 Lua 原子执行。


坑二:获取到的客户端 IP 不真实

现象

明明多个用户访问,结果全被算成同一个 IP。

原因

服务部署在 Nginx、网关、SLB 后面,直接取到的是代理层 IP。

排查

打印这些值:

  • X-Forwarded-For
  • X-Real-IP
  • request.getRemoteAddr()

解决

  • 让网关正确透传真实 IP
  • 应用层只信任可信代理头
  • 不要盲目信任客户端自带的转发头

这个问题很常见,我建议你上线前一定在测试环境把真实链路走一遍。


坑三:限流规则误伤正常用户

现象

用户频繁投诉“我没怎么点就被拦了”。

原因

  • 阈值设置过低
  • 时间窗口过长
  • 维度选错,比如把共享 IP 当成个人身份

排查思路

查看:

  • 限流 key 分布
  • 某接口的请求峰值
  • 某个 key 的累计命中次数
  • 是否集中在特定出口网络

解决

  • 对匿名接口用 IP 维度
  • 对登录接口用用户维度
  • 高价值操作加图形验证码或二次校验
  • 用灰度方式逐步收紧阈值

坑四:热点 key 导致 Redis 压力异常

现象

某个接口做全局限流时,Redis 单 key 访问过于集中。

原因

例如:

rate_limit:submit_order:global

所有请求都打到这一个 key 上。

解决思路

  • 只在确实需要时使用全局限流
  • 更细粒度按用户、租户拆分
  • 网关层先挡一层
  • 超高并发时引入本地预限流

坑五:限流失败时直接抛 500

现象

前端看到的是服务错误,而不是“访问过快”。

原因

没区分业务异常和系统异常。

解决

建议定义专门的限流异常,并返回统一状态码,如:

  • HTTP 429 Too Many Requests
  • 业务码如 RATE_LIMITED

安全/性能最佳实践

限流不只是“拦住请求”,更要考虑误伤率、可观测性和故障降级。

1. 返回 429 比返回 500 更合理

如果是 HTTP API,建议明确返回:

429 Too Many Requests

配合响应体说明:

  • 限流原因
  • 建议重试时间
  • 业务错误码

这样前端和调用方更容易处理。


2. 重要接口建议多层限流

一个比较稳妥的架构是:

  • 网关层:挡掉明显恶意流量
  • 应用层 Redis 限流:做业务维度精细控制
  • 下游服务保护:线程池、熔断、隔离

也就是说,不要把所有保护压力都压给 Redis 限流。


3. 限流失败策略要提前想好

如果 Redis 挂了,限流逻辑怎么办?

常见有两种策略:

策略 A:失败放行

优点:

  • 保证主流程可用

缺点:

  • 失去保护能力,可能流量冲垮系统

适合:

  • 非核心限流
  • 对可用性要求极高的读接口

策略 B:失败拒绝

优点:

  • 更安全,能保护后端

缺点:

  • Redis 故障会直接影响业务

适合:

  • 短信发送
  • 登录风控
  • 支付敏感接口

我的建议是:按接口分级,不要一刀切。


4. 加监控,否则限流等于“黑盒”

建议至少监控这些指标:

  • 限流总请求数
  • 被拦截次数
  • 各接口限流命中率
  • Redis Lua 执行耗时
  • Redis 异常次数
  • 热点 key 排名

这样你才能判断:

  • 是规则太严了
  • 还是流量真的异常
  • 还是 Redis 本身有瓶颈

5. 对高并发热点接口做本地 + Redis 双层限流

在超高并发下,可以考虑:

  • 第一层:JVM 本地快速限流
  • 第二层:Redis 全局限流

这么做的好处:

  • 降低 Redis 压力
  • 避免所有请求都远程打 Redis
  • 在极端洪峰下更稳

边界条件是:

  • 本地限流只是预过滤,不能替代全局限流
  • 阈值设计要避免双重误伤

6. 敏感接口不要只靠限流

像短信、登录、注册、优惠券领取这类接口,单纯限流不够,最好组合:

  • 验证码
  • 签名校验
  • 用户行为分析
  • 设备指纹
  • 黑名单
  • 风控策略

限流只是基础防线,不是全部安全方案。


可进一步演进的方向

如果你已经把固定窗口跑通,后续可以继续优化。

1. 滑动窗口限流

优势:

  • 更平滑
  • 边界突刺更少

常见做法:

  • Redis ZSet 记录时间戳
  • 删除窗口外数据
  • 统计窗口内请求数

代价:

  • 实现更复杂
  • Redis 开销更高

2. 令牌桶限流

优势:

  • 更适合控制平均速率
  • 允许一定突发流量

适合:

  • API 平台
  • 网关流控
  • 下游依赖保护

3. 动态配置限流规则

把规则从注解硬编码演进成配置中心下发,比如:

  • Nacos
  • Apollo
  • 数据库存储 + 本地缓存

这样就能做到:

  • 不发版改限流阈值
  • 临时收紧热点接口
  • 分环境、分租户、分用户组控制

一个更贴近生产的建议

如果你问我“实际项目怎么落地最稳”,我的答案通常是:

  1. 先用注解 + AOP 把核心接口保护起来
  2. 按用户/IP 两种维度分别建规则
  3. 统一返回 429 和友好提示
  4. 把限流命中日志和监控补齐
  5. 热点接口再叠加网关限流
  6. 对短信、登录等敏感接口增加验证码/风控

不要一开始就追求最复杂的算法,先把能挡住 80% 风险的方案做稳,收益最高。


总结

基于 Spring Boot + Redis 做高并发接口限流,本质上是在分布式场景下,用 Redis 维护共享计数状态,再借助 Lua 保证操作原子性。这个方案之所以常用,是因为它有几个很现实的优点:

  • 接入成本不高
  • 对 Spring Boot 项目友好
  • 能支持多实例部署
  • 规则维度灵活
  • 在大多数业务场景下足够实用

落地时最关键的几点,我建议你记住:

  1. 不要用非原子的 INCR + EXPIRE
  2. 限流 key 设计要清晰可排查
  3. 维度选择要贴合业务,不要迷信 IP
  4. 返回码、监控、降级策略必须配套
  5. 超高并发场景考虑网关层 + 本地层 + Redis 层组合防护

如果你的项目目前还没有限流,我建议优先从以下接口开始:

  • 登录
  • 短信发送
  • 验证码
  • 订单提交
  • 活动报名
  • 导出查询

这些地方最容易被流量洪峰和恶意请求打穿,也是限流最能立刻见效的地方。

限流不是“写完一个注解就结束”,而是系统稳定性设计的一部分。把它做好,很多线上事故其实能提前挡在门外。


分享到:

上一篇
《从提示工程到 RAG:中级开发者构建企业级 AI 问答系统的实战路径》
下一篇
《分布式架构中基于幂等设计与消息队列的订单系统一致性实战指南》