基于 AOP 实现接口幂等性 ------ 深入浅出实战指南
在日常开发中,我们经常会遇到重复请求的问题,比如:
- 用户在前端快速点击按钮多次,触发多次相同的接口调用。
- 网络抖动导致请求被多次发送。
- 分布式系统中,消息重复消费。
这些情况如果不加限制,可能会导致数据重复写入、库存扣减多次、资金重复扣款 等严重问题。
解决这个问题的关键就是幂等性(Idempotency)。
1. 什么是幂等性?
幂等性是指一次和多次请求某个资源,对资源本身应该产生相同的结果。
比如:
- 查询接口天然幂等
- 插入数据接口通常非幂等,需要额外处理
- 扣款接口如果重复执行,可能导致多次扣款,需要幂等保护
简单理解:无论你点一次还是点一百次,结果都一样。
sequenceDiagram
participant Client as 客户端
participant Controller as Controller 方法
participant Aspect as AOP 切面
participant KeyResolver as KeyResolver
participant Redis as Redis
Client->>Controller: 发送请求
Controller->>Aspect: 方法调用被 AOP 拦截
Aspect->>KeyResolver: 解析唯一请求 Key
KeyResolver-->>Aspect: 返回 Key
Aspect->>Redis: SETNX key timeout
Redis-->>Aspect: 成功(true) 或 失败(false)
alt SETNX 失败
Aspect-->>Client: 返回错误(重复请求)
else SETNX 成功
Aspect->>Controller: 执行目标方法
alt 方法执行成功
Controller-->>Aspect: 返回结果
Aspect-->>Client: 返回成功响应
else 方法执行异常
Aspect->>Redis: 删除 Key (可选)
Aspect-->>Client: 返回错误响应
end
end
2. 基于 AOP 的幂等性实现思路
采用AOP + Redis 分布式锁的方式来实现幂等性,核心思路是:
- 拦截方法调用(AOP 切面)
- 生成唯一请求 Key(根据方法名、参数、用户信息等)
- 使用 Redis setIfAbsent 加锁(一定时间内只允许执行一次)
- 执行方法逻辑
- 异常时删除 Key(避免异常导致锁一直存在)
这种方式的优点:
- 侵入性低,只需加一个注解
- 支持多种 Key 生成策略
- 适合分布式环境
3. 核心注解:@Idempotent
java
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
int timeout() default 1; // 超时时间
TimeUnit timeUnit() default TimeUnit.SECONDS; // 时间单位
String message() default "重复请求,请稍后重试"; // 提示信息
Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class; // Key 解析器
String keyArg() default ""; // 自定义 Key 参数
boolean deleteKeyWhenException() default true; // 异常时是否删除 Key
}
这个注解就是幂等性的入口,开发者只需要在方法上加上 @Idempotent
,就能自动开启幂等保护。
4. AOP 切面实现
java
@Aspect
@Slf4j
public class IdempotentAspect {
private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;
private final IdempotentRedisDAO idempotentRedisDAO;
@Around(value = "@annotation(idempotent)")
public Object aroundPointCut(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 1. 获取 Key 解析器
IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());
Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver");
// 2. 生成唯一 Key
String key = keyResolver.resolver(joinPoint, idempotent);
// 3. Redis 加锁
boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());
if (!success) {
log.info("[aroundPointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs());
throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), idempotent.message());
}
// 4. 执行方法逻辑
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
// 5. 异常时删除 Key
if (idempotent.deleteKeyWhenException()) {
idempotentRedisDAO.delete(key);
}
throw throwable;
}
}
}
这里的关键是 setIfAbsent
,它利用 Redis 的原子性保证了相同 Key 在指定时间内只能被设置一次,从而实现幂等。
5. 多种 Key 生成策略
5.1 默认全局 Key
java
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
String methodName = joinPoint.getSignature().toString();
String argsStr = StrUtil.join(",", joinPoint.getArgs());
return SecureUtil.md5(methodName + argsStr);
}
}
特点:方法名 + 参数,适合全局唯一的请求。
5.2 用户级别 Key
java
public class UserIdempotentKeyResolver implements IdempotentKeyResolver {
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
String methodName = joinPoint.getSignature().toString();
String argsStr = StrUtil.join(",", joinPoint.getArgs());
Long userId = WebFrameworkUtils.getLoginUserId();
Integer userType = WebFrameworkUtils.getLoginUserType();
return SecureUtil.md5(methodName + argsStr + userId + userType);
}
}
特点:同一个用户的相同请求才会被拦截,不同用户互不影响。
5.3 自定义表达式 Key
java
public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {
private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
private final ExpressionParser expressionParser = new SpelExpressionParser();
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
// 获得被拦截方法参数名列表
Method method = getMethod(joinPoint);
Object[] args = joinPoint.getArgs();
String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method);
// 准备 Spring EL 表达式解析的上下文
StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
if (ArrayUtil.isNotEmpty(parameterNames)) {
for (int i = 0; i < parameterNames.length; i++) {
evaluationContext.setVariable(parameterNames[i], args[i]);
}
}
// 解析参数
Expression expression = expressionParser.parseExpression(idempotent.keyArg());
return expression.getValue(evaluationContext, String.class);
}
private static Method getMethod(JoinPoint point) {
// 处理,声明在类上的情况
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
if (!method.getDeclaringClass().isInterface()) {
return method;
}
// 处理,声明在接口上的情况
try {
return point.getTarget().getClass().getDeclaredMethod(
point.getSignature().getName(), method.getParameterTypes());
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
}
特点:灵活性最高,适合复杂业务场景。
6.Redis DAO的实现
typescript
@AllArgsConstructor
public class IdempotentRedisDAO {
/**
* 幂等操作
*
* KEY 格式:idempotent:%s // 参数为 uuid
* VALUE 格式:String
* 过期时间:不固定
*/
private static final String IDEMPOTENT = "idempotent:%s";
private final StringRedisTemplate redisTemplate;
public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) {
String redisKey = formatKey(key);
return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit);
}
public void delete(String key) {
String redisKey = formatKey(key);
redisTemplate.delete(redisKey);
}
private static String formatKey(String key) {
return String.format(IDEMPOTENT, key);
}
}
统一添加前缀 KEY 格式:idempotent:%s // 参数为 uuid
7. 使用示例
java
@PostMapping("/order/create")
@Idempotent(timeout = 5, timeUnit = TimeUnit.SECONDS, keyResolver = UserIdempotentKeyResolver.class)
public String createOrder(@RequestBody OrderRequest request) {
// 创建订单逻辑
return "订单创建成功";
}
这样,同一个用户 在 5 秒内重复请求 createOrder
,只会执行一次。
8. 注意事项
-
超时时间设置要合理
太短可能无法覆盖业务执行时间,太长可能影响用户体验。
-
异常删除 Key
如果业务执行失败且不删除 Key,可能导致用户无法重试。
-
分布式环境必备 Redis
本实现依赖 Redis 来保证分布式锁的原子性。
-
适用场景
- 防止表单重复提交
- 防止接口被频繁调用
- 防止消息重复消费
9. 总结
通过 AOP + Redis,我们可以非常优雅地实现接口幂等性,既保证了业务安全,又降低了代码侵入性。
只需要在方法上加一个 @Idempotent
注解,就能轻松避免重复请求带来的问题。