跳转到内容
123xiao | 无名键客

《Spring Boot 中基于 AOP + 注解实现统一接口幂等控制的实战指南》

字数: 0 阅读时长: 1 分钟

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. 整体设计思路

核心流程是:

  1. 给需要幂等控制的接口加上自定义注解
  2. AOP 拦截该注解标记的方法
  3. 从请求中提取“幂等键”
  4. 用 Redis 做原子判断:第一次请求放行,重复请求拦截
  5. 返回统一错误信息或自定义提示

这套设计把“幂等控制”从业务代码里剥离出来了。

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:请求参数中有时间戳、随机数,导致永远不重复

有些前端请求体里会带:

  • timestamp
  • nonce
  • traceId

如果这些字段参与 key 计算,那么每次请求 key 都不同,幂等控制就失效了。

建议:

  • 生成 key 时过滤无关字段
  • 或者优先使用业务唯一号作为 key

坑 3:AOP 不生效

常见原因:

  • 没引入 AOP 依赖
  • 注解加在 private 方法
  • 同类内部调用,绕过代理
  • 切点表达式没匹配到

排查方式:

  1. 看启动日志是否有 AOP 相关增强
  2. 在切面中打印日志
  3. 确认调用路径是否经过 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_no
  • request_id
  • callback_id

即使 Redis 失效,数据库仍能兜底。


十二、一个更贴近生产的边界判断

这里我想强调一个很容易被忽略的现实:

AOP + 注解 + Redis 这个方案,非常适合“统一接口防重复提交”,但不等于解决了所有幂等问题。

它最适合:

  • 用户重复点击
  • 前端重复提交
  • 短时间重试保护
  • 一般性表单接口

它不完全适合单独处理:

  • 支付回调
  • 消息消费幂等
  • 分布式事务补偿
  • 需要永久去重的核心业务

这些场景必须加上更强的业务约束。


十三、总结

我们这篇文章完整实现了一个统一接口幂等控制方案,核心落地步骤是:

  1. 定义 @Idempotent 注解
  2. 用 AOP 拦截目标接口
  3. 基于请求信息生成幂等 key
  4. 通过 Redis SET NX EX 做原子去重
  5. 重复请求直接拦截并返回统一提示

这套方案的价值在于:

  • 接入成本低
  • 对业务侵入小
  • 适合在 Spring Boot 项目里快速推广
  • 对“重复提交”类问题非常有效

但最后给你几个务实建议:

  • 只防短时重复提交,用 Redis + AOP 就够了
  • 涉及资金、库存、回调,必须再加业务唯一号和 DB 唯一索引
  • key 设计一定要带业务维度和用户维度
  • TTL 不要拍脑袋,按真实业务耗时设置
  • 先解决 80% 的重复提交问题,再针对核心链路做强幂等

如果你正在做中后台、交易系统或开放平台,我很建议把这套能力沉淀成一个公共 starter。真正好用的技术方案,不是“写出来”,而是“别人愿意复用”。


分享到:

上一篇
《Java开发踩坑实录:线程池参数误配导致服务雪崩的排查与优化实践》
下一篇
《Java开发踩坑实战:排查并修复线程池误用导致的接口响应抖动与内存飙升-435》