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

30 阅读10分钟

前言

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

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

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

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

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

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


一、什么场景需要幂等?

举几个典型的:

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
fromHEADERToken 来源:HEADER / PARAM(PARAM 模式依次查找 URL 参数、表单字段、JSON Body)
tokenNameX-Idempotent-TokenHeader 名 / 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 表示删成功。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 的排序也可以在顶层统一配置:

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

不用 Redis 也能跑

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

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:

@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 机制实现:

  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):

<!-- 防重复提交 -->
<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 里有完整配置文档和更新日志):

源码不多,没有复杂的抽象,适合学习 Spring Boot Starter 的封装思路。有问题直接提 Issue,我基本都会回。

觉得有用的话,去 GitHub 点个 Star 支持一下,这对开源作者真的很重要。