Spring Boot 中基于 Redis 与 AOP 实现接口幂等控制的实战指南
接口幂等,说白了就是:同一个请求重复提交多次,系统最终只处理一次,结果可控。
这个需求在支付、下单、退款、消息消费、表单重复提交这些场景里特别常见。很多团队一开始觉得“前端按钮置灰一下就好了”,结果一上线就会发现:网络重试、用户狂点、网关超时重放、客户端重复提交,这些情况前端根本兜不住。
这篇文章我会带你用一个比较实用、也比较容易在 Spring Boot 项目里落地的方式:Redis + AOP + 自定义注解,做一套接口幂等控制方案。重点不是“写一个能跑的 demo”,而是“写一个线上能用、出问题能排查”的版本。
背景与问题
先明确一点:幂等不等于防重复点击。
它解决的是更广义的问题:
- 用户快速点击两次提交
- 前端因为网络抖动自动重试
- 网关或客户端超时后重放请求
- MQ 消费端重复消费
- 分布式系统下多实例同时收到相同业务请求
如果没有幂等控制,常见后果包括:
- 订单重复创建
- 库存重复扣减
- 支付重复处理
- 优惠券重复发放
- 资金类业务出现严重事故
很多人第一反应是数据库唯一索引。这个当然有用,但它更像是最后一道防线,并不总能优雅解决接口层的重复提交问题。比如:
- 你要在业务执行前就拦截重复请求
- 不同接口的幂等粒度不一样
- 你希望把逻辑统一抽到切面层,而不是每个 controller/service 都手写一遍
这时候,Redis 就非常适合做一个轻量、快速、跨实例共享状态的幂等标记存储。
前置知识与环境准备
本文示例环境:
- JDK 8+
- Spring Boot 2.x / 3.x 都可参考
- Redis 6+
- Maven
核心依赖如下:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</artifactId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
配置文件示例:
spring:
redis:
host: 127.0.0.1
port: 6379
timeout: 3000ms
核心原理
我们先把思路搭起来,再看代码会更顺。
方案目标
对某个接口:
- 请求进来时,先生成一个幂等 Key
- 用 Redis 原子操作尝试写入这个 Key
- 如果写入成功,说明是第一次请求,放行业务逻辑
- 如果写入失败,说明重复请求,直接拦截
- Key 设置过期时间,避免永久占用
一个典型的幂等键
通常会包含这些信息:
- 接口路径
- 用户标识
- 请求参数摘要
- 业务唯一号(如果有,比如订单号)
例如:
idempotent:submitOrder:10001:9f2b1b7a8c...
为什么要用 Redis 的原子操作
幂等控制最怕“并发穿透”。比如两个相同请求几乎同时到达:
- A 线程判断 Redis 没有 key
- B 线程也判断 Redis 没有 key
- 然后两个线程都继续执行业务
所以不能先 get 再 set,而是要用 Redis 的原子语义:
SET key value NX EX 10
意思是:
NX:key 不存在才设置EX 10:设置 10 秒过期时间
这样在并发下只有一个请求能成功。
幂等控制整体流程图
flowchart TD
A[客户端发起请求] --> B[进入 Controller]
B --> C[AOP 拦截带 @Idempotent 注解的方法]
C --> D[生成幂等 Key]
D --> E{Redis SET NX EX 是否成功}
E -- 是 --> F[执行业务逻辑]
F --> G[返回成功结果]
E -- 否 --> H[拦截请求并返回重复提交提示]
方案设计:为什么是 AOP + 注解
如果每个接口都手写这样的逻辑:
if (redis 幂等校验通过) {
// do business
}
项目一大,很快就会出现这些问题:
- 重复代码太多
- 不同人写法不一致
- 接口忘记加控制
- 后期改策略很难统一收口
所以更好的做法是:
- 用
@Idempotent注解声明“这个接口需要幂等” - 用 AOP 在方法执行前统一处理
- 业务代码本身保持干净
这也是 Spring Boot 项目里最常见、最舒服的落地方式。
时序图:请求如何被拦截
sequenceDiagram
participant Client as 客户端
participant Controller as Controller
participant Aspect as IdempotentAspect
participant Redis as Redis
participant Service as BusinessService
Client->>Controller: POST /order/submit
Controller->>Aspect: 调用目标方法
Aspect->>Redis: SET key value NX EX
alt 首次请求
Redis-->>Aspect: success
Aspect->>Service: 执行业务
Service-->>Aspect: 处理完成
Aspect-->>Controller: 返回结果
Controller-->>Client: success
else 重复请求
Redis-->>Aspect: fail
Aspect-->>Controller: 抛出重复提交异常
Controller-->>Client: 重复提交
end
实战代码(可运行)
下面直接给一套可以落地的示例。
1. 自定义注解 @Idempotent
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";
/**
* 过期时间
*/
long timeout() default 10;
/**
* 时间单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* SpEL 表达式,可用于指定业务唯一键,比如 #request.orderNo
*/
String key() default "";
}
2. 定义重复提交异常
package com.example.demo.idempotent;
public class RepeatSubmitException extends RuntimeException {
public RepeatSubmitException(String message) {
super(message);
}
}
3. Redis 配置
为了便于序列化和调试,建议显式配置 StringRedisTemplate 或 RedisTemplate。这里我们直接使用 StringRedisTemplate。
package com.example.demo.config;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedisConfig {
// 如果只用 StringRedisTemplate,这里可以不额外配置
}
4. 幂等切面实现
这里是核心。
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.core.DefaultParameterNameDiscoverer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
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.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Aspect
@Component
public class IdempotentAspect {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private ObjectMapper objectMapper;
private final ExpressionParser parser = new SpelExpressionParser();
private final DefaultParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();
@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, method, idempotent);
String value = UUID.randomUUID().toString();
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(
key,
value,
idempotent.timeout(),
idempotent.timeUnit()
);
if (!Boolean.TRUE.equals(success)) {
throw new RepeatSubmitException("请求重复,请稍后再试");
}
return joinPoint.proceed();
}
private String buildKey(ProceedingJoinPoint joinPoint, Method method, Idempotent idempotent) throws Exception {
HttpServletRequest request = getRequest();
String prefix = idempotent.prefix();
String uri = request != null ? request.getRequestURI() : method.getName();
String userId = getCurrentUserId(request);
String businessKey;
if (StringUtils.hasText(idempotent.key())) {
businessKey = parseSpel(method, joinPoint.getArgs(), idempotent.key());
} else {
String argsJson = objectMapper.writeValueAsString(joinPoint.getArgs());
businessKey = DigestUtils.md5DigestAsHex(argsJson.getBytes(StandardCharsets.UTF_8));
}
return String.join(":",
prefix,
uri,
userId,
businessKey
);
}
private String parseSpel(Method method, Object[] args, String spel) {
String[] paramNames = nameDiscoverer.getParameterNames(method);
EvaluationContext 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 Objects.toString(value, "null");
}
private HttpServletRequest getRequest() {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes == null ? null : attributes.getRequest();
}
private String getCurrentUserId(HttpServletRequest request) {
if (request == null) {
return "anonymous";
}
String userId = request.getHeader("X-User-Id");
return StringUtils.hasText(userId) ? userId : "anonymous";
}
}
5. 全局异常处理
package com.example.demo.web;
import com.example.demo.idempotent.RepeatSubmitException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RepeatSubmitException.class)
public Map<String, Object> handleRepeatSubmitException(RepeatSubmitException 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;
}
}
6. 请求对象与 Controller
package com.example.demo.order;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
public class OrderRequest {
@NotBlank
private String orderNo;
@NotBlank
private String productCode;
@Min(1)
private Integer count;
public String getOrderNo() {
return orderNo;
}
public void setOrderNo(String orderNo) {
this.orderNo = orderNo;
}
public String getProductCode() {
return productCode;
}
public void setProductCode(String productCode) {
this.productCode = productCode;
}
public Integer getCount() {
return count;
}
public void setCount(Integer count) {
this.count = count;
}
}
package com.example.demo.order;
import com.example.demo.idempotent.Idempotent;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/order")
public class OrderController {
@PostMapping("/submit")
@Idempotent(prefix = "submitOrder", key = "#request.orderNo", timeout = 30)
public Map<String, Object> submit(@RequestBody @Validated OrderRequest request) throws InterruptedException {
// 模拟业务处理耗时
Thread.sleep(2000);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("message", "下单成功");
result.put("orderNo", request.getOrderNo());
return result;
}
}
这里我故意用 #request.orderNo 作为幂等键的一部分,因为订单号本身就是业务唯一号,这比单纯对整个参数做 MD5 更稳定、更可解释。
7. 启动类
package com.example.demo;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class DemoApplication {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
逐步验证清单
项目启动后,可以这样验证。
第一步:发起第一次请求
curl -X POST 'http://localhost:8080/order/submit' \
-H 'Content-Type: application/json' \
-H 'X-User-Id: 10001' \
-d '{
"orderNo":"ORD202403010001",
"productCode":"P1001",
"count":1
}'
正常会返回:
{
"code": 200,
"message": "下单成功",
"orderNo": "ORD202403010001"
}
第二步:在短时间内重复提交相同请求
再次立即执行相同命令,会返回:
{
"code": 409,
"message": "请求重复,请稍后再试"
}
第三步:查看 Redis 中的 Key
redis-cli keys "submitOrder:*"
你会看到类似:
submitOrder:/order/submit:10001:ORD202403010001
再深入一点:这个方案到底拦住了什么
很多人实现完以后,会误以为“这就万无一失了”。其实不是。
这个方案主要拦住的是:
- 短时间内重复调用同一接口
- 同一用户同一业务键的并发请求
- 多实例部署下的重复提交
但它并不能完全替代:
- 数据库唯一约束
- 业务状态机控制
- 分布式事务
- 消息消费幂等表
所以更准确的理解应该是:
Redis 幂等控制是接口层的一道高效前置防线,不是业务一致性的唯一保障。
状态图:幂等键的生命周期
stateDiagram-v2
[*] --> NotExists
NotExists --> Locked: 首次请求 SET NX EX 成功
NotExists --> Rejected: 并发请求 SET NX EX 失败
Locked --> Expired: TTL 到期
Expired --> NotExists
Rejected --> [*]
常见坑与排查
这一部分我建议你认真看,真正上线时,坑基本都在这里。
1. 用 get + set 替代 setIfAbsent
错误写法:
if (redisTemplate.opsForValue().get(key) == null) {
redisTemplate.opsForValue().set(key, "1", 10, TimeUnit.SECONDS);
}
问题是:并发下不原子,会失效。
正确做法:必须使用原子 setIfAbsent。
2. Key 设计过粗或过细
过粗
如果只用接口路径作为 Key:
idempotent:/order/submit
那所有用户都会互相影响,一个人提交,别人也被拦住。
过细
如果把时间戳、随机数也拼进去:
idempotent:/order/submit:10001:1710000000
那每次请求都不一样,等于没做幂等。
建议:Key 至少包含“接口 + 用户 + 业务唯一标识”。
3. 幂等 TTL 设太短
比如业务要跑 5 秒,但你只给 Redis key 设了 2 秒过期。
那么第一次请求还没执行完,key 已经过期,第二次请求又能进来了。
这是非常常见的坑,我当时就踩过:测试环境没问题,生产因为某个下游接口变慢,重复单就出现了。
建议:TTL 要大于业务最大处理时长,并留冗余。
4. 异常后要不要删 Key
这是一个非常关键的问题,没有标准答案,要看业务。
方案 A:不删除 Key
优点:
- 能挡住短时间重复请求
- 逻辑简单
缺点:
- 如果第一次请求因为系统异常失败,后续重试也会被挡住,直到 key 过期
方案 B:异常时删除 Key
优点:
- 失败后允许客户端重试
缺点:
- 如果业务已经部分执行,再删 key,可能导致重复处理
所以我的建议是:
- 资金、订单、库存类核心业务:优先依赖业务唯一号 + 数据库唯一约束,不轻易删除 key
- 纯防重复点击类接口:可以在异常时删除 key,提升可重试性
如果你想加“异常删除 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, method, idempotent);
String value = UUID.randomUUID().toString();
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(
key,
value,
idempotent.timeout(),
idempotent.timeUnit()
);
if (!Boolean.TRUE.equals(success)) {
throw new RepeatSubmitException("请求重复,请稍后再试");
}
try {
return joinPoint.proceed();
} catch (Throwable e) {
stringRedisTemplate.delete(key);
throw e;
}
}
但请注意,这不是万能模板,要根据业务判断。
5. SpEL 表达式拿不到参数
例如你写了:
@Idempotent(key = "#request.orderNo")
但方法参数名实际不是 request,或者编译后参数名丢失,就会解析失败。
排查方向:
- 确认方法参数名和 SpEL 一致
- 确认是否启用了参数名保留
- 或者直接使用整个参数摘要作为兜底方案
6. AOP 不生效
典型原因:
- 没引入
spring-boot-starter-aop - 注解加在
private方法上 - 同类内部调用,没经过 Spring 代理
- 切点表达式写错
排查建议:
- 看项目启动日志里是否创建了切面 Bean
- 在切面里打日志确认是否进入
- 把注解先加在 Controller 的
public方法上验证
7. Redis 序列化与 Key 可读性差
如果用了默认 RedisTemplate<Object, Object>,可能出现 key 乱码,不方便排查。
建议:幂等场景优先用 StringRedisTemplate。
安全/性能最佳实践
这一部分是“从能跑到能上线”的关键。
1. 不要只靠前端防重
前端按钮置灰只是体验优化,不是安全保证。
任何核心接口都应该在服务端做幂等。
2. 核心业务一定要有业务唯一号
比如:
- 支付单号
- 订单号
- 流水号
- 请求号
如果没有业务唯一号,你只能退而求其次用“用户 + 参数摘要”,但稳定性和可解释性会差很多。
3. Redis 幂等要和数据库唯一约束配合使用
这是我最推荐的组合:
- 接口层:Redis 快速拦截重复请求
- 持久层:数据库唯一索引做最终兜底
例如订单表可以加唯一索引:
CREATE UNIQUE INDEX uk_order_no ON t_order(order_no);
这样即使 Redis 因为异常、过期、网络问题失效,数据库也能阻止重复落库。
4. TTL 不要无限长
TTL 太短会放过重复请求,太长会影响失败后的重试体验。
经验上:
- 防重复点击:5~30 秒
- 下单/支付提交:30~120 秒
- 更长业务链路:结合业务耗时评估
5. Key 中不要直接放敏感信息
不要把手机号、身份证、银行卡号原样拼进 Redis key。
如果必须参与唯一性计算,建议做哈希摘要。
6. 给幂等失败打监控日志
建议至少记录:
- 请求 URI
- 用户 ID
- 幂等 key
- 请求参数摘要
- 拦截时间
示例:
log.warn("repeat submit intercepted, uri={}, userId={}, key={}", uri, userId, key);
线上遇到“用户说自己没点两次”,这个日志非常有用。
7. 分清“防重复提交”和“处理结果复用”
本文方案主要是阻止重复请求进入。
但有些高级场景需要的是:
- 第一次请求正在处理中,后续相同请求不报错,而是返回“处理中”
- 第一次处理成功后,后续相同请求直接返回第一次结果
这属于更完整的“请求幂等记录”方案,通常要在 Redis 或数据库中维护:
- 请求状态:processing / success / fail
- 响应结果快照
这个复杂度会高一层,适合支付、开放平台 API 这类场景。
方案边界与适用场景
适合:
- 表单重复提交
- 下单接口
- 支付确认接口
- 券发放接口
- 有明确业务唯一号的写操作接口
不太适合直接照搬的场景:
- 超长事务接口
- 需要返回历史处理结果的开放 API
- 强一致资金清算场景
- MQ 消费端幂等(更常见是业务表/去重表方案)
如果是消息消费幂等,建议使用:
- 消息唯一 ID
- 消费记录表
- 业务表唯一索引
- 或 Redis + 持久化去重表组合
一个更稳妥的落地建议
如果你正在做生产系统,我建议按这个顺序上:
- 先定义业务唯一号
- 数据库加唯一索引
- 接口层加 Redis 幂等拦截
- 日志、监控、告警补齐
- 对关键链路评估异常重试策略
这套组合比单独依赖某一层稳得多。
总结
这篇文章我们实现了一套基于 Spring Boot + Redis + AOP 的接口幂等方案,核心点可以归纳为几句话:
- 幂等的本质是:相同业务请求重复提交,只处理一次
- Redis 的关键是:使用原子
setIfAbsent+ 过期时间 - AOP 的价值是:把通用逻辑统一收口,减少重复代码
- Key 设计要抓住三件事:接口、用户、业务唯一标识
- 真正上线时,Redis 幂等不是唯一保障,数据库唯一约束仍然必不可少
如果你只是想解决“用户狂点提交按钮”的问题,这套方案已经足够实用。
如果你在做支付、订单这类强业务场景,请一定记住一句话:
接口幂等是前置拦截,业务唯一约束才是最终底线。
只要把这个边界想清楚,这套方案就能用得很稳。