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
- 成功就放行业务
- 失败就拦截返回
这样业务方法只需要加一个注解。
方案设计思路
本文采用的方案是:
- 自定义注解
@Idempotent - AOP 拦截被注解的方法
- 通过 SpEL 或请求参数生成唯一 key
- 使用 Redis
SETNX + EXPIRE原子占位 - 已存在则直接拒绝本次请求
- 业务执行完成后,根据策略决定是否删除 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": "订单正在处理或已重复提交"
}
逐步验证清单
如果你想确认整套链路是通的,可以按这个顺序验证:
-
Redis 连接正常
- 能成功启动应用
- Redis 中能看到新 key
-
AOP 生效
- Controller 方法加了
@Idempotent - 重复请求会被拦截
- Controller 方法加了
-
SpEL 正常解析
#req.orderNo能拿到正确值- Redis key 不是空字符串
-
过期时间正确
ttl key查看剩余时间- 到期后允许再次提交
-
用户维度隔离
- 相同
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 管理的对象
排查方式:
- 确认切面类被扫描到
- 在切面里打日志
- 确认目标方法是
public - 避免在同类里
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 幂等是高性价比入口方案,不是最终一致性的全部答案。
一个更稳妥的落地建议
如果你要在真实项目里用,我建议按这个优先级来设计:
- 客户端生成 requestId
- 服务端 AOP + Redis 做入口幂等
- 数据库唯一索引做最终兜底
- 关键业务保存请求流水与处理结果
- 回调类接口支持重复请求结果复用
这个组合拳比单点方案靠谱得多。
总结
我们这篇文章做的事情其实很明确:
- 用
@Idempotent注解声明接口需要幂等控制 - 用 AOP 把通用逻辑从业务代码里抽出来
- 用 Redis 的原子
setIfAbsent实现分布式场景下的请求占位 - 通过 TTL 控制幂等窗口,避免重复提交
- 再配合数据库唯一约束,形成更完整的防线
如果你只记住一句话,我希望是这句:
接口幂等不是“防手抖”,而是服务端对重复执行风险的系统性治理。
最后给几个可执行建议:
- 创建类接口:优先用
requestId或业务唯一号做幂等 key - 不要只靠前端防重复
- 不要轻易在 finally 删除 Redis key
- 关键业务一定加数据库唯一约束
- Redis 不可用时要有明确降级策略
如果你现在项目里正好有“重复下单”“重复创建”“回调重复消费”这类问题,可以先从本文这套方案开始,成本不高,收益很直接。