Spring Boot 中基于 AOP + 注解实现统一接口幂等控制的实战指南
很多系统在刚起步时,接口“能用就行”;等到支付回调、重复提交、前端重试、网关超时补偿一来,问题就集中爆发了:同一个请求被处理了两次,甚至多次。
比如:
- 用户下单按钮点了两次,生成两笔订单
- 支付平台回调重试,业务重复入账
- MQ 消费重投,库存被重复扣减
- 前端超时后自动重试,后端没做防重
这些问题表面上看是“重复请求”,本质上是接口幂等性没有做好。
这篇文章我不打算只讲概念,而是带你做一个中级项目里很实用的方案:基于 Spring Boot + 自定义注解 + AOP + Redis,实现统一接口幂等控制。这个方案的优点是:
- 业务代码侵入小
- 适合 Controller 层统一治理
- 可配置、可扩展
- 对重复提交类场景尤其好用
一、背景与问题
1. 什么是接口幂等性
幂等性(Idempotency)可以简单理解为:
同一个请求执行一次和执行多次,最终结果应该一致。
注意,这里的“一致”不一定是返回值完全相同,而是业务结果不应重复生效。
例如:
- 创建订单:同一个请求只能创建一笔订单
- 充值:同一笔充值回调只能入账一次
- 提交审批:不能重复提交造成重复流转
2. 为什么重复请求很常见
在真实项目里,重复请求远比我们想象中多:
- 用户手抖连续点击
- 浏览器重复提交表单
- 前端轮询或自动重试
- 网关、代理层重试
- 第三方回调机制天然带重试
- 分布式环境下消息重复投递
如果不做治理,轻则脏数据,重则资损。
3. 常见“防重”方式有什么问题
很多项目最开始会这么做:
- 在 Controller 里手写去重逻辑
- 在 Service 里查数据库判断是否处理过
- 在前端把按钮置灰
这些方法不能说没用,但通常存在问题:
- 逻辑分散:每个接口都写一遍
- 容易漏:新接口上线忘了加
- 职责不清:业务逻辑和幂等控制耦合
- 性能一般:每次都查库代价高
- 前端防抖不可靠:只能防“手抖”,防不了服务端重试
所以更稳妥的思路是:把接口幂等控制做成一个通用能力。
二、前置知识与环境准备
本文示例使用:
- JDK 8+
- Spring Boot 2.x
- Spring AOP
- Redis
- Maven
依赖如下:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
配置文件示例:
spring:
redis:
host: 127.0.0.1
port: 6379
server:
port: 8080
三、核心原理
我们先不急着写代码,先把思路捋顺。
1. 整体设计思路
核心流程是:
- 给需要幂等控制的接口加上自定义注解
- AOP 拦截该注解标记的方法
- 从请求中提取“幂等键”
- 用 Redis 做原子判断:第一次请求放行,重复请求拦截
- 返回统一错误信息或自定义提示
这套设计把“幂等控制”从业务代码里剥离出来了。
2. 为什么用 AOP + 注解
因为它很适合做“横切逻辑”。
像日志、权限、限流、幂等,本质上都不是业务主流程,但又需要统一介入。AOP 的优势是:
- 不改业务主逻辑
- 接入简单,注解即可启用
- 统一维护,后续扩展方便
3. 为什么用 Redis
幂等控制最关键的是:
- 快
- 支持分布式
- 能做原子操作
- 能设置过期时间
Redis 正好适合。典型做法是:
SET key value NX EX 10
含义:
NX:只有 key 不存在时才设置成功EX 10:10 秒后过期
这样第一次请求会成功写入 key,重复请求因为 key 已存在而失败,从而实现拦截。
四、方案流程图
1. 请求处理流程
flowchart TD
A[客户端发起请求] --> B[Controller 接口]
B --> C[AOP 拦截 @Idempotent]
C --> D[生成幂等 Key]
D --> E{Redis SET NX EX 是否成功}
E -- 是 --> F[执行业务方法]
F --> G[返回成功结果]
E -- 否 --> H[拦截重复请求]
H --> I[返回重复提交提示]
2. 时序图
sequenceDiagram
participant Client as 客户端
participant Controller as Controller
participant Aspect as 幂等切面
participant Redis as Redis
participant Service as 业务服务
Client->>Controller: HTTP 请求
Controller->>Aspect: 进入切面
Aspect->>Redis: SET key value NX EX
alt 首次请求
Redis-->>Aspect: success
Aspect->>Service: 执行业务
Service-->>Aspect: 返回结果
Aspect-->>Controller: 放行
Controller-->>Client: 成功响应
else 重复请求
Redis-->>Aspect: fail
Aspect-->>Controller: 抛出重复提交异常
Controller-->>Client: 返回失败提示
end
五、实战代码(可运行)
下面我们做一个完整可运行版本。
1. 自定义注解
先定义一个注解,用来标记哪些接口需要做幂等控制。
package com.example.demo.idempotent;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* key 前缀,便于分类
*/
String prefix() default "idempotent";
/**
* 过期时间,单位秒
*/
long timeout() default 10L;
/**
* 重复请求时的提示
*/
String message() default "请求重复,请稍后再试";
}
2. 定义统一异常
package com.example.demo.idempotent;
public class IdempotentException extends RuntimeException {
public IdempotentException(String message) {
super(message);
}
}
3. 定义统一返回对象
package com.example.demo.common;
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public ApiResponse() {
}
public ApiResponse(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "success", data);
}
public static <T> ApiResponse<T> fail(String message) {
return new ApiResponse<>(500, message, null);
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
4. 全局异常处理
package com.example.demo.common;
import com.example.demo.idempotent.IdempotentException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IdempotentException.class)
public ApiResponse<Void> handleIdempotentException(IdempotentException e) {
return ApiResponse.fail(e.getMessage());
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception e) {
return ApiResponse.fail("系统异常:" + e.getMessage());
}
}
5. Redis 配置
为了让 RedisTemplate 更好用,我们简单配一下序列化。
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
@Configuration
public class RedisConfig {
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
}
6. 幂等 Key 生成工具
一个关键点是:key 不能乱生成。
否则不同请求会被误判成同一个,或者同一个请求无法识别成重复。
这里给一个简单实用策略:
- 请求 URI
- HTTP 方法
- 用户标识(如果有登录)
- 请求参数
共同参与生成 key。
package com.example.demo.idempotent;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.TreeMap;
@Component
public class IdempotentKeyGenerator {
private final ObjectMapper objectMapper = new ObjectMapper();
public String generate(HttpServletRequest request, Object[] args, String prefix) {
try {
String uri = request.getRequestURI();
String method = request.getMethod();
// 这里为了示例,简单从请求头中取用户标识
String userId = request.getHeader("X-User-Id");
if (userId == null || userId.trim().isEmpty()) {
userId = "anonymous";
}
Map<String, Object> data = new TreeMap<>();
data.put("uri", uri);
data.put("method", method);
data.put("userId", userId);
data.put("args", args);
String raw = objectMapper.writeValueAsString(data);
String md5 = DigestUtils.md5DigestAsHex(raw.getBytes(StandardCharsets.UTF_8));
return prefix + ":" + md5;
} catch (Exception e) {
throw new RuntimeException("生成幂等 Key 失败", e);
}
}
}
实战里我更建议把“用户标识”作为强约束之一,否则多个用户相同参数调用时可能互相影响。
7. AOP 切面实现
这是整套方案的核心。
package com.example.demo.idempotent;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Aspect
@Component
public class IdempotentAspect {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private IdempotentKeyGenerator keyGenerator;
@Around("@annotation(com.example.demo.idempotent.Idempotent)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
HttpServletRequest request = getCurrentRequest();
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
Idempotent idempotent = method.getAnnotation(Idempotent.class);
String key = keyGenerator.generate(request, joinPoint.getArgs(), idempotent.prefix());
String value = UUID.randomUUID().toString();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(key, value, idempotent.timeout(), TimeUnit.SECONDS);
if (Boolean.FALSE.equals(success)) {
throw new IdempotentException(idempotent.message());
}
return joinPoint.proceed();
}
private HttpServletRequest getCurrentRequest() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) {
throw new RuntimeException("无法获取当前请求上下文");
}
return ((ServletRequestAttributes) requestAttributes).getRequest();
}
}
这个实现的特点
- 首次请求:
setIfAbsent成功,放行 - 重复请求:
setIfAbsent失败,直接拦截 - Key 自动过期:避免无限堆积
一个容易忽视的点
这个版本适合短时间防重复提交。
比如 5 秒、10 秒内的重复点击、重复提交。
但如果你想处理的是:
- 支付回调永远不能重复入账
- 订单创建请求需要跨分钟甚至跨天唯一
那就不能只靠一个短 TTL 锁,还要结合业务唯一号 + 数据库唯一约束。这一点后面会详细讲。
8. 示例业务接口
先定义请求对象。
package com.example.demo.order;
public class OrderRequest {
private String productCode;
private Integer quantity;
public String getProductCode() {
return productCode;
}
public void setProductCode(String productCode) {
this.productCode = productCode;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
}
Service 实现:
package com.example.demo.order;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
public class OrderService {
public String createOrder(OrderRequest request) {
// 模拟业务处理
return "ORDER-" + UUID.randomUUID().toString().replace("-", "");
}
}
Controller:
package com.example.demo.order;
import com.example.demo.common.ApiResponse;
import com.example.demo.idempotent.Idempotent;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
@RestController
@RequestMapping("/orders")
public class OrderController {
@Resource
private OrderService orderService;
@PostMapping("/create")
@Idempotent(prefix = "order:create", timeout = 10, message = "订单正在处理中,请勿重复提交")
public ApiResponse<String> create(@RequestBody OrderRequest request) {
String orderNo = orderService.createOrder(request);
return ApiResponse.success(orderNo);
}
}
9. 启动类
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class IdempotentDemoApplication {
public static void main(String[] args) {
SpringApplication.run(IdempotentDemoApplication.class, args);
}
}
六、如何验证效果
你可以直接用 curl 或 Postman 测试。
1. 第一次请求
curl --location --request POST 'http://localhost:8080/orders/create' \
--header 'Content-Type: application/json' \
--header 'X-User-Id: 1001' \
--data-raw '{
"productCode": "P10001",
"quantity": 2
}'
预期返回:
{
"code": 200,
"message": "success",
"data": "ORDER-xxxxxx"
}
2. 在 10 秒内重复请求
再次发送完全相同请求,预期返回:
{
"code": 500,
"message": "订单正在处理中,请勿重复提交",
"data": null
}
七、逐步验证清单
如果你想确认整套链路真的生效,可以按这个顺序检查:
- Redis 服务已启动
- 接口方法已加
@Idempotent - 已引入
spring-boot-starter-aop - 请求确实进入了切面
- Redis 中能看到对应 key
- 在过期时间内重复请求被拦截
- 不同用户相同请求不会互相误伤
- 过期后再次请求可以正常执行
八、进阶设计:幂等控制的几种模式
很多同学做到这里就以为万事大吉了,但其实接口幂等分两类:
1. 防重复提交型
特点:
- 时间窗口内拦截重复请求
- 常用于表单提交、下单按钮点击
- Redis 锁足够好用
2. 业务唯一型
特点:
- 不是拦 10 秒,而是永远不能重复处理
- 常见于支付流水、第三方回调、消息消费
这类场景更推荐:
- 请求带唯一业务号
- 数据库表加唯一索引
- 处理状态机控制
- Redis 作为前置加速,不是最终裁决者
一个经验建议
我个人在项目里一般这么分层:
- Controller 层:AOP + 注解做“快速拦截”
- Service 层:基于业务号做“最终幂等保障”
- DB 层:唯一索引做“最后防线”
这个组合比单纯 Redis 更稳。
九、常见坑与排查
这一节很重要,我自己踩过不少。
坑 1:只靠请求参数生成 key,没有用户维度
例如两个不同用户提交了相同商品和数量,如果 key 里不带用户信息,就可能互相拦截。
排查方式:
- 打印最终生成的 key
- 对比两个用户请求是否生成了同一个 key
建议:
- key 中至少包含用户 ID、租户 ID、设备 ID 或业务唯一号之一
坑 2:请求参数中有时间戳、随机数,导致永远不重复
有些前端请求体里会带:
timestampnoncetraceId
如果这些字段参与 key 计算,那么每次请求 key 都不同,幂等控制就失效了。
建议:
- 生成 key 时过滤无关字段
- 或者优先使用业务唯一号作为 key
坑 3:AOP 不生效
常见原因:
- 没引入 AOP 依赖
- 注解加在
private方法 - 同类内部调用,绕过代理
- 切点表达式没匹配到
排查方式:
- 看启动日志是否有 AOP 相关增强
- 在切面中打印日志
- 确认调用路径是否经过 Spring 代理对象
坑 4:异常后是否删除 key?
这个问题没有统一答案,要看场景。
做法 A:不删除 key
优点:
- 防止用户短时间反复重试打爆接口
缺点:
- 如果第一次请求执行失败,短时间内用户也不能重试
做法 B:业务异常时删除 key
优点:
- 失败后允许立即重试
缺点:
- 某些并发场景可能导致重复执行
对于“防重复提交”场景,我通常建议:
- 默认不删除
- timeout 设置合理,比如 3~10 秒
对于“必须成功且允许失败重试”的业务,可以在切面里根据异常类型决定是否删除 key。
例如:
try {
return joinPoint.proceed();
} catch (Exception e) {
stringRedisTemplate.delete(key);
throw e;
}
但这要非常谨慎,不能一刀切。
坑 5:Redis 锁过期太短,业务没执行完
如果接口实际执行 15 秒,但幂等 key 只保留 5 秒,那么第 6 秒发来的重复请求就可能又进来了。
建议:
- timeout 要覆盖业务平均执行时间
- 长事务场景考虑更稳的业务幂等设计
- 不要把“短 TTL 锁”当成万能方案
坑 6:使用对象参数直接序列化,字段顺序不稳定
有些对象序列化结果顺序不稳定,会导致同样参数生成不同 hash。
虽然 Jackson 通常已经比较稳定,但复杂嵌套对象仍建议谨慎。
建议:
- 使用稳定序列化方式
- 或者只抽取关键业务字段参与 key 计算
十、安全/性能最佳实践
这一部分是把方案从“能跑”提升到“能上线”。
1. 不要把完整敏感数据直接拼进 key
比如身份证号、手机号、银行卡号,不要直接明文入 Redis key。
建议:
- 先做 hash,再作为 key 一部分
- key 中只保留业务可识别前缀
例如:
order:create:md5(...)
而不是:
order:create:13800138000:身份证号
2. Redis 只是第一道门,不是最终真相
如果业务涉及资金、库存、优惠券核销这类强一致场景,必须加:
- 数据库唯一索引
- 状态字段校验
- 事务控制
- 业务流水号
一句话总结: Redis 更像“门卫”,数据库才是“法官”。
3. 合理设置 TTL
TTL 太短:
- 无法覆盖业务执行时间
- 会漏拦截重复请求
TTL 太长:
- 用户失败后等待太久
- 误伤正常重试
一般建议:
- 防重复点击:3~10 秒
- 提交型表单:10~30 秒
- 支付回调:不要只靠 TTL,要结合业务唯一号
4. Key 设计要分层分业务
建议统一格式:
模块:动作:维度hash
例如:
order:create:xxxx
payment:callback:xxxx
coupon:receive:xxxx
这样后续排查 Redis key 时非常方便。
5. 给幂等失败打日志,但不要刷屏
重复请求本身未必是异常,也可能是用户正常重试。所以日志级别建议控制好。
建议记录:
- URI
- 用户标识
- 幂等 key
- 请求参数摘要
- 拦截时间
不要记录:
- 大量敏感信息
- 完整超大请求体
6. 支持自定义 Key 策略会更灵活
本文示例是统一从 URI + 用户 + 参数生成 key。
但在复杂系统里,最好支持按业务自定义,比如:
- 优先取请求头中的
Idempotency-Token - 优先取订单号
- 优先取支付流水号
如果你要继续扩展,可以在注解里增加字段,比如:
String key() default "";
再结合 SpEL 表达式解析:
@Idempotent(key = "#request.orderNo")
这样可用性会更强。
十一、进一步优化方向
如果你想把这个方案做成团队级通用组件,可以往这几个方向走。
1. 支持 SpEL 动态取值
这是企业项目里很常见的玩法。比如:
@Idempotent(prefix = "pay", key = "#payRequest.bizOrderNo", timeout = 60)
这样幂等 key 不是“整个请求哈希”,而是明确基于业务字段,更可控。
2. 支持不同重复策略
比如注解里增加策略:
REJECT:直接拒绝重复请求WAIT:轮询等待首个请求完成RETURN_LAST_RESULT:直接返回上一次结果
尤其是 RETURN_LAST_RESULT,在下单类接口里体验会更好,但实现复杂度会明显上升。
3. 支持状态机幂等
对于支付回调类场景,可以使用状态流转:
INIT -> PROCESSING -> SUCCESS / FAIL
这样就不只是“拦一下重复请求”,而是有完整业务状态管理。
stateDiagram-v2
[*] --> INIT
INIT --> PROCESSING: 首次接收请求
PROCESSING --> SUCCESS: 处理完成
PROCESSING --> FAIL: 处理失败
SUCCESS --> SUCCESS: 重复回调直接忽略
FAIL --> PROCESSING: 允许人工或系统重试
4. 与数据库唯一索引联动
例如订单表或幂等记录表增加唯一字段:
biz_norequest_idcallback_id
即使 Redis 失效,数据库仍能兜底。
十二、一个更贴近生产的边界判断
这里我想强调一个很容易被忽略的现实:
AOP + 注解 + Redis 这个方案,非常适合“统一接口防重复提交”,但不等于解决了所有幂等问题。
它最适合:
- 用户重复点击
- 前端重复提交
- 短时间重试保护
- 一般性表单接口
它不完全适合单独处理:
- 支付回调
- 消息消费幂等
- 分布式事务补偿
- 需要永久去重的核心业务
这些场景必须加上更强的业务约束。
十三、总结
我们这篇文章完整实现了一个统一接口幂等控制方案,核心落地步骤是:
- 定义
@Idempotent注解 - 用 AOP 拦截目标接口
- 基于请求信息生成幂等 key
- 通过 Redis
SET NX EX做原子去重 - 重复请求直接拦截并返回统一提示
这套方案的价值在于:
- 接入成本低
- 对业务侵入小
- 适合在 Spring Boot 项目里快速推广
- 对“重复提交”类问题非常有效
但最后给你几个务实建议:
- 只防短时重复提交,用 Redis + AOP 就够了
- 涉及资金、库存、回调,必须再加业务唯一号和 DB 唯一索引
- key 设计一定要带业务维度和用户维度
- TTL 不要拍脑袋,按真实业务耗时设置
- 先解决 80% 的重复提交问题,再针对核心链路做强幂等
如果你正在做中后台、交易系统或开放平台,我很建议把这套能力沉淀成一个公共 starter。真正好用的技术方案,不是“写出来”,而是“别人愿意复用”。