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

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

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

背景与问题

做 Java Web 接口时,大家通常先关注“能不能用”,但服务一上线,很快就会遇到另一个更现实的问题:有没有人把它打挂

常见场景包括:

  • 登录接口被暴力破解
  • 短信验证码接口被恶意刷取
  • 秒杀、抢券接口被瞬时高并发冲垮
  • 某些公开查询接口被爬虫高频调用
  • 内部接口因为调用方 bug 进入“死循环重试”

如果没有限流和防刷,后果往往不是“接口慢一点”这么简单,而是:

  • Redis、MySQL 连接池被打满
  • 应用线程阻塞,服务雪崩
  • 验证码、短信等第三方成本飙升
  • 用户体验急剧下降
  • 安全风险被放大

在单机时代,我们可能会先想到用 synchronized、本地计数器、Guava RateLimiter。但一旦进入多实例部署,这些方案就不够了。分布式环境下,Redis 是非常适合做接口限流与防刷的基础设施。

这篇文章我会用一个比较实战的角度,带你从 0 到 1 做出一个:

  • 支持按 IP / 用户 / 接口维度限流
  • 基于 Spring Boot 接入
  • 基于 Redis 保证多实例下计数一致
  • 代码可运行、易扩展
  • 适合中小型业务直接落地

前置知识与环境准备

本文默认你已经了解:

  • Spring Boot 基础使用
  • Redis 基本命令
  • Java 注解、AOP、拦截器的概念
  • Maven 或 Gradle 依赖管理

本文示例环境:

  • JDK 8+
  • Spring Boot 2.x
  • Redis 5.x / 6.x
  • Maven

一个典型调用链如下:

flowchart LR
    A[客户端请求] --> B[Spring Boot Controller]
    B --> C[限流拦截器/切面]
    C --> D[Redis 计数]
    D --> E{是否超限}
    E -- 否 --> F[业务逻辑]
    E -- 是 --> G[返回 429 或业务错误码]

核心原理

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

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

在 Web 接口防刷里,如果目标是“快速上手、低成本落地、能覆盖大多数业务场景”,我通常建议先用:

  • 固定窗口计数:实现最简单
  • 配合 Redis 的 INCR + EXPIRE
  • 对登录、验证码、公开查询接口已经足够实用

1. 固定窗口计数的思路

比如要求:

  • 同一 IP
  • 在 60 秒内
  • 最多访问 /api/sms/send 5 次

实现方式:

  • 用 Redis Key 记录计数,例如
    rate_limit:ip:127.0.0.1:/api/sms/send
  • 第一次访问时:
    • INCR key
    • EXPIRE key 60
  • 后续每次访问继续 INCR
  • 如果计数值大于 5,则拒绝请求

这个方法的好处是:

  • 简单
  • 性能高
  • 容易接入 Spring Boot

不足也要知道:

  • 存在“窗口临界突刺”问题
    比如某用户在第 59 秒访问 5 次,第 61 秒再访问 5 次,短时间内可能打到 10 次

对于大多数后台接口、验证码接口、防爬接口,这个问题通常是可接受的。真要更精细,再升级滑动窗口或令牌桶。

2. 为什么 Redis 很适合做限流

Redis 的优势主要有三点:

  • 原子性强INCR 天然适合计数
  • 性能高:内存操作,QPS 非常可观
  • 天然分布式共享:多台 Spring Boot 实例看到的是同一份计数

3. 限流维度怎么选

实践中不要只考虑“按 IP 限流”,而是要根据接口类型组合维度:

场景推荐维度
登录接口IP + 用户名
短信发送手机号 + IP
下单接口用户 ID
公开查询接口IP
管理后台接口用户 ID + URI

我踩过一个比较典型的坑:只按 IP 限流。结果公司出口 NAT 下很多正常用户共用一个公网 IP,被一起误伤。
所以真正落地时,建议根据业务特征做多维组合。


方案设计

这里我们做一个可复用方案,设计目标是:

  • 用注解声明限流规则
  • 用 AOP 在方法执行前拦截
  • 用 Redis 做统一计数
  • 支持自定义 Key:IP、用户、URI
  • 超限时抛出统一异常

整体结构如下:

classDiagram
    class RateLimit {
        +int max
        +int windowSeconds
        +String keyPrefix
        +LimitType limitType
        +String message
    }

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

    class RateLimitAspect {
        +Object around()
        -String buildKey()
    }

    class RedisRateLimiterService {
        +boolean tryAcquire(String key, int windowSeconds, int max)
    }

    class DemoController {
        +String sendSms()
    }

    RateLimitAspect --> RedisRateLimiterService
    DemoController --> RateLimit
    RateLimit --> LimitType

实战代码(可运行)

下面直接给出一套可落地的示例。

1. 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>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
</dependencies>

2. application.yml

server:
  port: 8080

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

3. 限流维度枚举

package com.example.demo.ratelimit;

public enum LimitType {
    IP,
    USER,
    URI,
    CUSTOM
}

4. 自定义注解

package com.example.demo.ratelimit;

import java.lang.annotation.*;

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

    int max();

    int windowSeconds();

    String keyPrefix() default "rate_limit";

    LimitType limitType() default LimitType.IP;

    String message() default "请求过于频繁,请稍后再试";
}

5. Redis 限流服务

先用最容易理解的实现:INCR 后首次设置过期时间。

package com.example.demo.ratelimit;

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

import java.util.concurrent.TimeUnit;

@Service
public class RedisRateLimiterService {

    private final StringRedisTemplate stringRedisTemplate;

    public RedisRateLimiterService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public boolean tryAcquire(String key, int windowSeconds, int max) {
        Long current = stringRedisTemplate.opsForValue().increment(key);
        if (current == null) {
            return false;
        }

        if (current == 1L) {
            stringRedisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS);
        }

        return current <= max;
    }
}

说明:
严格来说,incrementexpire 分成两步,极端情况下可能出现原子性问题。本文先带你跑通,后面“安全/性能最佳实践”里会给出 Lua 脚本版。

6. 获取请求信息工具类

package com.example.demo.ratelimit;

import javax.servlet.http.HttpServletRequest;

public class RequestUtil {

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

        ip = request.getHeader("X-Real-IP");
        if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) {
            return ip;
        }

        return request.getRemoteAddr();
    }
}

7. 限流异常

package com.example.demo.ratelimit;

public class RateLimitException extends RuntimeException {

    public RateLimitException(String message) {
        super(message);
    }
}

8. 全局异常处理

package com.example.demo.ratelimit;

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

9. AOP 切面实现

package com.example.demo.ratelimit;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;

import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Aspect
@Component
public class RateLimitAspect {

    private final RedisRateLimiterService redisRateLimiterService;

    public RateLimitAspect(RedisRateLimiterService redisRateLimiterService) {
        this.redisRateLimiterService = redisRateLimiterService;
    }

    @Around("@annotation(com.example.demo.ratelimit.RateLimit)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RateLimit rateLimit = method.getAnnotation(RateLimit.class);

        HttpServletRequest request = getRequest();
        String key = buildKey(rateLimit, request);

        boolean allowed = redisRateLimiterService.tryAcquire(
                key,
                rateLimit.windowSeconds(),
                rateLimit.max()
        );

        if (!allowed) {
            throw new RateLimitException(rateLimit.message());
        }

        return joinPoint.proceed();
    }

    private HttpServletRequest getRequest() {
        RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
        if (attributes == null) {
            throw new IllegalStateException("无法获取当前请求上下文");
        }
        return ((ServletRequestAttributes) attributes).getRequest();
    }

    private String buildKey(RateLimit rateLimit, HttpServletRequest request) {
        String prefix = rateLimit.keyPrefix();
        String uri = request.getRequestURI();

        switch (rateLimit.limitType()) {
            case IP:
                return prefix + ":ip:" + RequestUtil.getClientIp(request) + ":" + uri;
            case USER:
                String userId = request.getHeader("X-User-Id");
                if (userId == null || userId.trim().isEmpty()) {
                    userId = "anonymous";
                }
                return prefix + ":user:" + userId + ":" + uri;
            case URI:
                return prefix + ":uri:" + uri;
            case CUSTOM:
                String custom = request.getParameter("key");
                if (custom == null || custom.trim().isEmpty()) {
                    custom = "default";
                }
                return prefix + ":custom:" + custom + ":" + uri;
            default:
                return prefix + ":unknown:" + uri;
        }
    }
}

10. Controller 示例

package com.example.demo.controller;

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;

@RestController
public class DemoController {

    @GetMapping("/api/sms/send")
    @RateLimit(max = 5, windowSeconds = 60, limitType = LimitType.IP, message = "短信发送过于频繁")
    public String sendSms() {
        return "短信发送成功";
    }

    @GetMapping("/api/login")
    @RateLimit(max = 10, windowSeconds = 60, limitType = LimitType.IP, message = "登录请求过于频繁")
    public String login() {
        return "登录接口访问成功";
    }

    @GetMapping("/api/order/query")
    @RateLimit(max = 20, windowSeconds = 60, limitType = LimitType.USER, message = "查询过于频繁")
    public String queryOrder() {
        return "订单查询成功";
    }
}

11. 启动类

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

redis-server

或者本地已有 Redis 服务,确保 6379 可连通。

2. 启动 Spring Boot

mvn spring-boot:run

3. 连续调用测试接口

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

连续调用 6 次,前 5 次应该成功,第 6 次返回类似:

{
  "code": 429,
  "message": "短信发送过于频繁"
}

4. 查看 Redis Key

redis-cli keys "rate_limit:*"

你会看到类似:

rate_limit:ip:127.0.0.1:/api/sms/send

再看计数值:

redis-cli get "rate_limit:ip:127.0.0.1:/api/sms/send"

5. 模拟用户维度限流

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

两个用户应该使用不同的 Redis Key,不互相影响。


调用时序图

为了更直观看清楚整个流程,可以看下面这张时序图:

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

    C->>A: 发起 HTTP 请求
    A->>A: 解析 @RateLimit 注解
    A->>A: 构造限流 Key
    A->>R: INCR key
    alt 首次访问
        A->>R: EXPIRE key windowSeconds
    end
    R-->>A: 返回当前计数
    alt 未超限
        A->>S: 放行业务请求
        S-->>C: 正常响应
    else 已超限
        A-->>C: 抛出限流异常/返回429
    end

常见坑与排查

这一部分很重要,因为很多限流方案不是“不会写”,而是“写完发现和预期不一样”。

1. 代理层拿不到真实 IP

现象

明明不同用户访问,却都命中了同一个 IP 限流。

原因

请求经过 Nginx、SLB、网关转发后,request.getRemoteAddr() 可能拿到的是代理服务器 IP。

排查方法

打印以下请求头:

  • X-Forwarded-For
  • X-Real-IP

解决建议

Nginx 配置真实 IP 透传,例如:

location / {
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://127.0.0.1:8080;
}

注意:如果你的应用直接信任 X-Forwarded-For,要确保它来自可信代理,否则可能被伪造。


2. Key 设计过粗,误伤正常用户

现象

接口限流后,大量用户反馈“没怎么点就被拦了”。

常见原因

  • 只按 URI 限流,所有人共享一个窗口
  • 公司出口统一 NAT,多个用户共用公网 IP
  • APP 网关统一出口,导致 IP 维度失真

建议

不同场景用不同粒度:

  • 登录:IP + 用户名
  • 短信:手机号 + IP
  • 下单:用户 ID
  • 匿名公开接口:IP
  • 后台管理:用户 ID + URI

3. AOP 不生效

现象

注解加上了,但请求没有被限流。

排查方向

  1. 是否引入 spring-boot-starter-aop
  2. 切面类是否被 Spring 扫描到
  3. 注解是否加在 public 方法上
  4. 是否发生了同类内部调用,导致 AOP 代理失效

说明

如果一个类里 a() 方法内部直接调用同类的 b() 方法,而 b() 上有注解,AOP 可能不会生效。
这个坑在 Spring 里非常常见,我自己第一次做权限和限流时就踩过。


4. Redis Key 不过期,数量越来越多

现象

Redis 里堆积了很多限流 Key。

原因

INCREXPIRE 不是原子操作,如果应用在 INCR 后崩溃,可能导致部分 Key 没设置 TTL。

解决思路

  • 用 Lua 脚本把 INCR + EXPIRE 封装成原子操作
  • 定期巡检 TTL 异常 Key

后面会给 Lua 版实现。


5. 集群环境下时间窗口不一致?

这个问题在固定窗口计数里一般不是“系统时间不同步”,而是:

  • 不同实例各自用本地内存限流,导致统计不统一
  • 多个 Redis 分片策略不一致,Key 跑到了错误节点

如果用统一 Redis,并且 Key 规则一致,这类问题通常比较少见。


安全/性能最佳实践

真正上线时,不建议只停留在 demo 级实现。下面这些是更稳妥的做法。

1. 用 Lua 脚本保证原子性

我们把 INCREXPIRE 放进 Lua 脚本,一次性执行。

Lua 脚本

local current = redis.call('INCR', KEYS[1])
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
    return 0
else
    return 1
end

Spring Boot 调用方式

package com.example.demo.ratelimit;

import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Service;

import java.util.Collections;

@Service
public class RedisRateLimiterService {

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

    public RedisRateLimiterService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.redisScript = new DefaultRedisScript<>();
        this.redisScript.setResultType(Long.class);
        this.redisScript.setScriptSource(
                new ResourceScriptSource(new ClassPathResource("lua/rate_limit.lua"))
        );
    }

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

资源文件位置

src/main/resources/lua/rate_limit.lua

这样即使服务在执行过程中异常退出,也不会留下没有过期时间的脏 Key。


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

很多团队会把这两个概念混在一起,但实际目标不同:

  • 限流:保护系统,防止流量过大
  • 防刷:防止恶意重复操作,比如刷短信、刷券、撞库

所以实际策略应该分层:

  • 网关层:按 IP / URI 做粗粒度限流
  • 应用层:按业务身份做精细限流
  • 风控层:设备指纹、验证码、黑名单、行为分析

也就是说,Redis 限流是很重要的一层,但不是全部。


3. 不同接口,不要一个阈值打天下

一个常见错误是:

  • 所有接口统一设成 “60 秒 100 次”

这看似省事,实际上非常不靠谱。

建议按接口敏感度分级:

接口类型建议策略
登录接口严格,按 IP + 用户名
短信验证码很严格,按手机号 + IP
普通查询接口中等,按用户或 IP
静态资源/公开接口宽松或交给网关
后台管理操作严格,按用户 ID

4. 返回明确错误码与剩余信息

如果前后端协作比较完整,可以考虑返回:

  • 错误码:429
  • 提示信息
  • 剩余等待时间
  • 当前限流规则

例如:

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

这样前端能更友好地处理,也方便排查。


5. 给限流打监控

别让限流逻辑成为“黑盒”。建议至少监控:

  • 每分钟触发限流次数
  • 被限流最多的接口 TopN
  • 被限流最多的 IP / 用户 TopN
  • Redis 调用耗时
  • Redis Key 总量变化

很多时候,限流告警本身就是攻击或异常流量的第一信号。


6. 热点接口优先放在网关层兜底

如果你的流量特别大,仅靠应用层 AOP 做限流,应用本身已经接收到请求了,保护成本偏高。更合理的做法是:

  • 网关/Nginx 层做第一道粗限流
  • Spring Boot 应用层做第二道精细业务限流
  • Redis 做共享状态

这样更稳。


7. 注意 Redis 自身可用性

如果 Redis 挂了怎么办?

这是一个必须提前想清楚的问题。通常有两种策略:

失败放行(Fail Open)

  • Redis 不可用时,接口继续请求
  • 优点:业务不中断
  • 缺点:保护失效,可能被刷爆

失败拒绝(Fail Close)

  • Redis 不可用时,直接拒绝请求
  • 优点:系统更安全
  • 缺点:影响正常用户

我的建议:

  • 普通查询接口:可以考虑失败放行
  • 短信、登录、下单、支付类高风险接口:更倾向失败拒绝或降级处理

边界条件一定要提前定好,不要等线上出故障时再拍脑袋决定。


进阶:什么时候该升级到滑动窗口或令牌桶

如果你已经发现固定窗口不够用了,通常有以下信号:

  • 临界窗口突刺带来明显流量抖动
  • 需要更平滑地控制速率
  • 需要支持突发流量和平均速率并存
  • 需要更接近网关级别的精细限流

简单判断:

  • 固定窗口:实现简单,适合大多数业务接口
  • 滑动窗口:统计更精细,适合对公平性要求更高的场景
  • 令牌桶:更适合控制平均速率并允许有限突发

教程第一版我建议先把固定窗口方案落稳,再根据业务指标决定是否升级,别一上来就堆复杂度。


总结

这篇文章我们完整走了一遍 Spring Boot + Redis 实现接口限流与防刷的实战方案,核心点可以归纳成 5 句话:

  1. 分布式限流优先考虑 Redis,因为它天然适合做共享计数。
  2. 固定窗口是最容易落地的第一步,对登录、短信、查询等场景很实用。
  3. 限流维度比算法本身更重要,别只会按 IP 一刀切。
  4. 上线要用 Lua 保证原子性,并配合监控、异常处理、代理 IP 识别。
  5. 限流不是全部安全方案,它应该和验证码、黑名单、风控策略一起使用。

如果你现在要在项目里真正落地,我建议按这个顺序做:

  • 先接入本文的注解 + AOP + Redis 版固定窗口
  • 先覆盖最容易被刷的 2~3 个接口:登录、短信、公开查询
  • 再补 Lua 原子脚本
  • 再做监控与日志埋点
  • 最后根据误伤率和业务峰值,决定是否升级算法

这套方案不追求“理论最完美”,但它的优点是:实现成本低、效果直接、适合真实项目快速上线。
对大多数中型 Java Web 项目来说,这已经是一个非常好的起点。


分享到:

上一篇
《前端性能实战:基于 Core Web Vitals 的页面加载优化与排查方案》
下一篇
《中级开发者实战:基于 RAG 构建企业内部知识库问答系统的架构设计与性能优化》