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

《Spring Boot 中基于 Redis 与 AOP 实现接口幂等性的实战方案》

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

Spring Boot 中基于 Redis 与 AOP 实现接口幂等性的实战方案

接口幂等性这个话题,很多人第一次接触时会觉得“是不是重复点一次按钮而已”。但真到了业务里,你会发现它一点都不小:前端重复提交、用户网络抖动、网关重试、消息重复投递,都会让同一个请求被执行多次。

如果接口刚好是“创建订单”“发券”“扣库存”“发起支付”这类写操作,重复执行一次,后果往往不是多一条日志,而是直接出事故。

这篇文章我带你从一个能跑、能落地、易扩展的角度,基于 Spring Boot + Redis + AOP 实现一套接口幂等方案。重点不是讲概念,而是把它真正接进项目里。


背景与问题

什么是接口幂等性

简单说:

同一个请求,无论被调用一次还是多次,最终结果应当一致。

比如:

  • 创建订单:同一笔请求只能生成一个订单
  • 提交表单:重复点击“提交”不能插入多条记录
  • 发起支付:不能重复扣款
  • 发优惠券:不能重复发放

但要注意,“结果一致”不代表“每次都返回完全一样的响应内容”
更准确一点,幂等关注的是业务状态不能被重复修改

为什么常见接口会重复调用

现实里重复调用非常常见:

  1. 用户连续点击按钮
  2. 前端超时后自动重试
  3. Nginx / 网关 / SDK 重试
  4. 消息队列重复投递
  5. 客户端断网重连再次提交
  6. 移动端“返回再点一次”

如果服务端没有防护,一个正常接口很容易变成“概率型 bug 制造机”。

常见但不够通用的做法

很多项目里都见过这些方式:

  • 前端按钮置灰
  • 数据库唯一索引
  • 状态机控制
  • token 防重复提交
  • Redis 锁

这些方式并不冲突,但单独使用往往各有局限:

  • 前端控制:只能防“手快”,防不了重试和恶意请求
  • 数据库唯一索引:适合特定场景,但没法覆盖所有业务
  • 状态机:适合流程型业务,但接入成本高
  • Redis 锁:更偏“并发互斥”,不完全等同于“幂等”

这篇文章重点讲的是一种通用方案:

通过 AOP 拦截接口,从请求中提取幂等 key,并用 Redis 原子写入 记录请求是否已处理,从而拒绝重复执行。


前置知识与环境准备

本文示例环境:

  • JDK 8+
  • Spring Boot 2.x
  • Spring AOP
  • Spring Data Redis
  • Redis 5.x+

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>

    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
    </dependency>
</dependencies>

配置文件示例:

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    timeout: 3000
server:
  port: 8080

核心原理

这套方案可以拆成 4 步:

  1. 给需要幂等的接口加注解
  2. AOP 在方法执行前进行拦截
  3. 根据“用户标识 + 路径 + 业务参数”等生成唯一 key
  4. 用 Redis 执行 SETNX + 过期时间
    • 成功:说明第一次提交,放行
    • 失败:说明重复提交,直接拦截

流程图

flowchart TD
    A[客户端发起请求] --> B[进入 Controller]
    B --> C[AOP 拦截带注解的方法]
    C --> D[生成幂等 Key]
    D --> E{Redis SETNX 是否成功}
    E -- 是 --> F[执行业务逻辑]
    F --> G[返回成功结果]
    E -- 否 --> H[返回重复提交提示]

时序图

sequenceDiagram
    participant Client as 客户端
    participant Controller as Controller
    participant Aspect as IdempotentAspect
    participant Redis as Redis
    participant Service as BusinessService

    Client->>Controller: POST /order/create
    Controller->>Aspect: 调用切面
    Aspect->>Redis: SET key value NX EX 10
    alt 首次请求
        Redis-->>Aspect: success
        Aspect->>Service: 执行业务
        Service-->>Aspect: 业务结果
        Aspect-->>Controller: 放行
        Controller-->>Client: 返回成功
    else 重复请求
        Redis-->>Aspect: fail
        Aspect-->>Controller: 拦截
        Controller-->>Client: 返回重复提交
    end

为什么 AOP 很适合做这件事

因为幂等校验是一个很典型的横切关注点

  • 它和业务逻辑关系不大
  • 但很多接口都需要
  • 如果每个接口都手写一遍,会非常啰嗦

AOP 的价值就在这里:
业务代码只管业务,幂等逻辑统一抽离。


方案设计

为了让方案更实用,我们先明确几个设计点。

1. 幂等 key 怎么生成

常见组合:

  • 用户 ID
  • 请求 URI
  • 业务参数
  • 客户端传来的幂等号(推荐)

比如:

idem:userId:uri:hash(requestBody)

如果你的系统有明确的“业务唯一号”,比如:

  • 订单号
  • 支付流水号
  • 请求号 requestId

那最好直接用它。
如果没有,才退而求其次,用“用户 + 路径 + 参数摘要”。

2. 幂等有效期怎么定

这个时间不是越长越好,而是要结合业务:

  • 防止重复点击:5~10 秒通常够用
  • 提交订单:30 秒到几分钟
  • 支付请求:建议结合业务唯一流水做长期保障

我自己踩过一个坑:把所有接口统一设成 24 小时,结果用户真的需要重新提交时被卡死。所以,过期时间一定要按业务来

3. Redis 记录的是“处理中”还是“已完成”

这里有两种思路:

思路 A:请求一来就占位

优点:

  • 简单
  • 能防瞬时并发重复

缺点:

  • 如果业务执行失败,也会被暂时拦住,直到 key 过期

思路 B:区分处理状态

比如 Redis value 记录:

  • PROCESSING
  • SUCCESS
  • FAIL

这样更灵活,但实现复杂一些。

本文先给你一个够用的入门版
先用“请求占位 + TTL”的方式解决大多数重复提交问题。


实战代码(可运行)

下面我们一步步搭起来。


第一步:定义幂等注解

package com.example.demo.idempotent;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {

    /**
     * Redis key 前缀
     */
    String prefix() default "idempotent:";

    /**
     * 过期时间,单位秒
     */
    long expireSeconds() default 10;

    /**
     * 提示信息
     */
    String message() default "请求重复,请稍后再试";
}

第二步:定义统一异常

package com.example.demo.idempotent;

public class IdempotentException extends RuntimeException {

    public IdempotentException(String message) {
        super(message);
    }
}

第三步:编写 Redis 工具类

这里直接使用 StringRedisTemplate

package com.example.demo.idempotent;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class RedisIdempotentSupport {

    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdempotentSupport(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public boolean setIfAbsent(String key, String value, long expireSeconds) {
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    public void delete(String key) {
        stringRedisTemplate.delete(key);
    }
}

第四步:定义请求对象

package com.example.demo.dto;

public class OrderCreateRequest {

    private String productId;
    private Integer quantity;

    public String getProductId() {
        return productId;
    }

    public void setProductId(String productId) {
        this.productId = productId;
    }

    public Integer getQuantity() {
        return quantity;
    }

    public void setQuantity(Integer quantity) {
        this.quantity = quantity;
    }
}

第五步:编写 AOP 切面

这是核心部分。
它做三件事:

  1. 读取注解参数
  2. 生成幂等 key
  3. 调 Redis 占位,失败则抛异常
package com.example.demo.idempotent;

import com.fasterxml.jackson.databind.ObjectMapper;
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.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;

@Aspect
@Component
public class IdempotentAspect {

    private final RedisIdempotentSupport redisIdempotentSupport;
    private final ObjectMapper objectMapper;

    public IdempotentAspect(RedisIdempotentSupport redisIdempotentSupport,
                            ObjectMapper objectMapper) {
        this.redisIdempotentSupport = redisIdempotentSupport;
        this.objectMapper = objectMapper;
    }

    @Around("@annotation(com.example.demo.idempotent.Idempotent)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        Idempotent idempotent = method.getAnnotation(Idempotent.class);

        String key = buildKey(joinPoint, idempotent);
        boolean success = redisIdempotentSupport.setIfAbsent(key, "1", idempotent.expireSeconds());

        if (!success) {
            throw new IdempotentException(idempotent.message());
        }

        return joinPoint.proceed();
    }

    private String buildKey(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Exception {
        ServletRequestAttributes attributes =
                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

        if (attributes == null) {
            throw new IllegalStateException("无法获取当前请求上下文");
        }

        HttpServletRequest request = attributes.getRequest();
        String uri = request.getRequestURI();

        // 这里为了演示,先从请求头取用户标识
        // 实际项目中建议从登录态、JWT、网关透传信息中获取
        String userId = request.getHeader("X-User-Id");
        if (userId == null || userId.trim().isEmpty()) {
            userId = "anonymous";
        }

        String argsJson = objectMapper.writeValueAsString(joinPoint.getArgs());
        String argsDigest = DigestUtils.md5DigestAsHex(argsJson.getBytes(StandardCharsets.UTF_8));

        return idempotent.prefix() + userId + ":" + uri + ":" + argsDigest;
    }
}

第六步:统一返回与异常处理

返回对象

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<>(0, "success", data);
    }

    public static <T> ApiResponse<T> fail(String message) {
        return new ApiResponse<>(-1, 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;
    }
}

全局异常处理

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());
    }
}

第七步:编写业务接口

package com.example.demo.controller;

import com.example.demo.common.ApiResponse;
import com.example.demo.dto.OrderCreateRequest;
import com.example.demo.idempotent.Idempotent;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

@RestController
@RequestMapping("/order")
public class OrderController {

    @PostMapping("/create")
    @Idempotent(prefix = "order:create:", expireSeconds = 10, message = "订单请勿重复提交")
    public ApiResponse<String> createOrder(@RequestBody OrderCreateRequest request) throws InterruptedException {
        // 模拟业务耗时
        Thread.sleep(2000);

        String orderNo = UUID.randomUUID().toString();
        return ApiResponse.success("订单创建成功,订单号:" + orderNo);
    }
}

第八步:启动类

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);
    }
}

如何验证

启动 Redis 和 Spring Boot 后,可以用 curl 测试。

第一次请求

curl -X POST 'http://localhost:8080/order/create' \
-H 'Content-Type: application/json' \
-H 'X-User-Id: 1001' \
-d '{
  "productId": "P10001",
  "quantity": 2
}'

返回类似:

{
  "code": 0,
  "message": "success",
  "data": "订单创建成功,订单号:xxxxxx"
}

在 10 秒内重复请求

curl -X POST 'http://localhost:8080/order/create' \
-H 'Content-Type: application/json' \
-H 'X-User-Id: 1001' \
-d '{
  "productId": "P10001",
  "quantity": 2
}'

返回:

{
  "code": -1,
  "message": "订单请勿重复提交",
  "data": null
}

验证思路清单

你可以按这个顺序自测:

  • 同用户、同参数、短时间重复提交会被拦截
  • 同用户、不同参数可以通过
  • 不同用户、相同参数互不影响
  • 超过过期时间后可重新提交
  • Redis 宕机时接口如何降级,需要明确策略

进阶优化:引入客户端幂等号

上面的方案已经够处理大部分“重复点击”问题,但如果你想更稳,推荐让客户端传一个唯一请求号,比如:

Idempotency-Key: 2c2d98cb-6f95-4f66-8b18-33b5d8f5b111

服务端优先使用这个值作为幂等依据。

为什么更推荐这个做法

因为“用户 + 路径 + 参数摘要”虽然通用,但并不总是完美:

  • 参数顺序变化可能影响摘要
  • 某些字段是无关字段
  • 有些业务天然需要“同样参数也能再次提交”

而客户端幂等号由请求方显式生成,更符合语义。

改造后的 key 生成逻辑示意

private String buildKey(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Exception {
    ServletRequestAttributes attributes =
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

    if (attributes == null) {
        throw new IllegalStateException("无法获取当前请求上下文");
    }

    HttpServletRequest request = attributes.getRequest();
    String uri = request.getRequestURI();
    String userId = request.getHeader("X-User-Id");
    String idempotencyKey = request.getHeader("Idempotency-Key");

    if (userId == null || userId.trim().isEmpty()) {
        userId = "anonymous";
    }

    if (idempotencyKey != null && !idempotencyKey.trim().isEmpty()) {
        return idempotent.prefix() + userId + ":" + uri + ":" + idempotencyKey;
    }

    String argsJson = objectMapper.writeValueAsString(joinPoint.getArgs());
    String argsDigest = DigestUtils.md5DigestAsHex(argsJson.getBytes(StandardCharsets.UTF_8));
    return idempotent.prefix() + userId + ":" + uri + ":" + argsDigest;
}

方案结构图

classDiagram
    class Idempotent {
        +String prefix()
        +long expireSeconds()
        +String message()
    }

    class IdempotentAspect {
        +around(ProceedingJoinPoint)
        -buildKey(ProceedingJoinPoint, Idempotent)
    }

    class RedisIdempotentSupport {
        +setIfAbsent(String, String, long) boolean
        +delete(String)
    }

    class OrderController {
        +createOrder(OrderCreateRequest)
    }

    OrderController --> Idempotent : 使用注解
    IdempotentAspect --> RedisIdempotentSupport : 调用Redis
    IdempotentAspect --> Idempotent : 读取配置

常见坑与排查

这一部分很重要。很多人代码看起来都对,结果上线后幂等还是失效,问题常常就出在这些细节。

1. AOP 没生效

现象

接口明明加了 @Idempotent,但重复请求照样进业务。

排查点

  • 是否引入了 spring-boot-starter-aop
  • 注解是否加在 public 方法上
  • 是否是 Spring 管理的 Bean
  • 是否发生了类内部自调用

例如:

public void methodA() {
    this.methodB();
}

@Idempotent
public void methodB() {
}

这种情况下,methodB() 不会经过代理,AOP 不生效。

建议

  • 注解优先加在 Controller 对外方法上
  • 避免内部自调用依赖切面

2. Redis key 设计不合理

现象

  • 不同请求被误判成重复
  • 明明重复请求却没拦住

典型原因

  • 只用 URI 做 key,没有带用户维度
  • 只用用户 ID 做 key,没有带参数
  • 参数里包含时间戳、随机数,导致每次摘要不同

建议

key 一定要结合业务设计,优先级一般是:

  1. 业务唯一号
  2. 客户端幂等号
  3. 用户 + URI + 关键业务参数

3. 业务失败后无法立即重试

现象

第一次请求执行时发生异常,但因为 Redis 已经占位,短时间内无法再次提交。

原因

这是“先占位再执行业务”的天然副作用。

处理策略

有两种常见方式:

方式一:异常时删除 key

适合大多数普通场景。

@Around("@annotation(com.example.demo.idempotent.Idempotent)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
    Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
    Idempotent idempotent = method.getAnnotation(Idempotent.class);

    String key = buildKey(joinPoint, idempotent);
    boolean success = redisIdempotentSupport.setIfAbsent(key, "1", idempotent.expireSeconds());

    if (!success) {
        throw new IdempotentException(idempotent.message());
    }

    try {
        return joinPoint.proceed();
    } catch (Throwable e) {
        redisIdempotentSupport.delete(key);
        throw e;
    }
}
方式二:状态机方案

将 Redis value 记录为处理中、成功、失败。
适合支付、发券、资金类接口,但实现会复杂一些。


4. 参数序列化不稳定

现象

同样语义的请求,生成了不同摘要。

原因

  • Map 顺序不稳定
  • 参数对象包含无关字段
  • 文件上传、流对象无法稳定序列化

建议

  • 对关键字段做显式拼装
  • 不要盲目对全部参数直接 JSON 化
  • 文件上传接口一般不建议直接走这套通用摘要方案

5. 分布式环境下时间窗口误判

现象

不同节点返回行为不一致。

原因

如果你没用 Redis,而是用了本地缓存,那在多实例部署下肯定会失效。

建议

  • 幂等状态必须放到共享存储
  • Redis 是典型选择
  • 不要用单机内存 Map 代替生产方案

安全/性能最佳实践

这一节我尽量说得更接近生产,而不是“demo 能跑就行”。

1. 幂等 key 不要直接暴露敏感信息

不要把手机号、身份证号、完整支付信息直接拼到 key 里。

不推荐:

idempotent:13800138000:/pay:622202xxxxxxxx

推荐:

  • 用户 ID
  • 业务流水号
  • 参数摘要 hash

2. TTL 要按业务分层

不同接口,TTL 应该不同:

  • 表单提交:5~10 秒
  • 下单接口:10~60 秒
  • 支付接口:更建议使用业务唯一单号 + 数据库状态兜底

一句经验话:

Redis 幂等是第一道防线,不是最后一道防线。

尤其是核心交易接口,最好叠加:

  • 数据库唯一索引
  • 业务状态判断
  • 流水号约束

3. 不要把幂等当成分布式锁的替代品

幂等解决的是:

  • 同一请求重复执行的问题

分布式锁解决的是:

  • 多个请求并发竞争同一资源的问题

两者相关,但不等价。
比如扣库存场景,通常既要考虑幂等,也要考虑并发控制。


4. 对高频接口注意 Redis 压力

如果接口调用量很高,幂等 key 会很多。

建议:

  • key 前缀规范化,便于排查
  • TTL 不要过长
  • 避免把大对象放进 value
  • 如果只是占位,value 存 "1" 就够了

5. 对关键业务使用“双保险”

以订单或支付为例,推荐组合策略:

  1. AOP + Redis 拦截短时间重复请求
  2. 数据库唯一索引保证最终唯一
  3. 业务状态机保证流程正确

这是更稳的生产思路。


与其他方案的取舍

方案优点缺点适用场景
前端按钮置灰简单防不了重试和恶意请求基础体验优化
数据库唯一索引最终一致强侵入业务表设计明确唯一业务键
Redis + AOP通用、接入快依赖 key 设计与 TTL大多数写接口
状态机幂等精准实现复杂支付、发券、资金类
Token 防重复提交语义清晰需要额外下发和管理 token表单提交类业务

如果你问我怎么选,我的建议是:

  • 一般后台管理系统:Redis + AOP 足够好用
  • 电商下单:Redis + AOP + DB 唯一索引
  • 支付资金类:业务流水号 + 状态机 + DB 约束,Redis 只做前置削峰

逐步验证清单

上线前建议至少验证这些内容:

1. 注解是否覆盖所有关键写接口
2. AOP 是否在测试环境真实生效
3. Redis 宕机时策略是失败还是放行
4. key 生成逻辑是否包含用户维度
5. 参数摘要是否稳定
6. 业务失败后是否允许重试
7. TTL 是否符合业务要求
8. 是否有数据库唯一约束做兜底
9. 日志中是否能打印幂等 key 与命中结果
10. 压测下 Redis 是否成为瓶颈

这里特别说第 3 条:
Redis 宕机时怎么办,一定要提前定策略。

常见策略有两种:

  • 严格模式:Redis 不可用时直接拒绝请求
  • 降级模式:Redis 不可用时放行业务,但打高危日志

对于支付类,我更倾向严格模式;
对于普通表单类,可以考虑降级模式。


总结

基于 Spring Boot + Redis + AOP 实现接口幂等,本质上是在业务执行前做一次统一拦截,用 Redis 原子操作判断“这次请求是不是第一次出现”。

这套方案的优点很明显:

  • 接入成本低
  • 对业务侵入小
  • 适合大多数写接口
  • 在分布式环境下依然有效

但也要清楚它的边界:

  • 它主要解决“短时间重复请求”
  • 不应该单独承担核心交易的最终一致性
  • key 设计和 TTL 设置比代码本身更重要

如果你准备在项目里真正落地,我建议按这个优先顺序来:

  1. 先给关键写接口加 @Idempotent
  2. key 优先使用业务唯一号或客户端幂等号
  3. 普通接口用 Redis TTL 拦截短时重复提交
  4. 核心交易接口叠加数据库唯一约束和状态机
  5. 把“异常是否删 key、Redis 不可用如何处理”写成明确规范

最后一句实战建议:

幂等不是“加个注解就完事”,而是“注解 + key 设计 + 失败策略 + 数据兜底”一起成立,方案才算真正可靠。

如果你先从本文这版 demo 起步,已经足够解决项目里 70% 以上的重复提交问题。剩下那 30%,通常就在业务唯一号、状态流转和异常补偿里。


分享到:

上一篇
《Java 中线程池参数调优与异步任务治理实战指南-128》
下一篇
《Web逆向实战:中级工程师如何定位并复现前端签名参数生成逻辑》