Spring Boot 4 防重复提交:从接口幂等到 Redis 分布式锁的完整实践

在实际业务系统中,"重复提交"是一个非常常见但又容易被低估的问题。

用户在页面上连续点击提交按钮、浏览器刷新导致表单再次提交、网络抖动触发前端重试、网关或客户端超时后重复请求、消息消费重复投递......这些场景都可能让同一个业务操作被执行多次。

对于普通查询接口来说,重复请求通常问题不大;但如果是下单、支付、退款、创建订单、发放优惠券、扣减库存、提交审批等接口,一旦没有做好防重复提交,就可能造成严重的业务问题。

一、什么是重复提交?

重复提交指的是:同一个用户或客户端,在很短时间内,对同一个业务接口发起了多次相同或等价的请求,导致服务端重复执行了同一业务逻辑。

常见表现包括:

  • 用户连续点击"提交"按钮
  • 页面卡顿后用户多次点击
  • 浏览器刷新导致表单重复提交
  • 前端请求超时后自动重试
  • 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,服务端校验成功后立即删除。

流程如下:

  1. 用户打开表单页面
  2. 服务端生成 Token
  3. Token 存入 Redis 或 Session
  4. 前端提交时携带 Token
  5. 服务端校验 Token 是否存在
  6. 校验成功后删除 Token
  7. 后续重复提交因为 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

这种方式统一实现防重复提交。

整体思路如下:

  1. 定义一个 @RepeatSubmit 注解
  2. 在需要防重复的接口方法上标记该注解
  3. AOP 拦截被标记的方法
  4. 根据用户标识、接口路径、请求参数生成 Redis Key
  5. 使用 Redis SET NX EX 尝试写入 Key
  6. 写入成功则继续执行接口
  7. 写入失败则拒绝重复提交
  8. 请求执行完成后,可选择删除 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 就不会依赖整个请求体,而是只使用核心业务字段。

如果接口里有 requestNoorderNobizNo 这类唯一业务号,更推荐直接使用这些字段作为幂等依据。

十四、真正可靠的订单创建幂等设计

对于订单创建这种核心场景,仅靠 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 短时间防重已经足够好用;对于订单、支付、退款、库存这类核心链路,必须结合业务幂等号、数据库唯一约束和状态流转,才能真正保证系统在高并发和分布式环境下的正确性。

相关推荐
xlxxy_1 小时前
外部系统调用SAP接口遇到的一些报错
开发语言·数据库·sap·abap
八角.。1 小时前
方法参数与Debug按键
java·开发语言·jvm
BerryS3N1 小时前
Java在人工智能与大模型时代的深度演进:从工程落地、高性能计算到企业级Agent与RAG架构实战指南
java·人工智能·架构
麦聪聊数据1 小时前
连锁零售全域数据管控(下):API 化服务输出,释放全域数据业务价值
数据库
蔬菜_2 小时前
前端转全栈-day2(修饰符)
java
fīɡЙtīиɡ ℡2 小时前
怎么是用JWT
java·状态模式
就改了2 小时前
Docker安装并运行Redis
redis·docker·容器
奇树谦2 小时前
NAS + 对象存储 + LMDB/HDF5:海量小文件存储最佳实践
数据库
唐青枫2 小时前
Java JCommander 实战详解:用注解解析命令行参数
java