给 Spring Boot 接口加了幂等保护:Token 机制 + 结果缓存,一个注解搞定

前言

之前写了个轻量级的 Spring Boot 接口防护框架 Guardian,做了防重复提交和接口限流两个功能,发到了 Maven Central。

防重和幂等经常被搞混,但它们解决的是不同的问题:

  • 防重复提交:同一个请求短时间内别提交两次(锁一段时间就行)
  • 接口幂等:同一个操作不管执行几次,结果都一样(比如支付,扣一次钱就行)

防重是"不让你提交",幂等是"提交了也没事"。场景不一样,实现方式也不一样。

所以这次在 Guardian v1.4.3 里加了接口幂等模块 ,基于 Token 机制,支持结果缓存。和之前的防重、限流一样,独立 Starter,用哪个引哪个。

项目地址(源码 + 示例 + 文档全在里面):


一、什么场景需要幂等?

举几个典型的:

1. 支付回调

第三方支付平台通知你支付成功,网络抖了一下超时了,平台会重发。如果你的回调接口没做幂等,用户可能被扣两次钱。

2. 订单提交

用户点了提交按钮,前端没做防抖,或者网络慢用户多点了几下。后端收到三个一模一样的请求,创建了三个订单。

3. 消息队列重试

MQ 消费失败重试,消息被消费了两次。如果消费逻辑是"给用户加积分",那用户就多拿了积分。

这些场景的共同点是:请求可能被重复发送,但业务只应该执行一次


二、先看效果

三步搞定。

第一步,引依赖:

xml 复制代码
<dependency>
    <groupId>io.github.biggg-guardian</groupId>
    <artifactId>guardian-idempotent-spring-boot-starter</artifactId>
    <version>1.4.3</version>
</dependency>

第二步,业务接口加注解:

less 复制代码
@Idempotent("order-submit")
@PostMapping("/order/submit")
public Result submitOrder(@RequestBody OrderDTO order) {
    return orderService.submit(order);
}

第三步,客户端发请求前先拿 Token:

vbnet 复制代码
GET /guardian/idempotent/token?key=order-submit

返回:

css 复制代码
{
  "code": 200,
  "data": {
    "token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "expireIn": 300,
    "expireUnit": "SECONDS"
  }
}

把 Token 放到请求头里:

bash 复制代码
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 机制是怎么工作的?

整个流程分四步:

vbnet 复制代码
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 消费后,重复请求直接拒绝,返回错误提示。

但有些场景下,客户端需要的不是"你已经提交过了"这种错误,而是"上次提交的结果"。比如支付回调,第三方平台重发通知时期望收到一个正常的成功响应,而不是报错。

开启结果缓存就能解决这个问题:

yaml 复制代码
guardian:
  idempotent:
    result-cache: true

开启之后,首次请求的返回值会被自动缓存。后续拿同一个 Token(已消费)再请求时,拦截器直接返回缓存的结果,而不是报错。

css 复制代码
首次请求:
  Token 消费成功 → 执行业务 → 返回 {"code":200,"data":"订单创建成功"}
                                    ↓
                              缓存返回值到 Redis

重复请求:
  Token 已消费 → 查缓存 → 命中 → 直接返回 {"code":200,"data":"订单创建成功"}
                        → 未命中 → 正常拒绝

实现上用了 Spring 的 ResponseBodyAdvice,在 Controller 返回之后、响应写入之前,把返回值序列化存到 Redis(或本地缓存),缓存的过期时间和 Token 一致。

结果缓存的源码在 IdempotentResultCacheAdvice.java,拦截器在 IdempotentInterceptor.java,代码不多,感兴趣可以看看。


五、两种传 Token 的方式

Header 方式(默认)

Token 放在请求头里,适合大多数场景:

less 复制代码
@Idempotent("order-submit")
@PostMapping("/order/submit")
public Result submit(@RequestBody OrderDTO order) { ... }

请求头带上 X-Idempotent-Token: {token}

Param 方式

Token 不走请求头,而是作为参数传递。PARAM 模式会依次查找:URL 查询参数 → 表单字段 → JSON Body 字段,找到即用。

URL 参数方式:

less 复制代码
@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 嵌在请求体里):

less 复制代码
@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,可以改:

ini 复制代码
@Idempotent(value = "custom", tokenName = "X-Pay-Token")

六、全量配置

yaml 复制代码
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,全局异常处理器里接一下。

java 复制代码
@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]

scss 复制代码
[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 端点

bash 复制代码
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 表示删成功。ConcurrentHashMapremove 本身就是原子操作。

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 的排序也可以在顶层统一配置:

yaml 复制代码
guardian:
  repeatable-filter-order: -100   # 请求体缓存过滤器排序(全局,默认 -100)

不用 Redis 也能跑

和防重、限流一样,切成本地缓存就行:

yaml 复制代码
guardian:
  idempotent:
    storage: local

底层 ConcurrentHashMap + 守护线程定期清理过期 Key。开发环境、单体应用够用了。

想看 Redis 存储和本地存储的具体实现?源码在 guardian-storage-redisIdempotentLocalStorage


九、扩展点

和其他模块一样,核心组件都可替换,注册同类型 Bean 即可覆盖默认实现:

组件 接口 说明
Token 生成器 IdempotentTokenGenerator 默认 UUID,可改为雪花 ID 等
存储 IdempotentStorage 默认 Redis,可自定义
结果缓存 IdempotentResultCache 默认跟随 storage 类型
响应处理 IdempotentResponseHandler 自定义 JSON 响应格式
用户上下文 UserContext(三模块共享) 获取当前用户 ID

比如把 Token 生成换成雪花 ID:

typescript 复制代码
@Bean
public IdempotentTokenGenerator idempotentTokenGenerator() {
    return () -> String.valueOf(IdUtil.getSnowflakeNextId());
}

项目结构

r 复制代码
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 机制实现:

  1. 一次性 Token:客户端先获取 Token,携带 Token 发请求,Token 消费后即失效
  2. 结果缓存:开启后重复请求直接返回首次执行的结果,而非报错
  3. 接口隔离:Token 绑定接口标识,不同接口的 Token 不能混用
  4. 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):

xml 复制代码
<!-- 防重复提交 -->
<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>
相关推荐
是真的小外套1 天前
第十五章:XXE漏洞攻防与其他漏洞全解析
后端·计算机网络·php
ybwycx1 天前
SpringBoot下获取resources目录下文件的常用方法
java·spring boot·后端
小陈工1 天前
Python Web开发入门(十一):RESTful API设计原则与最佳实践——让你的API既优雅又好用
开发语言·前端·人工智能·后端·python·安全·restful
小阳哥AI工具1 天前
Seedance 2.0使用真人参考图生成视频的方法
后端
IeE1QQ3GT1 天前
使用ASP.NET Abstractions增强ASP.NET应用程序的可测试性
后端·asp.net
Full Stack Developme1 天前
SpringBoot多线程池配置
spring boot·后端·firefox
sxhcwgcy1 天前
SpringBoot 使用 spring.profiles.active 来区分不同环境配置
spring boot·后端·spring
稻草猫.1 天前
Spring事务操作全解析
java·数据库·后端·spring
希望永不加班1 天前
SpringBoot 整合 MongoDB
java·spring boot·后端·mongodb·spring
Lzh编程小栈1 天前
数据结构与算法之队列深度解析:循环队列+C 语言硬核实现 + 面试考点全梳理
c语言·开发语言·汇编·数据结构·后端·算法·面试