前言
之前写了个轻量级的 Spring Boot 接口防护框架 Guardian,做了防重复提交和接口限流两个功能,发到了 Maven Central。
防重和幂等经常被搞混,但它们解决的是不同的问题:
- 防重复提交:同一个请求短时间内别提交两次(锁一段时间就行)
- 接口幂等:同一个操作不管执行几次,结果都一样(比如支付,扣一次钱就行)
防重是"不让你提交",幂等是"提交了也没事"。场景不一样,实现方式也不一样。
所以这次在 Guardian v1.4.3 里加了接口幂等模块,基于 Token 机制,支持结果缓存。和之前的防重、限流一样,独立 Starter,用哪个引哪个。
项目地址(源码 + 示例 + 文档全在里面):
- GitHub:github.com/BigGG-Guard… ← 顺手点个 Star,不迷路
- Gitee(镜像同步):gitee.com/BigGG-Guard…
一、什么场景需要幂等?
举几个典型的:
1. 支付回调
第三方支付平台通知你支付成功,网络抖了一下超时了,平台会重发。如果你的回调接口没做幂等,用户可能被扣两次钱。
2. 订单提交
用户点了提交按钮,前端没做防抖,或者网络慢用户多点了几下。后端收到三个一模一样的请求,创建了三个订单。
3. 消息队列重试
MQ 消费失败重试,消息被消费了两次。如果消费逻辑是"给用户加积分",那用户就多拿了积分。
这些场景的共同点是:请求可能被重复发送,但业务只应该执行一次。
二、先看效果
三步搞定。
第一步,引依赖:
<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-idempotent-spring-boot-starter</artifactId>
<version>1.4.3</version>
</dependency>
第二步,业务接口加注解:
@Idempotent("order-submit")
@PostMapping("/order/submit")
public Result submitOrder(@RequestBody OrderDTO order) {
return orderService.submit(order);
}
第三步,客户端发请求前先拿 Token:
GET /guardian/idempotent/token?key=order-submit
返回:
{
"code": 200,
"data": {
"token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expireIn": 300,
"expireUnit": "SECONDS"
}
}
把 Token 放到请求头里:
POST /order/submit
X-Idempotent-Token: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Content-Type: application/json
{"productId": "P001", "amount": 1}
第一次请求正常处理。拿同一个 Token 再请求一次,直接被拒绝。Token 是一次性的,用完就没了。
完整可运行的示例代码在仓库的 guardian-example 模块里,Header/Param/结果缓存/自定义提示等场景都有,clone 下来直接跑。
三、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(),也是原子的。
这样即使两个完全一样的请求同时到达,也只有一个能消费成功。
为什么 Token 要绑定接口标识?
@Idempotent("order-submit") 里的 "order-submit" 是接口标识,Token 在 Redis 里的 Key 是 guardian:idempotent:order-submit:{uuid}。
这样做的好处是:不同接口的 Token 互相隔离。订单接口的 Token 不能拿去调支付接口,即使 Token 本身还没过期。
如果不绑定接口标识,一个 Token 就能调用任意带 @Idempotent 的接口,这在安全上是有隐患的。
四、结果缓存:重复请求直接返回首次结果
默认行为是:Token 消费后,重复请求直接拒绝,返回错误提示。
但有些场景下,客户端需要的不是"你已经提交过了"这种错误,而是"上次提交的结果"。比如支付回调,第三方平台重发通知时期望收到一个正常的成功响应,而不是报错。
开启结果缓存就能解决这个问题:
guardian:
idempotent:
result-cache: true
开启之后,首次请求的返回值会被自动缓存。后续拿同一个 Token(已消费)再请求时,拦截器直接返回缓存的结果,而不是报错。
首次请求:
Token 消费成功 → 执行业务 → 返回 {"code":200,"data":"订单创建成功"}
↓
缓存返回值到 Redis
重复请求:
Token 已消费 → 查缓存 → 命中 → 直接返回 {"code":200,"data":"订单创建成功"}
→ 未命中 → 正常拒绝
实现上用了 Spring 的 ResponseBodyAdvice,在 Controller 返回之后、响应写入之前,把返回值序列化存到 Redis(或本地缓存),缓存的过期时间和 Token 一致。
结果缓存的源码在 IdempotentResultCacheAdvice.java,拦截器在 IdempotentInterceptor.java,代码不多,感兴趣可以看看。
五、两种传 Token 的方式
Header 方式(默认)
Token 放在请求头里,适合大多数场景:
@Idempotent("order-submit")
@PostMapping("/order/submit")
public Result submit(@RequestBody OrderDTO order) { ... }
请求头带上 X-Idempotent-Token: {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) { ... }
请求:POST /pay/confirm?token={token}
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}
拦截器会自动从 JSON Body 中提取 token 字段,无需额外配置。
自定义 Token 名
默认的 Header 名是 X-Idempotent-Token,可以改:
@Idempotent(value = "custom", tokenName = "X-Pay-Token")
六、全量配置
guardian:
repeatable-filter-order: -100 # 请求体缓存过滤器排序(全局共享,仅需配置一次)
idempotent:
enabled: true # 总开关(默认 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(PARAM 模式依次查找 URL 参数、表单字段、JSON Body) |
tokenName | X-Idempotent-Token | Header 名 / URL 参数名 / JSON Body 字段名 |
message | 幂等Token无效或已消费 | 拒绝时的提示信息 |
响应模式
和防重、限流一样,两种模式:
exception 模式(默认):抛 IdempotentException,全局异常处理器里接一下。
@ExceptionHandler(IdempotentException.class)
public Result handleIdempotent(IdempotentException e) {
return Result.fail(e.getMessage());
}
json 模式:拦截器直接写 JSON 响应,默认格式 {"code":500,"msg":"幂等Token无效或已消费","timestamp":...}。
七、可观测性
拦截日志
log-enabled: true 开启后,每次幂等校验都有日志,前缀 [Guardian-Idempotent]:
[Guardian-Idempotent] @Idempotent 放行 | URI=/order/submit | Key=guardian:idempotent:order-submit:xxx | IP=127.0.0.1
[Guardian-Idempotent] @Idempotent 拦截 | URI=/order/submit | IP=127.0.0.1
[Guardian-Idempotent] @Idempotent 返回缓存结果 | URI=/order/submit | Key=guardian:idempotent:order-submit:xxx | IP=127.0.0.1
Actuator 端点
GET /actuator/guardianIdempotent
{
"totalRequestCount": 1200,
"totalPassCount": 1100,
"totalBlockCount": 100,
"blockRate": "8.33%",
"topBlockedApis": {
"/order/submit": 60,
"/pay/confirm": 40
}
}
八、一些设计细节
Token 消费的并发安全
Token 消费的核心操作是"存在就删除",必须是原子的。
Redis 用 DEL 命令,返回值 1 表示删成功(首次消费),0 表示 Key 不存在(重复)。Redis 单线程执行命令,天然原子。
本地缓存用 ConcurrentHashMap.remove(key),返回非 null 表示删成功。ConcurrentHashMap 的 remove 本身就是原子操作。
null 返回值的缓存处理
开启结果缓存后,如果 Controller 返回 null,也能正确缓存和还原。
存储时显式用 "null" 字符串兜底(因为 Hutool 的 JSONUtil.toJsonStr(null) 返回 Java null,不能直接存 Redis),取出时原样返回给客户端。
缓存结果直写,不做二次包装
缓存命中后,拦截器直接把缓存的 JSON 字符串写入 HTTP 响应,不经过 ResponseHandler 二次包装。这样保证缓存命中的响应和首次请求的响应格式完全一致。
三个拦截器的执行顺序
如果你同时用了限流、防重、幂等三个模块,它们的拦截器执行顺序是确定的,通过 interceptor-order 控制,值越小越先执行:
| 顺序 | 模块 | 默认 order | 为什么这样排 |
|---|---|---|---|
| 1 | 限流 | 1000 | 最先拦截,超限直接拒绝,避免后续无意义计算 |
| 2 | 防重 | 2000 | 通过限流后判断是否短时间重复请求 |
| 3 | 幂等 | 3000 | 最后执行,Token 消费不可逆,确保前面的校验都通过再消费 |
幂等放最后是关键——Token 一旦消费就没了,如果先消费 Token 再被限流拒绝,这个 Token 就浪费了。
每个模块的 order 都可以通过 YAML 自定义,方便和你项目里的其他拦截器(认证、日志等)协调顺序。另外,防重和幂等模块共用的请求体缓存过滤器 RepeatableRequestFilter 的排序也可以在顶层统一配置:
guardian:
repeatable-filter-order: -100 # 请求体缓存过滤器排序(全局,默认 -100)
不用 Redis 也能跑
和防重、限流一样,切成本地缓存就行:
guardian:
idempotent:
storage: local
底层 ConcurrentHashMap + 守护线程定期清理过期 Key。开发环境、单体应用够用了。
想看 Redis 存储和本地存储的具体实现?源码在 guardian-storage-redis 和 IdempotentLocalStorage。
九、扩展点
和其他模块一样,核心组件都可替换,注册同类型 Bean 即可覆盖默认实现:
| 组件 | 接口 | 说明 |
|---|---|---|
| Token 生成器 | IdempotentTokenGenerator | 默认 UUID,可改为雪花 ID 等 |
| 存储 | IdempotentStorage | 默认 Redis,可自定义 |
| 结果缓存 | IdempotentResultCache | 默认跟随 storage 类型 |
| 响应处理 | IdempotentResponseHandler | 自定义 JSON 响应格式 |
| 用户上下文 | UserContext(三模块共享) | 获取当前用户 ID |
比如把 Token 生成换成雪花 ID:
@Bean
public IdempotentTokenGenerator idempotentTokenGenerator() {
return () -> String.valueOf(IdUtil.getSnowflakeNextId());
}
项目结构
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-idempotent/ # 接口幂等(v1.4.3 新增)
│ ├── guardian-idempotent-core/
│ └── guardian-idempotent-spring-boot-starter/
├── guardian-storage-redis/ # Redis 存储(多模块共享)
└── guardian-example/ # 示例工程
三个模块完全独立,用哪个引哪个,互不依赖。
总结
Guardian v1.4.3 新增了接口幂等模块,基于 Token 机制实现:
- 一次性 Token:客户端先获取 Token,携带 Token 发请求,Token 消费后即失效
- 结果缓存:开启后重复请求直接返回首次执行的结果,而非报错
- 接口隔离:Token 绑定接口标识,不同接口的 Token 不能混用
- Header / Param 双模式:适配不同的前端传参方式
加上之前的防重复提交和接口限流,Guardian 现在覆盖了三种 API 请求层防护场景:
| 功能 | 解决什么问题 | Starter |
|---|---|---|
| 防重复提交 | 用户手抖连点、表单重复提交 | guardian-repeat-submit-spring-boot-starter |
| 接口限流 | 恶意刷接口、突发流量 | guardian-rate-limit-spring-boot-starter |
| 接口幂等 | 支付回调重试、MQ 重复消费 | guardian-idempotent-spring-boot-starter |
如果你的 Spring Boot 项目需要这些能力,但又不想引 Sentinel 那么重的东西,可以试试。
Maven Central 坐标(最新 v1.4.3):
<!-- 防重复提交 -->
<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-repeat-submit-spring-boot-starter</artifactId>
<version>1.4.3</version>
</dependency>
<!-- 接口限流 -->
<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-rate-limit-spring-boot-starter</artifactId>
<version>1.4.3</version>
</dependency>
<!-- 接口幂等 -->
<dependency>
<groupId>io.github.biggg-guardian</groupId>
<artifactId>guardian-idempotent-spring-boot-starter</artifactId>
<version>1.4.3</version>
</dependency>
项目地址(README 里有完整配置文档和更新日志):
- GitHub:github.com/BigGG-Guard…
- Gitee:gitee.com/BigGG-Guard…
源码不多,没有复杂的抽象,适合学习 Spring Boot Starter 的封装思路。有问题直接提 Issue,我基本都会回。
觉得有用的话,去 GitHub 点个 Star 支持一下,这对开源作者真的很重要。