基于 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 注解,就能轻松避免重复请求带来的问题。

相关推荐
架构师沉默7 分钟前
Java 开发者别忽略 return!这 11 种写法你写对了吗?
java·后端·架构
RainbowJie114 分钟前
Gemini CLI 与 MCP 服务器:释放本地工具的强大潜力
java·服务器·spring boot·后端·python·单元测试·maven
毕设源码尹学长1 小时前
计算机毕业设计 java 血液中心服务系统 基于 Java 的血液管理平台Java 开发的血液服务系统
java·开发语言·课程设计
lumi.1 小时前
2.3零基础玩转uni-app轮播图:从入门到精通 (咸虾米总结)
java·开发语言·前端·vue.js·微信小程序·uni-app·vue
mask哥2 小时前
详解flink SQL基础(四)
java·大数据·数据库·sql·微服务·flink
灰原喜欢柯南2 小时前
Spring Boot 自动配置全流程深度解析
java·spring boot·后端
Code_Artist2 小时前
[Java并发编程]4.阻塞队列
java·数据结构·后端
心月狐的流火号2 小时前
Java NIO Selector 源码分析
java
MrSYJ3 小时前
AuthenticationEntryPoint认证入口
java·spring cloud·架构