在实际业务系统中,"重复提交"是一个非常常见但又容易被低估的问题。
用户在页面上连续点击提交按钮、浏览器刷新导致表单再次提交、网络抖动触发前端重试、网关或客户端超时后重复请求、消息消费重复投递......这些场景都可能让同一个业务操作被执行多次。
对于普通查询接口来说,重复请求通常问题不大;但如果是下单、支付、退款、创建订单、发放优惠券、扣减库存、提交审批等接口,一旦没有做好防重复提交,就可能造成严重的业务问题。
一、什么是重复提交?
重复提交指的是:同一个用户或客户端,在很短时间内,对同一个业务接口发起了多次相同或等价的请求,导致服务端重复执行了同一业务逻辑。
常见表现包括:
- 用户连续点击"提交"按钮
- 页面卡顿后用户多次点击
- 浏览器刷新导致表单重复提交
- 前端请求超时后自动重试
- App 网络切换导致请求重复发送
- 网关、负载均衡或客户端 SDK 重试
- 消息队列重复投递
- 分布式环境下多个实例同时处理同一请求
例如一个创建订单接口:
java
@PostMapping("/orders")
public OrderCreateResponse createOrder(@RequestBody OrderCreateRequest request) {
return orderService.createOrder(request);
}
如果用户快速点击两次提交按钮,后端可能收到两次请求。若没有任何限制,系统可能会创建两笔订单。
二、防重复提交和接口幂等的区别
很多人会把"防重复提交"和"接口幂等"混为一谈,它们确实有关联,但侧重点不同。
1. 防重复提交
防重复提交更关注的是:
在短时间内阻止相同请求被重复处理。
它通常用于限制用户的连续点击、短时间重复请求等场景。
比如:同一个用户 5 秒内只能提交一次订单创建请求。
2. 接口幂等
接口幂等更关注的是:
同一个业务请求无论执行一次还是多次,最终结果都一致。
比如支付回调接口可能会收到多次回调,但系统只能把订单状态从"待支付"更新为"已支付"一次。
3. 二者关系
防重复提交通常是幂等设计的一部分,但不能完全替代幂等。
更严谨的设计应该是:
- 前端防抖,减少无意义请求
- 后端防重复提交,拦截短时间重复请求
- 数据库唯一约束,兜底防止重复数据
- 业务状态机,保证核心操作幂等
- 分布式锁或 Token 机制,控制并发执行
三、常见防重复提交方案
1. 前端按钮置灰
用户点击提交按钮后,立即将按钮置为不可点击状态。
优点:
- 实现简单
- 用户体验较好
- 可以减少大部分重复点击
缺点:
- 无法防止接口被直接调用
- 无法解决网络重试、恶意请求、多个客户端同时请求
- 不能作为后端安全保障
前端只能减少重复请求,不能作为最终防线。
2. Session Token 机制
进入表单页面时,服务端生成一个一次性 Token 返回给前端。提交时携带该 Token,服务端校验成功后立即删除。
流程如下:
- 用户打开表单页面
- 服务端生成 Token
- Token 存入 Redis 或 Session
- 前端提交时携带 Token
- 服务端校验 Token 是否存在
- 校验成功后删除 Token
- 后续重复提交因为 Token 不存在而被拒绝
优点:
- 控制粒度较强
- 适合表单提交场景
缺点:
- 前后端交互成本较高
- 每次提交前都需要获取 Token
- 对开放 API、移动端接口不一定方便
3. Redis 分布式锁
基于 Redis 的 SET NX EX 能力,在请求进入业务逻辑前生成一个唯一 Key。如果 Key 不存在,则写入并继续执行;如果 Key 已存在,则说明请求正在处理或刚处理过,直接拒绝。
优点:
- 适合分布式部署
- 实现通用
- 对业务代码侵入较低
- 可以结合注解和 AOP 统一处理
缺点:
- 需要 Redis
- Key 设计需要谨慎
- 锁过期时间需要合理设置
4. 数据库唯一约束
对于订单号、支付流水号、业务请求号等关键字段,可以增加唯一索引。
例如:
sql
CREATE UNIQUE INDEX uk_order_request_no ON t_order(request_no);
优点:
- 兜底能力强
- 不依赖应用层逻辑
- 对核心数据一致性非常重要
缺点:
- 只能解决部分场景
- 需要有天然唯一业务键
- 异常处理要做得比较友好
5. 业务状态机控制
对支付、退款、审批等流程型业务,应通过状态流转控制重复执行。
例如订单状态流转:
待支付 -> 已支付 -> 已发货 -> 已完成
支付回调来了多次时,只有订单处于"待支付"时才允许更新为"已支付"。
类似 SQL:
sql
UPDATE t_order
SET status = 'PAID'
WHERE order_no = ?
AND status = 'WAIT_PAY';
如果影响行数为 0,说明订单已经处理过或状态不允许流转。
四、Spring Boot 4 中推荐的通用实现思路
在 Spring Boot 4 项目中,可以采用:
自定义注解 + Spring AOP + Redis
这种方式统一实现防重复提交。
整体思路如下:
- 定义一个
@RepeatSubmit注解 - 在需要防重复的接口方法上标记该注解
- AOP 拦截被标记的方法
- 根据用户标识、接口路径、请求参数生成 Redis Key
- 使用 Redis
SET NX EX尝试写入 Key - 写入成功则继续执行接口
- 写入失败则拒绝重复提交
- 请求执行完成后,可选择删除 Key 或等待自动过期
五、引入依赖
以 Maven 为例,Spring Boot 4 项目通常需要 Web、AOP 和 Redis 相关依赖。
XML
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</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>
</dependencies>
说明一下,Spring Boot 4 对 Web Starter 做了更清晰的拆分。如果使用传统 Servlet MVC 应用,可以使用 spring-boot-starter-webmvc。如果你的项目仍使用兼容或迁移阶段的配置,也可以根据实际版本依赖进行调整。
六、定义防重复提交注解
java
package com.example.demo.common.repeat;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.time.temporal.ChronoUnit;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface RepeatSubmit {
long interval() default 5;
ChronoUnit timeUnit() default ChronoUnit.SECONDS;
String message() default "请勿重复提交";
}
这个注解包含三个属性:
interval:防重复时间间隔timeUnit:时间单位message:重复提交时返回的提示信息
使用方式如下:
java
@RepeatSubmit(interval = 3, message = "订单正在创建中,请勿重复提交")
@PostMapping("/orders")
public OrderCreateResponse createOrder(@RequestBody OrderCreateRequest request) {
return orderService.createOrder(request);
}
七、实现 Redis 防重复提交切面
下面是核心实现。
java
package com.example.demo.common.repeat;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.HexFormat;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Aspect
@Component
public class RepeatSubmitAspect {
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
public RepeatSubmitAspect(StringRedisTemplate stringRedisTemplate, ObjectMapper objectMapper) {
this.stringRedisTemplate = stringRedisTemplate;
this.objectMapper = objectMapper;
}
@Around("@annotation(repeatSubmit)")
public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
HttpServletRequest request = currentRequest();
String key = buildKey(request, joinPoint);
Duration duration = Duration.of(repeatSubmit.interval(), repeatSubmit.timeUnit());
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", duration);
if (!Boolean.TRUE.equals(success)) {
throw new RepeatSubmitException(repeatSubmit.message());
}
return joinPoint.proceed();
}
private HttpServletRequest currentRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
throw new IllegalStateException("当前上下文不是 HTTP 请求");
}
return attributes.getRequest();
}
private String buildKey(HttpServletRequest request, ProceedingJoinPoint joinPoint) throws Exception {
String userId = resolveUserId(request);
String uri = request.getRequestURI();
String method = request.getMethod();
String params = objectMapper.writeValueAsString(joinPoint.getArgs());
String fingerprint = sha256(method + ":" + uri + ":" + userId + ":" + params);
return "repeat-submit:" + fingerprint;
}
private String resolveUserId(HttpServletRequest request) {
String userId = request.getHeader("X-User-Id");
if (userId != null && !userId.isBlank()) {
return userId;
}
return request.getRemoteAddr();
}
private String sha256(String value) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(bytes);
}
}
这段代码的关键点在于:
- 使用
@Around("@annotation(repeatSubmit)")拦截标记了@RepeatSubmit的方法 - 使用请求方法、URI、用户标识、请求参数生成唯一 Key
- 使用 Redis
setIfAbsent实现原子写入 - 设置过期时间,避免 Key 永久存在
- 如果写入失败,说明同一请求在限定时间内已经提交过
八、自定义异常与统一响应
可以定义一个业务异常:
java
package com.example.demo.common.repeat;
public class RepeatSubmitException extends RuntimeException {
public RepeatSubmitException(String message) {
super(message);
}
}
然后通过全局异常处理器统一返回响应:
java
package com.example.demo.common.web;
import com.example.demo.common.repeat.RepeatSubmitException;
import java.time.Instant;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RepeatSubmitException.class)
public ErrorResponse handleRepeatSubmitException(RepeatSubmitException exception) {
return new ErrorResponse(
HttpStatus.TOO_MANY_REQUESTS.value(),
exception.getMessage(),
Instant.now()
);
}
}
响应对象示例:
java
package com.example.demo.common.web;
import java.time.Instant;
public record ErrorResponse(
int code,
String message,
Instant timestamp
) {
}
实际项目中,也可以复用已有的统一响应结构,例如 Result<T>、ApiResponse<T> 等。
九、接口使用示例
java
package com.example.demo.order;
import com.example.demo.common.repeat.RepeatSubmit;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@RepeatSubmit(interval = 5, message = "订单正在提交,请不要重复操作")
@PostMapping("/orders")
public OrderCreateResponse createOrder(@RequestBody OrderCreateRequest request) {
return orderService.createOrder(request);
}
}
当同一个用户在 5 秒内重复提交相同参数的 /orders 请求时,后端会直接返回重复提交错误,而不会再次进入订单创建逻辑。
十、Key 设计是核心
防重复提交方案是否可靠,很大程度取决于 Redis Key 的设计。
一个合理的 Key 通常应该包含:Plain Text
业务前缀 + 用户标识 + 接口路径 + 请求方法 + 请求参数摘要
例如:
repeat-submit:{sha256(POST:/orders:userId:requestBody)}
1. 为什么要包含用户标识?
如果不包含用户标识,两个不同用户提交相同请求参数时,可能会互相影响。
例如两个用户都购买同一个商品,如果 Key 只包含接口路径和参数,第二个用户可能被误判为重复提交。
2. 为什么要包含请求参数?
如果不包含请求参数,同一个用户在短时间内提交两个不同订单,也可能被误判为重复提交。
例如用户先购买商品 A,又立即购买商品 B,这两个请求不应该互相阻塞。
3. 为什么要做哈希?
请求参数可能很长,不适合直接拼到 Redis Key 中。使用 SHA-256 可以让 Key 更短、更稳定。
十一、是否应该在接口执行完成后删除 Key?
这是一个很常见的问题。
方案一:执行完成后删除 Key
java
try {
return joinPoint.proceed();
} finally {
stringRedisTemplate.delete(key);
}
优点:
- 不影响用户短时间内再次正常提交
- 只限制并发执行,不限制提交频率
缺点:
- 如果接口执行很快,用户仍然可能在极短时间内再次提交成功
- 更像是"防并发提交",不是"防短时间重复提交"
方案二:不主动删除,等待自动过期
java
return joinPoint.proceed();
优点:
- 能真正限制指定时间窗口内的重复提交
- 适合订单创建、表单提交等场景
缺点:
- 用户如果确实需要再次提交,需要等待过期时间
推荐选择
对于大多数"防重复点击、防重复提交"场景,建议:
不主动删除 Key,等待 Redis 自动过期。
对于"只想防止同一请求并发执行"的场景,可以在 finally 中删除 Key。
十二、Spring Boot 4 下需要注意的点
1. Jakarta 包名
Spring Boot 3 之后已经迁移到 Jakarta EE,Spring Boot 4 继续沿用该方向。因此 Servlet 相关类应使用:
java
import jakarta.servlet.http.HttpServletRequest;
而不是:
java
import javax.servlet.http.HttpServletRequest;
2. Web Starter 选择
传统 Spring MVC 项目中,可优先使用:
XML
<artifactId>spring-boot-starter-webmvc</artifactId>
如果是响应式 WebFlux 项目,则应使用对应的响应式技术栈,AOP 和请求上下文获取方式也需要相应调整。
3. AOT 和 Native Image
如果项目使用 GraalVM Native Image 或 AOT 优化,需要注意反射、序列化、代理等配置。AOP、Jackson 序列化参数对象时,可能需要额外的运行时提示配置。
4. 参数序列化风险
示例中使用:
java
objectMapper.writeValueAsString(joinPoint.getArgs())
在真实项目中要注意:
- 参数中可能包含
HttpServletRequest - 参数中可能包含文件上传对象
- 参数中可能包含不可序列化对象
- 参数中可能包含敏感字段
更稳妥的做法是只提取业务请求 DTO,或提供 SpEL 表达式指定参与 Key 计算的字段。
十三、进阶:使用 SpEL 指定防重 Key
在复杂业务中,直接把所有参数序列化并不总是合适。
可以给注解增加一个 key 属性:
java
public @interface RepeatSubmit {
String key() default "";
long interval() default 5;
ChronoUnit timeUnit() default ChronoUnit.SECONDS;
String message() default "请勿重复提交";
}
使用时指定关键字段:
java
@RepeatSubmit(key = "#request.productId + ':' + #request.addressId", interval = 5)
@PostMapping("/orders")
public OrderCreateResponse createOrder(@RequestBody OrderCreateRequest request) {
return orderService.createOrder(request);
}
这样 Key 就不会依赖整个请求体,而是只使用核心业务字段。
如果接口里有 requestNo、orderNo、bizNo 这类唯一业务号,更推荐直接使用这些字段作为幂等依据。
十四、真正可靠的订单创建幂等设计
对于订单创建这种核心场景,仅靠 Redis 防重复提交还不够。
更推荐的完整设计是:
1. 前端生成请求号
前端或服务端提前生成一个 requestNo。
{
"requestNo": "REQ202608040001",
"productId": 1001,
"quantity": 1
}
2. 数据库增加唯一索引
sql
ALTER TABLE t_order ADD UNIQUE KEY uk_request_no (request_no);
3. 后端创建订单时写入 requestNo
java
@Transactional
public OrderCreateResponse createOrder(OrderCreateRequest request) {
Order existingOrder = orderRepository.findByRequestNo(request.requestNo());
if (existingOrder != null) {
return OrderCreateResponse.from(existingOrder);
}
Order order = new Order();
order.setRequestNo(request.requestNo());
order.setProductId(request.productId());
order.setQuantity(request.quantity());
orderRepository.save(order);
return OrderCreateResponse.from(order);
}
4. 捕获唯一索引冲突
即使多个请求同时进入,也可以通过数据库唯一约束兜底。
java
try {
orderRepository.save(order);
} catch (DuplicateKeyException exception) {
Order existingOrder = orderRepository.findByRequestNo(request.requestNo());
return OrderCreateResponse.from(existingOrder);
}
这种方式比单纯的"短时间拦截"更可靠,因为它保证的是业务层面的幂等。
十五、不同场景下的推荐方案
1. 普通表单提交
推荐:
- 前端按钮置灰
- 后端
@RepeatSubmit - Redis 短时间防重
适合场景:
- 用户资料提交
- 工单提交
- 评论发布
- 审批提交
2. 订单创建
推荐:
- 前端防抖
- Redis 防重复提交
- requestNo 幂等号
- 数据库唯一索引
- 事务控制
适合场景:
- 创建订单
- 预约报名
- 资源抢占
- 库存扣减前置流程
3. 支付回调
推荐:
- 回调流水号唯一索引
- 订单状态机
- 乐观锁或条件更新
- 日志记录完整回调内容
适合场景:
- 支付成功通知
- 退款成功通知
- 第三方异步回调
4. 消息消费
推荐:
- 消息 ID 去重表
- Redis 去重缓存
- 数据库唯一索引
- 消费状态记录
适合场景:
- MQ 消费
- 事件驱动架构
- 延迟队列任务
十六、完整实践建议
在生产项目中,可以按照如下层次设计防重复能力:
第一层:前端防抖
第二层:后端注解防重复提交
第三层:Redis 分布式去重
第四层:业务幂等号 requestNo
第五层:数据库唯一索引
第六层:状态机或条件更新
越靠前的层,越偏向用户体验和流量拦截;越靠后的层,越偏向数据一致性兜底。
不要把所有希望都寄托在某一层上。尤其是核心交易链路,一定要有数据库约束或状态机作为最终保障。
十七、总结
Spring Boot 4 中实现防重复提交,推荐采用 自定义注解 + AOP + Redis 的方式,将通用逻辑从业务代码中抽离出来。
核心实现思路是:
- 使用注解标记需要防重复的接口
- 使用 AOP 拦截接口调用
- 根据用户、接口、参数生成唯一 Key
- 使用 Redis 原子操作判断是否重复提交
- 对重复请求直接拦截
- 对核心业务再结合 requestNo、唯一索引、状态机实现真正幂等
防重复提交不是单点技术问题,而是一个系统性设计问题。
对于普通业务,Redis 短时间防重已经足够好用;对于订单、支付、退款、库存这类核心链路,必须结合业务幂等号、数据库唯一约束和状态流转,才能真正保证系统在高并发和分布式环境下的正确性。