之前写了个轻量级的 Spring Boot 接口防护框架 Guardian,陆续做了防重复提交、接口限流、接口幂等三个功能,发到了 Maven Central 开源。
最近又加了三个实用功能:参数自动 Trim、慢接口检测、请求链路追踪。现在 Guardian v1.5.0 一共六个模块,覆盖了 API 请求层最常见的防护需求。
每个模块独立 Starter,用哪个引哪个,互不依赖。最快只需要引个依赖就能用,零配置。
项目地址(源码 + 示例 + 文档全在里面):
| 功能 | Starter | 注解 | YAML | 说明 |
|---|---|---|---|---|
| 防重复提交 | guardian-repeat-submit-spring-boot-starter | @RepeatSubmit | 防止用户重复提交表单/请求 | |
| 接口限流 | guardian-rate-limit-spring-boot-starter | @RateLimit | 滑动窗口 + 令牌桶,双算法可选 | |
| 接口幂等 | guardian-idempotent-spring-boot-starter | @Idempotent | — | Token 机制保证接口幂等性,支持结果缓存 |
| 参数自动Trim | guardian-auto-trim-spring-boot-starter | — | 自动去除请求参数首尾空格 + 不可见字符替换 | |
| 慢接口检测 | guardian-slow-api-spring-boot-starter | @SlowApiThreshold | 慢接口自动告警 + Top N 统计 + Actuator 端点 | |
| 请求链路追踪 | guardian-trace-spring-boot-starter | — | 自动生成/透传 TraceId,MDC 日志串联 |
下面一个一个说。
用户点了提交按钮,前端没做防抖,或者网络慢用户多点了几下。后端收到三个一模一样的请求,创建了三个订单。
防重复提交就是解决这个问题:同一个请求短时间内别让它提交两次。
三步搞定。
第一步,引依赖:
<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-repeat-submit-spring-boot-starter</artifactId>
<version>1.5.0</version>
</dependency>
第二步,加注解:
@PostMapping("/submit")
@RepeatSubmit(interval = 10, message = "订单正在处理,请勿重复提交")
public Result submitOrder(@RequestBody OrderDTO order) {
return orderService.submit(order);
}
第三步,没了。启动项目就生效了。
10 秒内同一个用户、同一个接口、同样的请求参数,第二次请求会被直接拦截。
单个接口用注解挺方便,但如果有 50 个接口都要配防重,一个一个加注解就有点累了。支持在 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
几个要点:
exclude-urls)优先级最高,命中直接放行key-scope 控制防重维度:user(按用户)、ip(按 IP)、global(全局)guardian:
repeatable-filter-order: -100 # 请求体缓存过滤器排序(全局共享,仅需配置一次)
repeat-submit:
storage: redis # redis / local
key-encrypt: md5 # none / md5
response-mode: exception # exception / json
log-enabled: false
interceptor-order: 2000 # 拦截器排序(值越小越先执行)
exclude-urls:
- /api/public/**
urls:
- pattern: /api/order/submit
interval: 10
time-unit: seconds
key-scope: user # user / ip / global
message: "请勿重复提交"
| 维度 | YAML 值 | 注解值 | 效果 |
|---|---|---|---|
| 用户级 | user | KeyScope.USER | 同一用户 + 同一接口 + 同一参数(默认) |
| IP 级 | ip | KeyScope.IP | 同一 IP + 同一接口 + 同一参数 |
| 全局级 | global | KeyScope.GLOBAL | 同一接口 + 同一参数 |
| 模式 | 配置值 | 行为 |
|---|---|---|
| 异常模式 | exception(默认) | 抛出 RepeatSubmitException,由全局异常处理器捕获 |
| JSON 模式 | json | 拦截器直接写入 JSON 响应 |
Key 怎么拼?
userId + url 够不够?如果同一个用户对同一个接口传了不同的参数呢?比如下单接口,买商品 A 和买商品 B 应该算两次不同的请求,不能拦截。
所以防重 Key 把请求参数也算了进去。但 POST 请求的 body 是个流,读了一次就没了,框架内置了 RepeatableRequestFilter 自动缓存请求体,Key 生成时会把请求参数做 JSON 序列化 + Base64 编码拼进去。
用户没登录怎么办?
已登录用 userId → 没登录用 sessionId → 没 session 用客户端 IP。三级降级,永远不会出现 null。
业务异常了锁不释放怎么办?
拦截器的 afterCompletion 里做了处理:如果请求抛了异常,自动释放锁。正常完成的请求才让锁自然过期。
context-path 的坑:
匹配时同时尝试完整 URI 和去掉 context-path 后的路径,两者有一个匹配上就算命中。所以不管 YAML 里写的是 /order/submit 还是 /admin-api/order/submit,都能正确匹配。
log-enabled: true,前缀 [Guardian-Repeat-Submit]GET /actuator/guardianRepeatSubmit{
"totalBlockCount": 128,
"totalPassCount": 5432,
"topBlockedApis": {
"/api/order/submit": 56,
"/api/sms/send": 42
}
}
核心组件均可替换,注册同类型 Bean 即可覆盖默认实现。
自定义用户上下文(所有模块共享):
@Bean
public UserContext userContext() {
return () -> SecurityUtils.getCurrentUserId();
}
自定义 Key 生成策略:
public class MyKeyGenerator extends AbstractKeyGenerator {
public MyKeyGenerator(UserContext userContext, AbstractKeyEncrypt keyEncrypt) {
super(userContext, keyEncrypt);
}
@Override
protected String buildKey(RepeatSubmitKey key) {
return key.getServletUri() + ":" + key.getUserId();
}
}
@Bean
public MyKeyGenerator myKeyGenerator(UserContext userContext, AbstractKeyEncrypt keyEncrypt) {
return new MyKeyGenerator(userContext, keyEncrypt);
}
自定义存储 / 自定义响应处理器:
@Bean
public RepeatSubmitStorage myStorage() {
return new RepeatSubmitStorage() {
@Override
public boolean tryAcquire(RepeatSubmitToken token) { /* ... */ }
@Override
public void release(RepeatSubmitToken token) { /* ... */ }
};
}
@Bean
public RepeatSubmitResponseHandler repeatSubmitResponseHandler() {
return (request, response, code, data, message) -> {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSONUtil.toJsonStr(CommonResult.result(code, data, message)));
};
}
有人写个脚本一秒钟请求你的搜索接口 1000 次,防重拦不住(因为每次参数可能不一样),这时候就需要限流了。
Guardian 的限流就是冲着轻量场景来的:注解 + YAML 双模式、滑动窗口 + 令牌桶双算法可选。不需要引 Sentinel 那么重的东西。
<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-rate-limit-spring-boot-starter</artifactId>
<version>1.5.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/**
| 滑动窗口(默认) | 令牌桶 | |
|---|---|---|
| 算法 | 统计窗口内请求数,超过阈值拒绝 | 按速率补充令牌,有令牌放行,无令牌拒绝 |
| 突发流量 | 不允许,窗口内严格限制 | 允许,桶满时可瞬间消耗所有令牌 |
| 适合场景 | 精确控速(短信、登录尝试) | 允许突发(秒杀、抢购) |
| 数据结构 | Local: Deque / Redis: ZSET | Local: double + synchronized / Redis: HASH |
举个直观的例子,都是 qps=10,突然来了 20 个请求:
| 滑动窗口 | 令牌桶(capacity=20) | |
|---|---|---|
| 第 1-10 个 | 通过 | 通过 |
| 第 11-20 个 | 全部拒绝 | 全部通过 |
| 之后每秒 | 最多 10 个 | 最多 10 个 |
guardian:
rate-limit:
enabled: true # 总开关
storage: redis # redis / local
response-mode: exception # exception / json
log-enabled: false
interceptor-order: 1000 # 拦截器排序(值越小越先执行)
exclude-urls:
- /api/public/**
urls:
- pattern: /api/sms/send
qps: 1
window: 60
window-unit: seconds
rate-limit-scope: ip
- pattern: /api/seckill/**
qps: 10
capacity: 50
algorithm: token_bucket
rate-limit-scope: global
| 参数 | 默认值 | 说明 |
|---|---|---|
qps | 10 | 滑动窗口=QPS,令牌桶=每 window 补充的令牌数 |
window | 1 | 滑动窗口=窗口跨度,令牌桶=补充周期 |
windowUnit | SECONDS | 时间单位 |
algorithm | SLIDING_WINDOW | 限流算法:SLIDING_WINDOW / TOKEN_BUCKET |
capacity | -1 | 令牌桶容量,≤0 时取 qps 值 |
rateLimitScope | GLOBAL | 限流维度:GLOBAL / IP / USER |
message | 请求过于频繁,请稍后再试 | 提示信息 |
| 维度 | 效果 | 典型场景 |
|---|---|---|
GLOBAL(默认) | 整个接口共用一个计数器 | 全站搜索接口 |
IP | 每个 IP 独立计数 | 短信发送、验证码 |
USER | 每个用户独立计数 | 用户操作频率限制 |
限流对并发安全的要求很高。Guardian 的处理:
synchronized 锁到 Key 粒度,不同 Key 之间互不阻塞log-enabled: true,前缀 [Guardian-Rate-Limit]GET /actuator/guardianRateLimit{
"totalRequestCount": 5560,
"totalPassCount": 5432,
"totalBlockCount": 128,
"blockRate": "2.30%",
"topBlockedApis": { "/api/sms/send": 56 },
"topRequestApis": { "/api/search": 3200 },
"apiDetails": {
"/api/sms/send": { "requests": 200, "passes": 144, "blocks": 56, "blockRate": "28.00%" }
}
}
@Bean
public UserContext userContext() {
return () -> SecurityUtils.getCurrentUserId();
}
@Bean
public RateLimitStorage myRateLimitStorage() {
return new RateLimitStorage() {
@Override
public boolean tryAcquire(RateLimitToken token) { /* ... */ }
};
}
@Bean
public RateLimitResponseHandler rateLimitResponseHandler() {
return (request, response, code, data, message) -> {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSONUtil.toJsonStr(CommonResult.result(code, data, message)));
};
}
防重和幂等经常被搞混,但它们解决的是不同的问题:
防重是"不让你提交",幂等是"提交了也没事"。
典型场景:
<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-idempotent-spring-boot-starter</artifactId>
<version>1.5.0</version>
</dependency>
1. 获取 Token:
GET /guardian/idempotent/token?key=order-submit
返回:
{
"code": 200,
"data": {
"token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expireIn": 300,
"expireUnit": "SECONDS"
}
}
2. 业务接口携带 Token:
@Idempotent("order-submit")
@PostMapping("/order/submit")
public Result submitOrder(@RequestBody OrderDTO order) {
return orderService.submit(order);
}
请求头带上 X-Idempotent-Token: {token},首次请求正常处理,重复请求直接拒绝。Token 是一次性的,用完就没了。
1. 客户端请求 Token
GET /guardian/idempotent/token?key=order-submit
│
▼
2. 服务端生成 UUID Token,存入 Redis/本地缓存,设置 TTL
Key: guardian:idempotent:order-submit:{uuid}
│
▼
3. 客户端携带 Token 发起业务请求
Header: X-Idempotent-Token: {uuid}
│
▼
4. 拦截器校验
├─ Token 存在 → 删除 Token(原子操作)→ 放行业务执行
└─ Token 不存在或已消费 → 拒绝请求
关键在第 4 步的删除操作是原子的。Redis 用 DEL 命令,返回 1 表示删除成功(首次消费),返回 0 表示 Key 不存在(重复请求)。本地缓存用 ConcurrentHashMap.remove(),也是原子的。
默认行为是重复请求直接拒绝。但有些场景下需要返回首次执行的结果,比如支付回调平台重发通知时期望收到正常的成功响应。
guardian:
idempotent:
result-cache: true
开启后,首次请求的返回值自动缓存(实现在 IdempotentResultCacheAdvice.java)。后续拿同一个 Token 再请求时,直接返回缓存的结果而非报错。
首次请求:
Token 消费成功 → 执行业务 → 返回 {"code":200,"data":"订单创建成功"}
↓
缓存返回值到 Redis
重复请求:
Token 已消费 → 查缓存 → 命中 → 直接返回 {"code":200,"data":"订单创建成功"}
→ 未命中 → 正常拒绝
Header 方式(默认): Token 放在请求头 X-Idempotent-Token 里。
Param 方式: Token 作为参数传递,PARAM 模式会依次查找:URL 查询参数 → 表单字段 → JSON Body 字段。
// URL 参数方式
@Idempotent(value = "pay-confirm", from = IdempotentTokenFrom.PARAM, tokenName = "token")
@PostMapping("/pay/confirm")
public Result confirm(@RequestParam String token, @RequestBody PayDTO pay) { ... }
// JSON Body 方式(Token 嵌在请求体里)
@Idempotent(value = "body-token", from = IdempotentTokenFrom.PARAM, tokenName = "token")
@PostMapping("/order/submit")
public Result submit(@RequestBody OrderDTO order) { ... }
// 请求体:{"token": "xxx", "orderId": "123", "amount": 1}
guardian:
repeatable-filter-order: -100 # 请求体缓存过滤器排序(全局共享,仅需配置一次)
idempotent:
enabled: true # 总开关
storage: redis # redis / local
timeout: 300 # Token 有效期(默认 300)
time-unit: seconds # 有效期单位
response-mode: exception # exception / json
log-enabled: false
interceptor-order: 3000 # 拦截器排序
token-endpoint: true # 是否注册内置 Token 获取接口
result-cache: false # 是否启用结果缓存
| 参数 | 默认值 | 说明 |
|---|---|---|
value | 必填 | 接口唯一标识,用于隔离不同接口的 Token |
from | HEADER | Token 来源:HEADER / PARAM |
tokenName | X-Idempotent-Token | Header 名 / URL 参数名 / JSON Body 字段名 |
message | 幂等Token无效或已消费 | 拒绝时的提示信息 |
log-enabled: true,前缀 [Guardian-Idempotent]GET /actuator/guardianIdempotent{
"totalRequestCount": 1200,
"totalPassCount": 1100,
"totalBlockCount": 100,
"blockRate": "8.33%",
"topBlockedApis": {
"/order/submit": 60,
"/pay/confirm": 40
}
}
// 自定义 Token 生成器(默认 UUID,可改为雪花 ID)
@Bean
public IdempotentTokenGenerator idempotentTokenGenerator() {
return () -> String.valueOf(IdUtil.getSnowflakeNextId());
}
// 自定义存储
@Bean
public IdempotentStorage myIdempotentStorage() {
return new IdempotentStorage() {
@Override
public void save(IdempotentToken token) { /* ... */ }
@Override
public boolean tryConsume(String tokenKey) { /* ... */ }
};
}
这个功能是从实际踩坑来的。
用户注册时用户名输了个 " zhangsan "(前后带空格),存进了数据库。后来登录输 "zhangsan" 死活登不上。运维排查半天,发现数据库里的用户名前面多了个空格。
更隐蔽的是不可见字符。用户从某些网页复制粘贴内容,看起来一模一样,但实际上带了零宽空格(u200B)或 BOM(uFEFF)。这种字符肉眼看不见,但程序比较字符串时会失败。
Guardian 的参数自动 Trim 就是解决这个问题:引个依赖,全局生效,所有请求参数自动去空格 + 可选清除不可见字符。
<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-auto-trim-spring-boot-starter</artifactId>
<version>1.5.0</version>
</dependency>
引完就能用了,零配置。所有请求参数(表单参数 + JSON Body)自动去除首尾空格。
如果你还需要清除一些不可见字符,配一下 character-replacements:
guardian:
auto-trim:
character-replacements:
- from: "\r" # 回车符
to: ""
- from: "\u200B" # 零宽空格
to: ""
- from: "\uFEFF" # BOM
to: ""
支持的转义格式:
| 转义写法 | 实际字符 | 说明 |
|---|---|---|
\r | r | 回车符 |
\n | n | 换行符 |
\t | t | 制表符 |
\0 | |