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

《Java Web 开发中基于 Spring Boot + Redis 实现接口限流与防刷的实战指南》

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

Java Web 开发中基于 Spring Boot + Redis 实现接口限流与防刷的实战指南

在 Java Web 项目里,“接口被刷”几乎不是会不会发生的问题,而是什么时候发生。

我自己做后台接口时,最早踩过一个很典型的坑:登录短信验证码接口没做限流,结果有人半夜写脚本高频调用,短信平台费用直接飙升。后来我们把接口限流、防重复提交、防恶意刷接口这几件事统一收口到 Spring Boot + Redis 上,效果很稳,成本也低。

这篇文章就按能落地、能运行、能排查的思路,带你从 0 到 1 做一套适合中型项目的接口限流方案。


背景与问题

在实际业务中,接口限流和防刷通常对应下面几类场景:

  • 登录接口:防暴力破解密码
  • 短信验证码接口:防恶意轰炸
  • 下单/抢购接口:防瞬时流量压垮服务
  • 公开查询接口:防爬虫、薅资源
  • 支付回调或幂等接口:防重复提交

如果不处理,常见后果包括:

  • 应用线程被打满,正常用户也不可用
  • 数据库连接耗尽,整站雪崩
  • 第三方资源被恶意消耗,比如短信、邮件、OCR 次数
  • 被脚本绕过前端校验,持续高频调用接口

为什么选 Redis?

很多人第一反应是:在 Java 代码里加个 ConcurrentHashMap 计数不就行了?

开发环境可以,生产通常不够。原因很直接:

  • 单机内存方案不支持集群共享
  • 应用重启后状态丢失
  • 不适合多实例部署
  • 过期控制、原子递增、Lua 脚本这些 Redis 天然更合适

所以,Spring Boot 负责接入与拦截,Redis 负责计数、过期和原子控制,这是很常见也很实用的组合。


前置知识与环境准备

本文示例环境:

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

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.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

application.yml

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

server:
  port: 8080

核心原理

接口限流的核心,不是“拦多少”,而是“按什么维度、在什么时间窗、用什么算法拦”。

常见限流维度

通常可以按以下维度组合:

  • 按 IP
  • 按用户 ID
  • 按接口路径
  • 按设备标识
  • 按业务参数,比如手机号、订单号

在实际项目里,我更建议用组合 key,例如:

rate_limit:login:ip:192.168.1.10
rate_limit:sms:user:10001
rate_limit:sms:mobile:13800000000

常见限流算法

1. 固定窗口

比如“1 分钟内最多 5 次”。

优点:

  • 简单
  • 好实现
  • Redis INCR + EXPIRE 就能做

缺点:

  • 窗口边界容易突刺
    比如 00:59 调 5 次,01:00 再调 5 次,短时间内相当于 10 次

2. 滑动窗口

更平滑,统计最近一段时间内的请求数。

优点:

  • 更接近真实控制
  • 比固定窗口更公平

缺点:

  • 实现复杂一点
  • Redis 需要 ZSET 或 Lua 脚本

3. 令牌桶/漏桶

适合更通用的流量整形。

优点:

  • 平滑吞吐
  • 适合网关层

缺点:

  • 业务接口单独实现时复杂度更高

本文选型

这篇文章会给出两种实战方案:

  1. 固定窗口限流:最适合先落地
  2. Lua 脚本原子限流:避免并发下 INCREXPIRE 分离带来的边界问题

整体设计思路

我们先做一个适合业务接口层使用的方案:

  • 自定义注解 @RateLimit
  • AOP 切面统一拦截
  • Redis 记录计数
  • 超限时直接返回 429
  • 支持按 IP、用户 ID、自定义 key 限流

设计流程图

flowchart TD
    A[请求进入 Spring Boot] --> B[匹配到控制器方法]
    B --> C{是否标注 @RateLimit}
    C -- 否 --> D[正常执行业务逻辑]
    C -- 是 --> E[构建限流Key]
    E --> F[调用 Redis Lua 脚本计数]
    F --> G{是否超限}
    G -- 否 --> D
    G -- 是 --> H[返回 429 Too Many Requests]

时序图

sequenceDiagram
    participant Client as 客户端
    participant App as Spring Boot
    participant AOP as RateLimitAspect
    participant Redis as Redis

    Client->>App: HTTP 请求
    App->>AOP: 进入带 @RateLimit 的方法
    AOP->>AOP: 生成限流 key
    AOP->>Redis: 执行 Lua 脚本(INCR + EXPIRE)
    Redis-->>AOP: 返回当前次数/是否超限
    alt 未超限
        AOP->>App: 放行
        App-->>Client: 正常响应
    else 已超限
        AOP-->>Client: 429 请求过于频繁
    end

实战代码(可运行)

下面我们直接搭一套完整代码。


第一步:定义限流注解

package com.example.demo.ratelimit;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

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

    /**
     * key 前缀,用于区分不同业务
     */
    String key() default "";

    /**
     * 时间窗口内最大请求次数
     */
    long limit();

    /**
     * 时间窗口大小
     */
    long window();

    /**
     * 时间单位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 限流类型:IP / USER / CUSTOM
     */
    LimitType limitType() default LimitType.IP;

    /**
     * 自定义标识,limitType=CUSTOM 时使用
     */
    String customKey() default "";
}

第二步:定义限流类型枚举

package com.example.demo.ratelimit;

public enum LimitType {
    IP,
    USER,
    CUSTOM
}

第三步:统一异常类

package com.example.demo.ratelimit;

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

第四步:Redis Lua 脚本配置

这里用 Lua 是因为它能保证 Redis 内部执行的原子性
这是线上项目里很关键的一点,我建议不要把 INCREXPIRE 拆成两次 Java 调用。

package com.example.demo.config;

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

@Configuration
public class RedisLuaConfig {

    @Bean
    public DefaultRedisScript<Long> rateLimitScript() {
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptText("""
                local key = KEYS[1]
                local limit = tonumber(ARGV[1])
                local expireTime = tonumber(ARGV[2])

                local current = redis.call('INCR', key)
                if current == 1 then
                    redis.call('EXPIRE', key, expireTime)
                end

                if current > limit then
                    return 0
                else
                    return current
                end
                """);
        redisScript.setResultType(Long.class);
        return redisScript;
    }
}

返回值说明:

  • 返回 0:表示超限
  • 返回 1..limit:表示当前计数,允许通过

第五步:获取用户/IP 信息的工具类

为了演示简单,我这里优先从请求头取真实 IP,再回退到 remoteAddr

package com.example.demo.ratelimit;

import jakarta.servlet.http.HttpServletRequest;

public class RequestUtil {

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

        for (String header : headers) {
            String ip = request.getHeader(header);
            if (ip != null && !ip.isBlank() && !"unknown".equalsIgnoreCase(ip)) {
                return ip.split(",")[0].trim();
            }
        }
        return request.getRemoteAddr();
    }

    /**
     * 示例:实际项目中应从 Spring Security / JWT / Session 中获取用户ID
     */
    public static String getCurrentUserId(HttpServletRequest request) {
        String userId = request.getHeader("X-User-Id");
        return (userId == null || userId.isBlank()) ? "anonymous" : userId;
    }
}

第六步:实现 AOP 切面

package com.example.demo.ratelimit;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
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;
import java.util.concurrent.TimeUnit;

@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {

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

    @Before("@annotation(rateLimit)")
    public void doBefore(JoinPoint joinPoint, RateLimit rateLimit) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
                .currentRequestAttributes()).getRequest();

        String limitKey = buildKey(request, rateLimit);
        long expireSeconds = rateLimit.timeUnit().toSeconds(rateLimit.window());

        Long result = stringRedisTemplate.execute(
                rateLimitScript,
                Collections.singletonList(limitKey),
                String.valueOf(rateLimit.limit()),
                String.valueOf(expireSeconds)
        );

        if (result == null || result == 0L) {
            throw new RateLimitException("请求过于频繁,请稍后再试");
        }
    }

    private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
        String prefix = "rate_limit";
        String bizKey = rateLimit.key().isBlank() ? request.getRequestURI() : rateLimit.key();

        return switch (rateLimit.limitType()) {
            case IP -> prefix + ":" + bizKey + ":ip:" + RequestUtil.getClientIp(request);
            case USER -> prefix + ":" + bizKey + ":user:" + RequestUtil.getCurrentUserId(request);
            case CUSTOM -> prefix + ":" + bizKey + ":custom:" + rateLimit.customKey();
        };
    }
}

第七步:统一异常处理

package com.example.demo.web;

import com.example.demo.ratelimit.RateLimitException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

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

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RateLimitException.class)
    public ResponseEntity<?> handleRateLimitException(RateLimitException e) {
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                .body(Map.of(
                        "timestamp", LocalDateTime.now().toString(),
                        "code", 429,
                        "message", e.getMessage()
                ));
    }
}

第八步:写一个测试控制器

package com.example.demo.web;

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

import java.util.Map;
import java.util.concurrent.TimeUnit;

@RestController
public class DemoController {

    /**
     * 按IP限流:10秒内最多访问3次
     */
    @GetMapping("/api/test/ip")
    @RateLimit(key = "test_ip", limit = 3, window = 10, timeUnit = TimeUnit.SECONDS, limitType = LimitType.IP)
    public Map<String, Object> ipLimit() {
        return Map.of(
                "success", true,
                "message", "IP限流接口访问成功"
        );
    }

    /**
     * 按用户限流:60秒内最多访问5次
     * 测试时带请求头 X-User-Id
     */
    @GetMapping("/api/test/user")
    @RateLimit(key = "test_user", limit = 5, window = 60, timeUnit = TimeUnit.SECONDS, limitType = LimitType.USER)
    public Map<String, Object> userLimit() {
        return Map.of(
                "success", true,
                "message", "用户限流接口访问成功"
        );
    }

    /**
     * 模拟短信接口:60秒内同一个接口最多1次
     */
    @GetMapping("/api/test/sms")
    @RateLimit(key = "sms_send", limit = 1, window = 60, timeUnit = TimeUnit.SECONDS, limitType = LimitType.IP)
    public Map<String, Object> sendSms() {
        return Map.of(
                "success", true,
                "message", "短信发送成功(模拟)"
        );
    }
}

第九步:启动类

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

逐步验证清单

代码写完后,别急着说“应该能跑”。建议按下面步骤验证。

1. 启动 Redis

如果你本地有 Docker,可以直接这样起:

docker run -d --name redis-test -p 6379:6379 redis:7

2. 启动 Spring Boot

mvn spring-boot:run

3. 测试按 IP 限流接口

curl http://localhost:8080/api/test/ip
curl http://localhost:8080/api/test/ip
curl http://localhost:8080/api/test/ip
curl http://localhost:8080/api/test/ip

前三次正常,第四次应返回 429

4. 测试按用户限流

curl -H "X-User-Id: 1001" http://localhost:8080/api/test/user

连续调用 5 次后,第 6 次应被拦截。

5. 查看 Redis 中的 key

redis-cli
keys rate_limit:*

你会看到类似:

rate_limit:test_ip:ip:127.0.0.1
rate_limit:test_user:user:1001

进一步增强:短信/验证码防刷不能只按 IP

这里要特别提醒一句:真正的防刷不能只看 IP

因为攻击者可能:

  • 使用代理池切换 IP
  • 多人共用同一出口 IP
  • 手机号被单独恶意轰炸

所以验证码或短信接口,通常至少要叠加几层限制:

  1. 同 IP 每分钟次数限制
  2. 同用户每日次数限制
  3. 同手机号发送间隔限制
  4. 图形验证码/行为验证码
  5. 设备指纹校验
  6. 黑名单策略

一个更贴近业务的防刷策略图

flowchart LR
    A[短信发送请求] --> B{图形验证码通过?}
    B -- 否 --> X[拒绝]
    B -- 是 --> C{手机号60秒内是否已发送}
    C -- 是 --> X
    C -- 否 --> D{同IP分钟级次数是否超限}
    D -- 是 --> X
    D -- 否 --> E{同用户日发送次数是否超限}
    E -- 是 --> X
    E -- 否 --> F[发送短信]

常见坑与排查

这一部分非常重要。很多限流“看起来写完了”,但线上不生效,大多栽在这些点上。

1. 获取 IP 不准确

现象

明明不同用户访问,结果都被识别成同一个 IP。

原因

项目部署在 Nginx、网关、负载均衡后面,直接拿到的是代理服务器 IP。

排查方法

打印:

System.out.println(request.getHeader("X-Forwarded-For"));
System.out.println(request.getRemoteAddr());

同时检查 Nginx 是否透传真实 IP:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;

我之前就遇到过这个坑:AOP 写得没问题,但所有请求都被网关 IP 命中,结果整批用户误伤。


2. INCREXPIRE 分开写导致计数脏数据

错误写法示例

Long count = stringRedisTemplate.opsForValue().increment(key);
if (count != null && count == 1) {
    stringRedisTemplate.expire(key, 60, TimeUnit.SECONDS);
}

问题

如果 INCR 成功后应用挂了,EXPIRE 没执行,key 可能变成永久存在

建议

  • 用 Lua 脚本保证原子性
  • 或使用 Redis 原生命令组合方案

3. CUSTOM 维度写死,导致限流失效

如果 customKey = "abc" 是固定值,那么所有请求共用一个计数器,可能不是你想要的效果。

正确思路

CUSTOM 应该来自业务动态值,例如:

  • 手机号
  • 订单号
  • 设备号

更成熟的做法是支持 SpEL 表达式,例如:

@RateLimit(key = "sms", limit = 1, window = 60, limitType = LimitType.CUSTOM, customKey = "#mobile")

本文先不展开 SpEL 实现,但你要知道这一步在企业项目里很常见。


4. Redis 序列化问题

如果你混用了 RedisTemplate<Object, Object>StringRedisTemplate,key 序列化可能不一致,导致:

  • 程序里能写
  • redis-cli 里看不懂
  • 或者脚本执行 key 对不上

建议

限流场景优先使用:

StringRedisTemplate

简单直接,最不容易出错。


5. 限流返回码不规范

很多项目直接返回 200,然后 body 里写“请求过于频繁”。

这不算错,但对前端、网关、监控不友好。

建议

优先返回:

  • 429 Too Many Requests

这样日志、APM、前端处理都更清晰。


安全/性能最佳实践

限流不是简单“加个注解”就结束了,下面这些建议更接近真实项目。

1. 分层限流,不要只在 Controller 层处理

推荐至少分两层:

  • 网关层限流:挡住明显恶意流量
  • 应用层限流:处理业务维度,如用户、手机号、订单

如果只有应用层限流,恶意流量依然会打到你的服务实例上。


2. 区分“限流”和“防刷”

两者相关,但不完全一样:

  • 限流:控制访问频率
  • 防刷:识别恶意行为并阻断

防刷往往还需要结合:

  • 验证码
  • 签名校验
  • Token
  • 设备指纹
  • 黑白名单
  • 风控规则

别指望 Redis 计数器单独解决所有问题。


3. 针对不同接口设置不同阈值

不要全站一个配置。

例如:

  • 登录接口:1 分钟 5 次
  • 短信接口:1 分钟 1 次,1 天 10 次
  • 查询接口:10 秒 20 次
  • 导出接口:5 分钟 2 次

业务价值越高、资源越贵,阈值就该越严格。


4. Redis key 设计要可观测

建议规范 key:

rate_limit:{biz}:{dimension}:{value}

比如:

rate_limit:login:ip:10.0.0.8
rate_limit:sms:user:10086
rate_limit:sms:mobile:13800138000

这样你排查时一眼就能看懂,而不是留下一堆莫名其妙的 key。


5. 热点接口注意 Redis 压力

如果是超高频接口,所有请求都打 Redis,也会有压力。

可考虑:

  • 本地缓存 + Redis 双层限流
  • 网关侧先挡一层
  • 对只读查询场景做边缘缓存
  • 使用 Lua 减少网络往返
  • 限流 key 设置合理 TTL,避免积压

6. 失败策略要明确

当 Redis 不可用时,系统怎么办?

这不是技术细节,而是业务决策。

常见两种策略:

Fail-Open:放行

优点:

  • 不影响主业务可用性

缺点:

  • Redis 宕机时限流失效

Fail-Close:拒绝

优点:

  • 更安全

缺点:

  • Redis 故障会影响可用性

我的建议:

  • 登录、短信、验证码等高风险接口:偏向 Fail-Close
  • 普通查询接口:偏向 Fail-Open

可扩展方向

如果你准备把这套方案继续做深,可以考虑这些升级点:

1. 支持 SpEL 动态取参数

例如按手机号限流:

@RateLimit(key = "sms", limit = 1, window = 60, limitType = LimitType.CUSTOM, customKey = "#phone")

2. 支持滑动窗口

适合对公平性要求更高的接口。常见实现:

  • Redis ZADD
  • 记录时间戳
  • 定期清理窗口外数据
  • ZCOUNT 判断次数

3. 支持分布式黑名单

比如某个 IP 连续多次触发限流后,自动拉黑一段时间。

4. 与 Spring Security 联动

针对未登录用户按 IP,已登录用户按用户 ID,组合使用效果更好。


一个简单的类关系图

classDiagram
    class RateLimit {
        +String key()
        +long limit()
        +long window()
        +TimeUnit timeUnit()
        +LimitType limitType()
        +String customKey()
    }

    class LimitType {
        <<enumeration>>
        IP
        USER
        CUSTOM
    }

    class RateLimitAspect {
        -StringRedisTemplate stringRedisTemplate
        -DefaultRedisScript~Long~ rateLimitScript
        +doBefore(JoinPoint, RateLimit)
        -buildKey(HttpServletRequest, RateLimit) String
    }

    class RequestUtil {
        +getClientIp(HttpServletRequest) String
        +getCurrentUserId(HttpServletRequest) String
    }

    class RateLimitException {
        +RateLimitException(String)
    }

    RateLimitAspect --> RateLimit
    RateLimitAspect --> LimitType
    RateLimitAspect --> RequestUtil
    RateLimitAspect --> RateLimitException

总结

如果你想在 Spring Boot 项目里快速落地接口限流与防刷,我建议按这个顺序来:

  1. 先用注解 + AOP + Redis Lua 落地固定窗口限流
  2. 优先覆盖高风险接口:登录、短信、验证码、下单
  3. 限流维度不要只看 IP,至少叠加用户、手机号等业务维度
  4. 统一返回 429,方便前端和监控系统识别
  5. 线上一定验证真实 IP 获取是否正确
  6. 高风险场景把限流和验证码、黑名单、风控规则组合使用

一句话概括:
Redis 适合做“计数与过期”,Spring Boot 适合做“接入与业务拦截”,两者结合能很高效地实现一套可维护的接口限流方案。

如果你的项目现在还没有任何限流,我建议先把本文这版固定窗口方案用起来。它不一定是最强的,但绝对是投入产出比很高、上线速度很快的一种实现。等业务量和攻击面上来,再逐步升级到滑动窗口、网关限流和风控联动,会更稳。


分享到:

上一篇
《Java开发踩坑实战:排查并修复线程池误用导致的请求堆积与 OOM 问题》
下一篇
《Docker Compose 实战:为中型项目构建可复用的多环境开发与部署配置》