Spring Boot 中基于 Redis 与 AOP 实现接口幂等性的实战方案
接口幂等性这个话题,很多人第一次接触时会觉得“是不是重复点一次按钮而已”。但真到了业务里,你会发现它一点都不小:前端重复提交、用户网络抖动、网关重试、消息重复投递,都会让同一个请求被执行多次。
如果接口刚好是“创建订单”“发券”“扣库存”“发起支付”这类写操作,重复执行一次,后果往往不是多一条日志,而是直接出事故。
这篇文章我带你从一个能跑、能落地、易扩展的角度,基于 Spring Boot + Redis + AOP 实现一套接口幂等方案。重点不是讲概念,而是把它真正接进项目里。
背景与问题
什么是接口幂等性
简单说:
同一个请求,无论被调用一次还是多次,最终结果应当一致。
比如:
- 创建订单:同一笔请求只能生成一个订单
- 提交表单:重复点击“提交”不能插入多条记录
- 发起支付:不能重复扣款
- 发优惠券:不能重复发放
但要注意,“结果一致”不代表“每次都返回完全一样的响应内容”。
更准确一点,幂等关注的是业务状态不能被重复修改。
为什么常见接口会重复调用
现实里重复调用非常常见:
- 用户连续点击按钮
- 前端超时后自动重试
- Nginx / 网关 / SDK 重试
- 消息队列重复投递
- 客户端断网重连再次提交
- 移动端“返回再点一次”
如果服务端没有防护,一个正常接口很容易变成“概率型 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 步:
- 给需要幂等的接口加注解
- AOP 在方法执行前进行拦截
- 根据“用户标识 + 路径 + 业务参数”等生成唯一 key
- 用 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 记录:
PROCESSINGSUCCESSFAIL
这样更灵活,但实现复杂一些。
本文先给你一个够用的入门版:
先用“请求占位 + 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 切面
这是核心部分。
它做三件事:
- 读取注解参数
- 生成幂等 key
- 调 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 一定要结合业务设计,优先级一般是:
- 业务唯一号
- 客户端幂等号
- 用户 + 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. 对关键业务使用“双保险”
以订单或支付为例,推荐组合策略:
- AOP + Redis 拦截短时间重复请求
- 数据库唯一索引保证最终唯一
- 业务状态机保证流程正确
这是更稳的生产思路。
与其他方案的取舍
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 前端按钮置灰 | 简单 | 防不了重试和恶意请求 | 基础体验优化 |
| 数据库唯一索引 | 最终一致强 | 侵入业务表设计 | 明确唯一业务键 |
| 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 设置比代码本身更重要
如果你准备在项目里真正落地,我建议按这个优先顺序来:
- 先给关键写接口加
@Idempotent - key 优先使用业务唯一号或客户端幂等号
- 普通接口用 Redis TTL 拦截短时重复提交
- 核心交易接口叠加数据库唯一约束和状态机
- 把“异常是否删 key、Redis 不可用如何处理”写成明确规范
最后一句实战建议:
幂等不是“加个注解就完事”,而是“注解 + key 设计 + 失败策略 + 数据兜底”一起成立,方案才算真正可靠。
如果你先从本文这版 demo 起步,已经足够解决项目里 70% 以上的重复提交问题。剩下那 30%,通常就在业务唯一号、状态流转和异常补偿里。