⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7606195582512627750 「BigGGGuardian」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

前言

事情是这样的,前段时间在公司项目里又写了一遍防重复提交的逻辑——Redis 加锁、拼 Key、设过期时间、处理异常释放锁……写到一半我就烦了,这套东西每个项目都要来一遍,而且每次写法还不太一样,维护起来头大。

限流也是,要么上 Sentinel 搞一套,要么自己写个拦截器糊一个,代码散得到处都是。

想了想,干脆自己封一个 Starter。搞着搞着就把防重复提交和接口限流都做了,打包发到 Maven Central 开源了。

项目叫 Guardian,一个轻量级的 Spring Boot API 请求层防护框架,目前 v1.3.0。两个功能完全独立,用哪个引哪个,互不依赖。

GitHub:

Maven Central:

  • io.github.biggg-guardian

一、防重复提交

先看效果

三步搞定:

第一步,引依赖:

<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-repeat-submit-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>

第二步,加注解:

@PostMapping("/submit")
@RepeatSubmit(interval = 10, message = "订单正在处理,请勿重复提交")
public Result submitOrder(@RequestBody OrderDTO order) {
return orderService.submit(order);
}

第三步,没了。启动项目就生效了。

10 秒内同一个用户、同一个接口、同样的请求参数,第二次请求会被直接拦截。

为什么不直接用 Redis 加个锁?

你肯定想说:”这不就是 Redis setnx 嘛,我自己写也行。”

确实能写,但你想想实际项目里会遇到的问题:

1. Key 怎么拼?

userId + url 够不够?如果同一个用户对同一个接口传了不同的参数呢?比如下单接口,买商品 A 和买商品 B 应该算两次不同的请求,不能拦截。

所以防重 Key 要把请求参数也算进去。但 POST 请求的 body 是个流,读了一次就没了,你还得处理 HttpServletRequestWrapper 的问题。

Guardian 内置了 RepeatableRequestFilter,自动缓存请求体,Key 生成时会把请求参数做 JSON 序列化 + Base64 编码拼进去。

2. 用户没登录怎么办?

很多防重方案直接用 userId 作为 Key 的一部分,但用户没登录的时候 userId 是 null,Key 就乱了。

Guardian 的处理是:已登录用 userId → 没登录用 sessionId → 没 session 用客户端 IP。三级降级,永远不会出现 null。

3. 业务异常了锁不释放怎么办?

比如用户提交订单,业务代码报了个异常,但防重锁已经设了 10 秒。结果用户修正数据重新提交,被告知”请勿重复提交”——这体验就很差了。

Guardian 在拦截器的 afterCompletion 里做了处理:如果请求抛了异常,自动释放锁。正常完成的请求才让锁自然过期。

4. 有些接口不需要防重怎么办?

全局配了防重之后,健康检查接口、公开查询接口这些也被拦了。你要么给每个接口单独控制,要么维护一个白名单。

Guardian 支持 exclude-urls 白名单,AntPath 通配符匹配,优先级最高。命中直接放行,不走任何防重逻辑。

注解不够用?试试 YAML 批量配置

单个接口用注解挺方便,但如果你有 50 个接口都要配防重,一个一个加注解就有点累了。

Guardian 支持在 YAML 里批量配置,用 AntPath 通配符一口气匹配一批接口:

guardian:
repeat-submit:
storage:redis
key-encrypt:md5
urls:
-pattern:/api/order/**
interval:10
key-scope:user
message:"订单正在处理,请勿重复提交"
-pattern:/api/sms/send
interval:60
key-scope:ip
exclude-urls:
-/api/public/**
-/api/health

几个要点:

  • YAML 规则的优先级高于注解,同一个接口两边都配了以 YAML 为准
  • 白名单(exclude-urls)优先级最高,命中直接放行
  • key-scope 控制防重维度:user(按用户)、ip(按 IP)、global(全局)

比如短信发送接口配 key-scope: ip,同一个 IP 60 秒内只能发一次,不管登没登录、哪个用户——这比按用户维度更合理。

拦截了之后怎么响应?

这是我纠结了挺久的一个设计点。

一开始只做了抛异常的方式——拦截后抛 RepeatSubmitException,让业务端的全局异常处理器去处理。但后来想到,有些项目可能就想开箱即用,不想为了一个防重还得写个异常处理器。

所以做了两种模式:

guardian:
repeat-submit:
response-mode: exception # 默认,抛异常
# response-mode: json # 直接返回 JSON

exception 模式(默认): 抛 RepeatSubmitException,你在全局异常处理器里接一下:

@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RepeatSubmitException.class)
public Result handleRepeatSubmit(RepeatSubmitException e) {
return Result.fail(e.getMessage());
}
}

json 模式:拦截器直接写 JSON 响应,默认格式是 {"code":500,"msg":"...","timestamp":...}。

格式不满意?注册一个 RepeatSubmitResponseHandler Bean 就能覆盖:

@Bean
public RepeatSubmitResponseHandler repeatSubmitResponseHandler() {
return (request, response, message) -> {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSONUtil.toJsonStr(R.fail(message)));
};
}

不用 Redis 也能跑

不是每个项目都有 Redis 的。本地开发环境、小型单体应用,可能就没有 Redis。

guardian:
repeat-submit:
storage: local # 用本地缓存

切成 local 就行了,底层用 ConcurrentHashMap 实现,带定时过期清理。当然生产环境还是推荐 Redis,支持分布式。

关于 context-path 的坑

这个坑我自己踩过。项目配了 server.servlet.context-path: /admin-api,然后 YAML 里配的 URL 规则死活匹配不上。

排查了一下发现,request.getRequestURI() 返回的是带 context-path 的完整路径(比如 /admin-api/order/submit),但 YAML 里配的可能是 /order/submit。

Guardian 的处理是:匹配时同时尝试完整 URI 和去掉 context-path 后的路径,两者有一个匹配上就算命中。所以不管你 YAML 里写的是 /order/submit 还是 /admin-api/order/submit,都能正确匹配。

防重的内部流程

简单画一下请求的处理流程:

请求进入
│
▼
RepeatableRequestFilter ← 缓存请求体,支持重复读取
│
▼
RepeatSubmitInterceptor
├─ 1. 匹配白名单 → 命中直接放行
├─ 2. 匹配 YAML 规则
├─ 3. 检查 @RepeatSubmit 注解
│ 均未命中 → 放行
▼
KeyGenerator ← 按维度(user/ip/global)生成防重 Key
│
▼
KeyEncrypt ← 可选 MD5 加密
│
▼
Storage.tryAcquire()
├─ 成功 → 放行,写入存储 + 设置 TTL
└─ 失败 → 根据 response-mode 响应
├─ exception → 抛 RepeatSubmitException
└─ json → 直接写 JSON 响应
│
▼
业务执行
├─ 正常 → Key 自然过期
└─ 异常 → afterCompletion 自动释放

二、接口限流

为什么要做限流?

防重复提交解决的是”同一个请求短时间内被提交多次”的问题,但还有另一类问题它管不了:恶意刷接口。

比如有人写个脚本一秒钟请求你的搜索接口 1000 次,防重拦不住(因为每次参数可能不一样),这时候就需要限流了。

市面上的限流方案不少,但要么是网关级别的(Sentinel、Spring Cloud Gateway),要么得写一堆配置。如果你就是个普通的 Spring Boot 单体应用,想给几个接口加个限流,没必要引那么重的东西。

Guardian 的限流就是冲着这个场景来的:轻量、注解 + YAML 双模式、两种算法可选。

先看效果

<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-rate-limit-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>
// 滑动窗口:每秒最多 10 次
@RateLimit(qps = 10)

// 令牌桶:每秒补 5 个令牌,桶容量 20,允许瞬间突发 20 次
@RateLimit(qps = 5, capacity = 20, algorithm = RateLimitAlgorithm.TOKEN_BUCKET)

同样支持 YAML 批量配置:

guardian:
rate-limit:
urls:
-pattern:/api/sms/send
qps:1
rate-limit-scope:ip
-pattern:/api/seckill/**
qps:10
capacity:50
algorithm:token_bucket
rate-limit-scope:global
exclude-urls:
-/api/public/**

和防重一样,注解 + YAML 双模式,YAML 优先级高于注解,白名单优先级最高。

滑动窗口 vs 令牌桶

这是限流最常用的两种算法,Guardian 都支持。

滑动窗口:统计时间窗口内的请求次数,超了就拒绝。比如配了 qps=10, window=1s,就是每秒最多 10 次,多了直接打回。

@RateLimit(qps = 10)

特点是严格。窗口内绝对不会超过阈值。适合短信发送、登录尝试这种需要精确控制频率的场景。

令牌桶:桶里装令牌,按固定速率往里放,请求来了取一个,桶空了就拒绝。桶满时可以一口气把令牌全用完。

@RateLimit(qps = 5, capacity = 20, algorithm = RateLimitAlgorithm.TOKEN_BUCKET)

这个配置的意思是:每秒补 5 个令牌,桶最多攒 20 个。平时空闲的时候令牌慢慢攒,突然来一波流量,瞬间可以放过 20 个请求,打完之后回到每秒 5 个的稳态。

特点是允许突发。适合秒杀、抢购这种”平时没啥流量,偶尔来一波高峰”的场景。

举个直观的例子,都是 qps=10,突然来了 20 个请求:

滑动窗口 令牌桶(capacity=20)
第 1-10 个 通过 通过
第 11-20 个 全部拒绝 全部通过
之后每秒 最多 10 个 最多 10 个

补充速率怎么控制?

令牌桶的补充速率通过 qps 和 window 两个参数控制:

  • qps=10, window=1s → 每秒补 10 个
  • qps=10, window=1min → 每分钟补 10 个(约 6 秒补 1 个)
// 每分钟补 10 个令牌,桶容量 10
@RateLimit(qps = 10, window = 1, windowUnit = TimeUnit.MINUTES,
capacity = 10, algorithm = RateLimitAlgorithm.TOKEN_BUCKET)

这样就能实现慢速补充的场景。

限流维度

和防重一样,限流也支持三种维度:

维度 效果 典型场景
GLOBAL(默认) 整个接口共用一个计数器 全站搜索接口
IP 每个 IP 独立计数 短信发送、验证码
USER 每个用户独立计数 用户操作频率限制
@RateLimit(qps = 1, rateLimitScope = RateLimitKeyScope.IP, message = "短信发送过于频繁")

限流的响应处理

和防重一样,两种模式:

guardian:
rate-limit:
response-mode: exception # 默认,抛 RateLimitException
# response-mode: json # 直接返回 JSON

也支持自定义响应处理器,注册一个 RateLimitResponseHandler Bean 就行。

三、一些设计细节

并发安全

限流对并发安全的要求比防重高。你想,10 个请求同时进来,限流阈值是 5,如果并发控制没做好,可能 10 个都放过去了。

Guardian 的处理:

  • Redis:滑动窗口和令牌桶都用 Lua 脚本,Redis 单线程执行 Lua 是天然原子的
  • 本地缓存:synchronized 锁到 Key 粒度,不同 Key 之间互不阻塞

防重那边也是一样,Redis 用 SET NX EX 原子操作,本地缓存用 ConcurrentHashMap 的原子方法。

本地缓存的内存管理

用 ConcurrentHashMap 做本地存储有个容易忽略的问题:Key 只进不出,长时间运行内存会一直涨。

Guardian 在防重和限流的本地存储里都加了守护线程,每 5 分钟扫一次,清理过期的 Key。线程是 daemon 的,不会阻止 JVM 关闭。

可插拔架构

两个模块的核心组件都是面向接口编程的,框架内部用 @ConditionalOnMissingBean 做的,你不注册就用默认的,注册了就用你的:

组件 防重复提交 接口限流
Key 生成 RepeatSubmitKeyGenerator RateLimitKeyGenerator
Key 加密 AbstractKeyEncrypt AbstractKeyEncrypt
存储 RepeatSubmitStorage RateLimitStorage
响应处理 RepeatSubmitResponseHandler RateLimitResponseHandler
用户上下文 UserContext(共享) UserContext(共享)

可观测性

两个模块都内置了监控能力:

拦截日志:log-enabled: true 开启后,拦截/放行都有日志输出。

Actuator 端点:

GET /actuator/guardian-repeat-submit    → 防重统计
GET /actuator/guardian-rate-limit → 限流统计

限流的统计数据长这样:

{
"totalRequestCount": 5560,
"totalPassCount": 5432,
"totalBlockCount": 128,
"blockRate": "2.30%",
"topBlockedApis": { "/api/sms/send": 56 },
"topRequestApis": { "/api/search": 3200 }
}

项目结构

guardian-parent
├── guardian-core # 公共基础(共享类)
├── guardian-repeat-submit/ # 防重复提交
│ ├── guardian-repeat-submit-core/
│ └── guardian-repeat-submit-spring-boot-starter/
├── guardian-rate-limit/ # 接口限流
│ ├── guardian-rate-limit-core/
│ └── guardian-rate-limit-spring-boot-starter/
├── guardian-storage-redis/ # Redis 存储(多模块共享)
└── guardian-example/ # 示例工程

模块拆分是为了灵活组合。比如你只需要防重就引 guardian-repeat-submit-spring-boot-starter,只需要限流就引 guardian-rate-limit-spring-boot-starter,都需要就两个都引,互不影响。

guardian-core 放的是两个模块都用到的公共类,比如 UserContext、GuardianResponseHandler。guardian-storage-redis 是 Redis 存储的共享实现,两个模块的 Redis 存储都在这里面。

总结

Guardian 做了两件事:

  1. 防重复提交:拦截短时间内的重复请求,支持注解 / YAML、用户 / IP / 全局维度、异常自动释放、context-path 兼容
  2. 接口限流:控制接口访问频率,支持滑动窗口 / 令牌桶、突发流量处理、三种维度

两个功能独立 Starter,核心组件全部可插拔,注册 Bean 就能替换默认实现。Redis 和本地缓存一键切换。

如果你的 Spring Boot 项目里需要这些能力,但又不想引 Sentinel 那么重的东西,可以试试。

Maven Central 坐标:

<!-- 防重复提交 -->
<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-repeat-submit-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>

<!-- 接口限流 -->
<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-rate-limit-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>
文章目录
  1. 1. 前言
  2. 2. 一、防重复提交
    1. 2.1. 先看效果
    2. 2.2. 为什么不直接用 Redis 加个锁?
    3. 2.3. 注解不够用?试试 YAML 批量配置
    4. 2.4. 拦截了之后怎么响应?
    5. 2.5. 不用 Redis 也能跑
    6. 2.6. 关于 context-path 的坑
    7. 2.7. 防重的内部流程
  3. 3. 二、接口限流
    1. 3.1. 为什么要做限流?
    2. 3.2. 先看效果
    3. 3.3. 滑动窗口 vs 令牌桶
    4. 3.4. 补充速率怎么控制?
    5. 3.5. 限流维度
    6. 3.6. 限流的响应处理
  4. 4. 三、一些设计细节
    1. 4.1. 并发安全
    2. 4.2. 本地缓存的内存管理
    3. 4.3. 可插拔架构
    4. 4.4. 可观测性
  5. 5. 项目结构
  6. 6. 总结