Spring Boot + Redisson 实现接口幂等性
前言
在后端系统中,很多接口并不是「调用一次就一定只执行一次」。
例如用户连续点击提交按钮、前端请求超时后自动重试、消息队列重复投递、第三方平台回调多次通知,都可能让同一个业务动作被执行多次。如果接口没有做好幂等控制,就可能出现重复下单、重复扣款、重复发券、重复创建任务等问题。
本文记录一种在 Spring Boot 项目中基于 Redis 和 Redisson 实现接口幂等性的方案。它适合用在提交类接口、回调接口、异步任务消费等场景中,核心目标是:同一个业务请求在一段时间内只允许成功处理一次。
一、什么是幂等性?
幂等性指的是:同一个操作执行一次和执行多次,对系统产生的最终影响一致。
举个例子:
-
查询用户信息:天然幂等,因为多查几次不会修改数据
-
修改用户昵称为固定值:通常幂等,因为多次修改后的结果一致
-
创建订单:默认不幂等,因为多次调用可能创建多条订单
-
扣减库存:默认不幂等,因为多次调用可能重复扣减
所以,幂等性重点处理的不是查询接口,而是会改变系统状态的接口。
二、为什么需要接口幂等?
实际项目中,重复请求很常见。
常见来源有以下几类:
-
用户重复点击
前端按钮没有禁用,或者用户网络较慢时连续点击提交,导致同一个请求被发送多次。
-
网络超时重试
客户端没有收到响应,不代表服务端没有处理成功。此时如果客户端重试,服务端可能再次执行同一业务逻辑。
-
MQ 重复消费
消息队列通常保证「至少一次」投递,消费者处理成功但提交 offset 失败时,消息可能再次被消费。
-
第三方回调重复通知
支付、物流、风控等第三方平台为了保证通知可靠,可能会重复推送同一事件。
如果这些入口没有幂等控制,问题通常不会立刻暴露,而是在高并发、网络波动或外部系统异常时集中出现。
三、幂等性的常见实现方式
接口幂等没有唯一方案,需要根据业务场景选择合适的落点。
3.1 数据库唯一索引
通过业务唯一键建立唯一索引,例如订单号、支付流水号、请求流水号。
优点是强一致,最终兜底能力强。
缺点是异常通常发生在数据库写入阶段,无法提前阻止重复请求进入业务逻辑。
3.2 状态机控制
通过业务状态限制重复流转,例如订单只能从 WAIT_PAY 变为 PAID,已经支付的订单不能再次支付。
优点是贴合业务语义。
缺点是每个业务都要单独设计状态规则,无法作为通用接口防重方案。
3.3 Token 机制
提交前先申请一次性 Token,提交时携带 Token,服务端校验并删除。
优点是适合表单提交。
缺点是调用链路多一步,对纯后端接口、MQ 消费和第三方回调不够自然。
3.4 Redis 防重 Key
根据请求参数或业务唯一标识生成幂等 Key,通过 Redis 的原子写入能力控制重复请求。
优点是实现简单、性能高、适合分布式部署。
缺点是需要合理设计 Key、过期时间和异常释放策略。
本文采用 Redisson 来封装 Redis 操作,实现一个通用的接口幂等注解。
四、实现思路
整体流程如下:
-
在需要防重的接口或方法上添加 @Idempotent
-
AOP 拦截方法调用
-
根据注解配置和请求参数生成幂等 Key
-
使用 Redisson 的 RBucket.trySet 原子写入 Key
-
写入成功,说明是第一次请求,继续执行业务
-
写入失败,说明请求重复,直接返回异常
-
Key 设置过期时间,避免长期占用 Redis
核心点在于:判断和写入必须是一个原子动作。
如果先 get 再 set,在并发场景下两个请求可能同时判断 Key 不存在,然后都进入业务逻辑。Redis 的 SET key value NX EX seconds 或 Redisson 封装的 trySet 可以避免这个问题。
五、引入 Redisson
在 pom.xml 中加入 Redisson 依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
</dependency>
在 application.yml 中配置 Redis:
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password:
database: 0
如果项目已经使用 Spring Boot 的 Redis 配置,Redisson starter 通常可以直接复用相关连接配置。生产环境中建议根据实际部署方式配置单机、哨兵或集群模式。
六、自定义幂等注解
先定义一个注解,用来标记需要幂等控制的方法。
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 幂等 Key 前缀,用于区分不同业务场景。
*/
String prefix() default "idempotent";
/**
* 支持 SpEL 表达式,例如:#request.orderNo。
*/
String key();
/**
* Key 过期时间。
*/
long expire() default 10;
TimeUnit timeUnit() default TimeUnit.SECONDS;
String message() default "请求处理中,请勿重复提交";
}
这里没有直接把整个请求体作为幂等 Key,是因为请求体可能包含时间戳、随机数等字段,直接计算 Hash 容易导致同一个业务请求生成不同 Key。
更推荐使用业务唯一标识,例如订单号、支付流水号、任务编号、消息 ID。
七、实现 AOP 拦截
接下来通过 AOP 在方法执行前做幂等校验。
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.redisson.api.RBucket;
import org.redisson.api.RedissonClient;
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 org.springframework.stereotype.Component;
import java.lang.reflect.Method;
@Aspect
@Component
public class IdempotentAspect {
private final RedissonClient redissonClient;
private final ExpressionParser expressionParser = new SpelExpressionParser();
private final DefaultParameterNameDiscoverer parameterNameDiscoverer =
new DefaultParameterNameDiscoverer();
public IdempotentAspect(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
String key = buildKey(joinPoint, idempotent);
RBucket<String> bucket = redissonClient.getBucket(key);
boolean success = bucket.trySet(
"1",
idempotent.expire(),
idempotent.timeUnit()
);
if (!success) {
throw new IllegalStateException(idempotent.message());
}
return joinPoint.proceed();
}
private String buildKey(ProceedingJoinPoint joinPoint, Idempotent idempotent) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Object[] args = joinPoint.getArgs();
String[] parameterNames = parameterNameDiscoverer.getParameterNames(method);
StandardEvaluationContext context = new StandardEvaluationContext();
if (parameterNames != null) {
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
}
String bizKey = expressionParser
.parseExpression(idempotent.key())
.getValue(context, String.class);
return idempotent.prefix() + ":" + bizKey;
}
}
这段代码有两个关键点:
-
trySet 负责原子写入,只有第一个请求能成功
-
SpEL 负责从方法参数中提取业务唯一标识,让注解更通用
八、在业务接口中使用
假设有一个创建订单接口:
public class CreateOrderRequest {
private String requestNo;
private Long userId;
private Long productId;
private Integer quantity;
public String getRequestNo() {
return requestNo;
}
public void setRequestNo(String requestNo) {
this.requestNo = requestNo;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getProductId() {
return productId;
}
public void setProductId(Long productId) {
this.productId = productId;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
}
在 Service 方法上添加注解:
import org.springframework.stereotype.Service;
@Service
public class OrderService {
@Idempotent(
prefix = "order:create",
key = "#request.requestNo",
expire = 30,
message = "订单正在创建中,请勿重复提交"
)
public Long createOrder(CreateOrderRequest request) {
// 1. 校验商品库存
// 2. 创建订单
// 3. 扣减库存
// 4. 返回订单 ID
return 10001L;
}
}
Controller 调用:
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public Long createOrder(@RequestBody CreateOrderRequest request) {
return orderService.createOrder(request);
}
}
前端或调用方需要保证 requestNo 在同一次业务提交中唯一且稳定。比如进入下单页时生成一个请求号,重复点击提交按钮时仍然使用同一个请求号。
九、异常释放要不要做?
这是幂等实现里很容易忽略的问题。
上面的方案中,Key 写入成功后,无论业务执行成功还是失败,都会等待过期时间自动释放。这样做比较保守,适合「请求已经进入处理流程,就不希望短时间内重复进入」的场景。
但如果业务参数校验失败,是否应该删除 Key?
可以按下面的规则判断:
-
参数格式错误:可以释放,因为这类请求没有真正进入业务处理
-
下游超时异常:不建议立即释放,因为服务端可能已经执行了一部分逻辑
-
数据库唯一键冲突:不需要释放,因为这本身说明请求可能已经处理过
-
业务明确失败且无副作用:可以释放,但要确保没有写库、发消息、调用第三方等副作用
如果确实需要在异常时释放,可以在注解中增加配置:
boolean releaseOnException() default false;
然后在 AOP 中根据异常类型决定是否删除 Key。但默认不建议一刀切删除,否则在网络抖动时可能重新放入重复请求。
十、幂等 Key 如何设计?
幂等 Key 设计不好,接口防重就会失效。
推荐原则:
-
使用业务唯一标识
例如订单请求号、支付流水号、第三方事件 ID、MQ messageId。
-
不要使用纯用户 ID
如果 Key 只使用 userId,同一个用户短时间内只能提交一个请求,会误伤正常操作。
-
不要直接使用完整请求 JSON
请求体中如果包含时间戳、签名、随机数,同一个业务动作可能生成不同 Key。
-
加上业务前缀
不同业务之间要隔离,例如 order:create:xxxpay:notify:xxx。
-
设置合理过期时间
过短无法覆盖重试窗口,过长会影响用户再次提交。
对于创建订单这类场景,推荐 Key:
order:create:{requestNo}
对于支付回调这类场景,推荐 Key:
pay:notify:{tradeNo}
对于 MQ 消费这类场景,推荐 Key:
mq:consume:{topic}:{messageId}
十一、接口幂等和分布式锁的区别
很多时候,接口幂等会和分布式锁混在一起使用,但它们解决的问题不完全一样。
分布式锁更关注「同一时刻只能有一个线程处理共享资源」,例如扣减同一个商品库存。
接口幂等更关注「同一个业务请求不能重复处理」,例如同一个订单请求号不能创建两次订单。
简单理解:
-
分布式锁解决并发互斥问题
-
接口幂等解决重复请求问题
在复杂业务中,两者可以同时使用。
例如创建订单时,可以先用幂等 Key 防止同一个请求重复提交,再用分布式锁保护同一个商品库存的并发扣减。前者按请求维度控制,后者按资源维度控制。
十二、生产环境注意事项
12.1 数据库唯一索引必须保留
Redis 防重属于前置拦截,不能替代数据库约束。
原因很简单:Redis 可能出现 Key 过期、误删、缓存不可用等情况。真正的业务唯一性仍然应该由数据库唯一索引兜底。
例如订单表可以增加唯一索引:
CREATE UNIQUE INDEX uk_order_request_no ON t_order(request_no);
12.2 过期时间不要拍脑袋
过期时间应该覆盖正常请求处理时间和客户端重试窗口。
如果接口平均 500ms 完成,但调用方可能在 10 秒内重试,那么过期时间设置为 1 秒就没有意义。
建议先从 30 秒或 60 秒开始,再结合业务链路耗时、重试策略和用户体验调整。
12.3 异常响应要统一
重复请求不应该直接抛出一段难懂的 Java 异常。
建议结合全局异常处理返回明确响应:
{
"code": "REPEAT_SUBMIT",
"message": "请求处理中,请勿重复提交"
}
这样前端可以识别并给出友好提示。
12.4 不要把幂等做成万能逻辑
幂等只解决重复请求,不解决业务并发的所有问题。
例如库存扣减仍然需要考虑行锁、乐观锁、分布式锁或数据库原子更新;支付成功回调仍然需要状态机校验;消息消费仍然需要消费记录表或业务唯一索引兜底。
十三、总结
本文基于 Spring Boot、Redisson 和 AOP 实现了一个通用的接口幂等注解。
整体思路并不复杂:
-
通过注解声明幂等规则
-
通过 SpEL 提取业务唯一标识
-
通过 Redis 原子写入防止重复请求
-
通过过期时间自动释放幂等 Key
-
通过数据库唯一索引做最终兜底
在真实项目中,接口幂等不要只停留在工具层,更要结合业务语义设计唯一标识、状态流转和异常处理策略。
一个靠谱的幂等方案,应该既能挡住重复提交,也不会误伤正常请求;既能提升接口稳定性,也能在极端情况下由数据库约束兜底。