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

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

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

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

接口幂等性这个话题,很多人第一次接触时会觉得“好像不复杂,不就是防重复提交吗”。但真落地到业务里,尤其是下单、支付、创建工单、发券这些场景,就会发现它不是一个 if 判断能解决的问题。

这篇文章我不打算只讲概念,而是直接带你做一个Spring Boot + Redis + AOP 的可运行方案。重点放在:

  • 为什么要做幂等
  • Redis 和 AOP 在这里各自扮演什么角色
  • 如何写一个通用注解,尽量少侵入业务代码
  • 常见坑怎么排查
  • 怎么把它真正用到线上而不是“看起来能跑”

背景与问题

先看几个典型场景:

  • 用户连续点了两次“提交订单”
  • 前端因为网络超时自动重试
  • 网关层做了失败重试
  • MQ 消费端因为异常被重新投递
  • 第三方回调通知重复推送

这些场景有个共同点:同一个业务请求可能被执行多次

如果接口本身不是幂等的,就可能出现:

  • 重复下单
  • 重复扣款
  • 重复发券
  • 库存被扣两次
  • 工单被创建多份

很多系统的“防重复”做法是前端按钮置灰、页面禁用点击。这种方式只能挡住一部分“手抖”,挡不住:

  • 浏览器重发
  • App 重试
  • 代理层重试
  • 服务间重试
  • 恶意请求

所以,幂等性必须在服务端兜底


前置知识与环境准备

本文示例基于以下环境:

  • JDK 17
  • Spring Boot 3.x
  • Spring AOP
  • Spring Data Redis
  • Redis 6.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>
</dependencies>

application.yml

spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 3000ms

server:
  port: 8080

核心原理

我们先把问题抽象一下。

什么叫接口幂等

幂等的意思不是“只能调用一次”,而是:

同一个请求调用一次和调用多次,最终结果应当一致。

比如创建订单接口,在相同业务条件下,第一次请求创建成功;第二次相同请求过来,不应该再创建第二张订单。

为什么选 Redis

Redis 适合做幂等控制,原因有几个:

  • 原子操作强,SET key value NX EX seconds 很适合抢占“执行资格”
  • 性能高,适合高并发请求入口
  • 可以设置过期时间,避免死锁式占用
  • 独立于应用实例,适合分布式部署

为什么配合 AOP

如果每个接口里都写一遍 Redis 判重逻辑,代码会很散:

  • 很容易漏
  • 维护成本高
  • 业务代码和通用逻辑耦合

AOP 的作用就是把“幂等控制”抽成一个横切逻辑:

  • 在方法执行前,生成幂等 key
  • 到 Redis 抢占 key
  • 成功就放行业务
  • 失败就拦截返回

这样业务方法只需要加一个注解。


方案设计思路

本文采用的方案是:

  1. 自定义注解 @Idempotent
  2. AOP 拦截被注解的方法
  3. 通过 SpEL 或请求参数生成唯一 key
  4. 使用 Redis SETNX + EXPIRE 原子占位
  5. 已存在则直接拒绝本次请求
  6. 业务执行完成后,根据策略决定是否删除 key

这里有一个关键问题:

key 什么时候删除?

这是实际落地里最容易被忽略的点。

常见有两种策略:

策略一:执行后立即删除

适合“防抖式幂等”,比如 3 秒内禁止重复点击。

优点:

  • 同一用户过一会儿还可以正常再次提交

缺点:

  • 如果业务本身要求“绝对去重”,删除后又能再次执行

策略二:依赖过期时间自动删除

适合“在一段时间内只允许一次”的场景,比如支付提交、订单创建。

优点:

  • 简单稳妥
  • 出异常也不会因为 finally 删除导致漏洞

缺点:

  • 过期时间要配置合理

这篇文章我更推荐第二种,因为它更符合大多数接口幂等的真实诉求,也更安全。


整体流程图

flowchart TD
    A[客户端发起请求] --> B[进入 Controller]
    B --> C[AOP 拦截 @Idempotent]
    C --> D[解析 SpEL 生成幂等 Key]
    D --> E{Redis SET NX EX 是否成功}
    E -- 成功 --> F[执行业务方法]
    F --> G[返回成功结果]
    E -- 失败 --> H[拦截请求并返回重复提交提示]

幂等状态时序图

sequenceDiagram
    participant Client as 客户端
    participant App as Spring Boot
    participant AOP as IdempotentAspect
    participant Redis as Redis
    participant Biz as BusinessService

    Client->>App: POST /order/create
    App->>AOP: 进入幂等切面
    AOP->>Redis: SET key value NX EX 10
    alt 第一次请求
        Redis-->>AOP: OK
        AOP->>Biz: 执行业务
        Biz-->>AOP: 返回结果
        AOP-->>App: 放行
        App-->>Client: 创建成功
    else 重复请求
        Redis-->>AOP: null
        AOP-->>App: 抛出重复提交异常
        App-->>Client: 请勿重复提交
    end

实战代码(可运行)

下面我们一步一步实现。


1)定义幂等注解

package com.example.demo.idempotent;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

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

    /**
     * 幂等 Key 前缀
     */
    String prefix() default "idempotent:";

    /**
     * SpEL 表达式,用于从参数中提取业务唯一值
     * 例如:#req.orderNo 或 #userId
     */
    String key();

    /**
     * 过期时间
     */
    long timeout() default 10;

    /**
     * 时间单位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 重复提交时的提示
     */
    String message() default "请勿重复提交";
}

2)定义异常类

package com.example.demo.idempotent;

public class IdempotentException extends RuntimeException {
    public IdempotentException(String message) {
        super(message);
    }
}

3)编写 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 RedisIdempotentHelper {

    private final StringRedisTemplate stringRedisTemplate;

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

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

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

4)编写 SpEL 解析工具

为了让注解能写 #req.orderNo 这种表达式,需要从方法参数上下文里解析。

package com.example.demo.idempotent;

import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

import java.lang.reflect.Method;

public class SpelUtils {

    private static final ExpressionParser PARSER = new SpelExpressionParser();
    private static final DefaultParameterNameDiscoverer NAME_DISCOVERER =
            new DefaultParameterNameDiscoverer();

    public static String parseKey(String spel, Method method, Object[] args) {
        String[] paramNames = NAME_DISCOVERER.getParameterNames(method);
        StandardEvaluationContext context = new StandardEvaluationContext();
        if (paramNames != null) {
            for (int i = 0; i < paramNames.length; i++) {
                context.setVariable(paramNames[i], args[i]);
            }
        }
        Object value = PARSER.parseExpression(spel).getValue(context);
        return value == null ? "" : value.toString();
    }
}

提醒一下:如果你发现参数名取不到,先确认编译参数里是否保留了方法参数名。Spring Boot 3 一般问题不大,但有些构建环境会踩坑,后文我会讲排查方法。


5)编写 AOP 切面

package com.example.demo.idempotent;

import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.UUID;

@Aspect
@Component
public class IdempotentAspect {

    private final RedisIdempotentHelper redisIdempotentHelper;
    private final HttpServletRequest request;

    public IdempotentAspect(RedisIdempotentHelper redisIdempotentHelper,
                            HttpServletRequest request) {
        this.redisIdempotentHelper = redisIdempotentHelper;
        this.request = request;
    }

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

        String bizKey = SpelUtils.parseKey(idempotent.key(), method, joinPoint.getArgs());
        if (bizKey == null || bizKey.isBlank()) {
            throw new IllegalArgumentException("幂等 key 解析结果为空,请检查注解 key 表达式");
        }

        String userToken = request.getHeader("X-User-Id");
        if (userToken == null || userToken.isBlank()) {
            userToken = request.getRemoteAddr();
        }

        String redisKey = idempotent.prefix() + userToken + ":" + bizKey;
        String value = UUID.randomUUID().toString();

        boolean locked = redisIdempotentHelper.tryLock(
                redisKey,
                value,
                idempotent.timeout(),
                idempotent.timeUnit()
        );

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

        return joinPoint.proceed();
    }
}

这里我故意把 key 设计成:

prefix + 用户标识 + 业务标识

因为仅靠业务字段有时不够,比如“提交评论内容相同”不代表一定是重复请求;而加入用户维度后会更合理。


6)统一异常处理

package com.example.demo.web;

import com.example.demo.idempotent.IdempotentException;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IdempotentException.class)
    public Map<String, Object> handleIdempotent(IdempotentException e) {
        Map<String, Object> result = new HashMap<>();
        result.put("code", 409);
        result.put("message", e.getMessage());
        return result;
    }

    @ExceptionHandler(Exception.class)
    public Map<String, Object> handleException(Exception e) {
        Map<String, Object> result = new HashMap<>();
        result.put("code", 500);
        result.put("message", e.getMessage());
        return result;
    }
}

7)编写业务请求对象

package com.example.demo.order;

public class CreateOrderRequest {

    private String orderNo;
    private Long amount;

    public String getOrderNo() {
        return orderNo;
    }

    public void setOrderNo(String orderNo) {
        this.orderNo = orderNo;
    }

    public Long getAmount() {
        return amount;
    }

    public void setAmount(Long amount) {
        this.amount = amount;
    }
}

8)编写 Service

package com.example.demo.order;

import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

@Service
public class OrderService {

    public Map<String, Object> createOrder(CreateOrderRequest req) {
        Map<String, Object> result = new HashMap<>();
        result.put("orderNo", req.getOrderNo());
        result.put("amount", req.getAmount());
        result.put("status", "CREATED");
        return result;
    }
}

9)编写 Controller

package com.example.demo.order;

import com.example.demo.idempotent.Idempotent;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

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

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping("/create")
    @Idempotent(
            prefix = "order:create:",
            key = "#req.orderNo",
            timeout = 10,
            timeUnit = TimeUnit.SECONDS,
            message = "订单正在处理或已重复提交"
    )
    public Map<String, Object> create(@RequestBody CreateOrderRequest req) {
        Map<String, Object> result = new HashMap<>();
        result.put("code", 200);
        result.put("data", orderService.createOrder(req));
        return result;
    }
}

10)启动类

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 --location 'http://localhost:8080/order/create' \
--header 'Content-Type: application/json' \
--header 'X-User-Id: 1001' \
--data '{
  "orderNo": "ORD20231101001",
  "amount": 100
}'

返回示例:

{
  "code": 200,
  "data": {
    "orderNo": "ORD20231101001",
    "amount": 100,
    "status": "CREATED"
  }
}

10 秒内重复发同样请求:

{
  "code": 409,
  "message": "订单正在处理或已重复提交"
}

逐步验证清单

如果你想确认整套链路是通的,可以按这个顺序验证:

  1. Redis 连接正常

    • 能成功启动应用
    • Redis 中能看到新 key
  2. AOP 生效

    • Controller 方法加了 @Idempotent
    • 重复请求会被拦截
  3. SpEL 正常解析

    • #req.orderNo 能拿到正确值
    • Redis key 不是空字符串
  4. 过期时间正确

    • ttl key 查看剩余时间
    • 到期后允许再次提交
  5. 用户维度隔离

    • 相同 orderNo,不同 X-User-Id 是否互不影响

再往前一步:状态视角理解幂等控制

很多同学把幂等理解成“加锁”,其实更准确地说,它是在请求入口维护一个简化状态。

stateDiagram-v2
    [*] --> 未处理
    未处理 --> 处理中: 首次请求抢占成功
    处理中 --> 已拦截: 重复请求到达
    处理中 --> 过期可重试: TTL 到期
    过期可重试 --> 处理中: 新请求再次抢占成功

这个状态图能帮助你理解:
我们并不是在数据库层判断“有没有处理过”,而是在请求入口用短时状态挡住重复执行


常见坑与排查

这部分很重要,我自己项目里踩过不少。

1)幂等 key 设计不合理

比如你写了:

@Idempotent(key = "#req.amount")

这就有问题。相同金额并不代表相同业务请求。

更合理的做法是选择:

  • 订单号
  • 请求流水号
  • 客户端生成的唯一 requestId
  • 用户 ID + 业务主键

原则:能唯一标识“同一业务动作”的字段,才能做幂等 key。


2)只靠 IP 做 key,误伤严重

示例里我把 IP 当成兜底方案,但绝对不建议在生产里主要依赖它。因为:

  • NAT 下多个用户共用一个出口 IP
  • 代理、网关会影响真实 IP
  • 移动网络 IP 变化频繁

生产里更推荐:

  • 登录用户 ID
  • 租户 ID + 用户 ID
  • 客户端 requestId
  • 设备 ID + 业务号

3)AOP 不生效

常见原因:

  • 没引入 spring-boot-starter-aop
  • 注解加在 private 方法上
  • 同类内部调用,绕过了 Spring 代理
  • 方法不是 Spring Bean 管理的对象

排查方式:

  1. 确认切面类被扫描到
  2. 在切面里打日志
  3. 确认目标方法是 public
  4. 避免在同类里 this.xxx() 调用被拦截方法

4)SpEL 取不到参数名

如果表达式写的是:

#req.orderNo

但运行时报空,很可能是参数名没保留。

可排查:

  • IDEA / Maven 是否开启参数名保留
  • 方法签名是否真的是 CreateOrderRequest req
  • 是否被代理后拿错 Method

如果你不想依赖参数名,也可以改成索引方式,比如扩展支持:

#p0.orderNo

这是更稳的做法。


5)异常时是否删除 key

这是一个非常典型的争议点。

有些文章会这样写:

try {
    return joinPoint.proceed();
} finally {
    redis.delete(key);
}

看起来很“优雅”,其实风险不小。

假设业务刚执行完数据库写入,还没来得及返回,结果服务抖动,客户端超时重试。由于 finally 已删除 key,第二次请求可能又会进来,造成重复创建。

所以对于“创建类接口”,我通常建议:

  • 不要在 finally 里删除
  • 让 Redis TTL 自然过期
  • TTL 根据业务窗口合理配置

当然,也有例外:

  • 表单防抖
  • 秒级重复点击拦截
  • 仅保护短时并发

这些场景可以考虑执行后删除。


6)Redis 单点故障怎么办

如果 Redis 挂了,幂等能力就失效了。这里不能只说“上哨兵、上集群”这么简单,还要考虑业务降级策略:

  • 强一致场景:Redis 异常时直接拒绝请求
  • 弱一致场景:记录告警后放行,但接受重复风险

像支付、扣款、发券这类接口,我的建议很明确:

Redis 不可用时,宁可失败,也不要静默放行。


安全/性能最佳实践

这一部分是把方案从“能跑”推进到“能上线”。

1)优先使用客户端请求唯一号

如果前端或调用方可以生成 requestId,这是最好的:

  • 每次业务动作唯一
  • 重试时沿用同一个 requestId
  • 服务端直接用它做幂等 key

比如:

{
  "requestId": "0f8fad5b-d9cb-469f-a165-70867728950e",
  "orderNo": "ORD20231101001"
}

然后注解写成:

@Idempotent(key = "#req.requestId")

这样最干净。


2)幂等不要替代业务唯一约束

这是很多系统的误区。

Redis 幂等只能挡住大部分重复请求,但不能代替数据库唯一索引
真正关键的业务,还是应该在存储层有最后一道防线,比如:

  • 订单号唯一索引
  • 支付流水号唯一索引
  • 券发放记录唯一索引

也就是说:

  • Redis:入口拦截,减压
  • 数据库唯一约束:最终兜底,保底正确性

两者最好同时存在。


3)过期时间别拍脑袋

TTL 太短:

  • 业务还没处理完,请求锁就过期了
  • 重试可能再次进入

TTL 太长:

  • 用户正常重试也会被拦截
  • 体验差

经验上可以这么配:

  • 普通表单提交:3~10 秒
  • 下单创建:10~60 秒
  • 支付确认:30~120 秒
  • 异步回调:根据对方重试策略设置更长窗口

最稳的方法是:根据接口 RT、超时设置、客户端重试策略综合评估。


4)Redis key 要有命名规范

建议统一格式:

业务系统:环境:模块:动作:用户标识:业务标识

例如:

mall:prod:order:create:1001:ORD20231101001

好处:

  • 便于排查
  • 便于统计
  • 不容易和别的模块冲突

5)日志要能看懂

建议至少记录:

  • 幂等 key
  • 用户标识
  • 接口路径
  • 是否抢占成功
  • 请求参数摘要
  • 异常信息

比如在切面中打日志:

log.info("idempotent check, uri={}, key={}, locked={}", request.getRequestURI(), redisKey, locked);

线上排查时,这类日志能省很多时间。


6)高并发场景注意热点 key

如果所有请求都打到同一个 key,比如你误把 key 写成固定值:

@Idempotent(key = "'fixed'")

那就会形成热点,所有请求都竞争同一把“锁”。

正确做法是让 key 具有业务区分度,并尽量均匀分布。


7)对外回调场景建议使用“结果复用”

对于第三方回调,仅仅拦截重复请求有时还不够。更好的方式是:

  • 第一次处理后保存处理结果
  • 后续重复回调,直接返回上次结果

这类场景比“只返回请勿重复提交”更友好,也更符合协议交互预期。


方案边界与适用场景

这个方案很好用,但不是万能的。

适合的场景

  • 防重复提交
  • 短时间内去重
  • 创建类接口的一次性保护
  • 入口层并发控制
  • 重试请求拦截

不适合单独依赖的场景

  • 跨天、长期的全局唯一去重
  • 严格金融级一致性
  • 分布式事务完整保障
  • 已执行结果需要查询复用的复杂回放场景

换句话说,Redis + AOP 幂等是高性价比入口方案,不是最终一致性的全部答案


一个更稳妥的落地建议

如果你要在真实项目里用,我建议按这个优先级来设计:

  1. 客户端生成 requestId
  2. 服务端 AOP + Redis 做入口幂等
  3. 数据库唯一索引做最终兜底
  4. 关键业务保存请求流水与处理结果
  5. 回调类接口支持重复请求结果复用

这个组合拳比单点方案靠谱得多。


总结

我们这篇文章做的事情其实很明确:

  • @Idempotent 注解声明接口需要幂等控制
  • 用 AOP 把通用逻辑从业务代码里抽出来
  • 用 Redis 的原子 setIfAbsent 实现分布式场景下的请求占位
  • 通过 TTL 控制幂等窗口,避免重复提交
  • 再配合数据库唯一约束,形成更完整的防线

如果你只记住一句话,我希望是这句:

接口幂等不是“防手抖”,而是服务端对重复执行风险的系统性治理。

最后给几个可执行建议:

  • 创建类接口:优先用 requestId 或业务唯一号做幂等 key
  • 不要只靠前端防重复
  • 不要轻易在 finally 删除 Redis key
  • 关键业务一定加数据库唯一约束
  • Redis 不可用时要有明确降级策略

如果你现在项目里正好有“重复下单”“重复创建”“回调重复消费”这类问题,可以先从本文这套方案开始,成本不高,收益很直接。


分享到:

上一篇
《Java开发踩坑实战:排查并彻底解决线程池误用导致的接口超时与内存飙升问题》
下一篇
《从源码到部署:基于开源项目 MinIO 搭建高可用对象存储服务的实战指南-251》