基于 AOP 实现接口幂等性 —— 深入浅出实战指南

基于 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 分布式锁的方式来实现幂等性,核心思路是:

  1. 拦截方法调用(AOP 切面)
  2. 生成唯一请求 Key(根据方法名、参数、用户信息等)
  3. 使用 Redis setIfAbsent 加锁(一定时间内只允许执行一次)
  4. 执行方法逻辑
  5. 异常时删除 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. 注意事项

  1. 超时时间设置要合理

    太短可能无法覆盖业务执行时间,太长可能影响用户体验。

  2. 异常删除 Key

    如果业务执行失败且不删除 Key,可能导致用户无法重试。

  3. 分布式环境必备 Redis

    本实现依赖 Redis 来保证分布式锁的原子性。

  4. 适用场景

    • 防止表单重复提交
    • 防止接口被频繁调用
    • 防止消息重复消费

9. 总结

通过 AOP + Redis,我们可以非常优雅地实现接口幂等性,既保证了业务安全,又降低了代码侵入性。

只需要在方法上加一个 @Idempotent 注解,就能轻松避免重复请求带来的问题。

相关推荐
Grey Zeng4 小时前
Java SE 25新增特性
java·jdk·jdk新特性·jdk25
雨白5 小时前
Java 线程通信基础:interrupt、wait 和 notifyAll 详解
android·java
架构师沉默9 小时前
设计多租户 SaaS 系统,如何做到数据隔离 & 资源配额?
java·后端·架构
Java中文社群10 小时前
重要:Java25正式发布(长期支持版)!
java·后端·面试
每天进步一点_JL11 小时前
JVM 类加载:双亲委派机制
java·后端
用户2986985301412 小时前
Java HTML 转 Word 完整指南
java·后端
渣哥12 小时前
原来公平锁和非公平锁差别这么大
java
渣哥12 小时前
99% 的人没搞懂:Semaphore 到底是干啥的?
java
J2K12 小时前
JDK都25了,你还没用过ZGC?那真得补补课了
java·jvm·后端
kfyty72512 小时前
不依赖第三方,不销毁重建,loveqq 框架如何原生实现动态线程池?
java·架构