Java Web 开发中基于 Spring Boot + Redis 实现接口幂等与重复提交防护实战
在 Java Web 项目里,“重复提交”几乎是每个后端都会遇到的问题:
用户连点两次按钮、前端超时重试、网关重复转发、消息补偿、甚至爬虫误调用,都会让同一个业务请求被执行多次。
如果接口刚好是“创建订单”“扣减库存”“发放优惠券”这类操作,多执行一次,后果往往不是日志多一条那么简单,而是数据错乱、资金风险、库存穿透。
这篇文章我不讲太空泛的概念,而是带你从一个常见的 Spring Boot 项目出发,用 Redis + AOP + 自定义注解 实现一套可运行的接口幂等与重复提交防护方案。你看完可以直接改到自己的项目里。
背景与问题
先明确两个很容易混淆的概念:
- 重复提交防护:主要解决“短时间内同一个请求被提交多次”的问题
- 接口幂等:主要解决“同一个请求执行一次和执行多次,结果应该一致”的问题
它们相关,但不完全一样。
常见重复请求来源
我在项目里最常见的有这几类:
- 用户手抖连点
- 前端请求超时后自动重试
- 移动端弱网导致同一请求重复发送
- 网关、代理层重放
- 消费端补偿或定时任务重复触发
- 分布式环境下,多实例同时处理同一业务
为什么不能只靠前端防抖
很多团队会先做按钮置灰、前端防抖,这当然有用,但只能算第一层。
因为:
- 前端逻辑可以被绕过
- 抓包工具可以直接重放请求
- 弱网重试不受前端按钮控制
- 分布式系统里,真正的“一次性语义”必须后端兜底
所以,前端防抖可以做,但后端幂等必须有。
核心原理
这类问题常见有三种处理思路:
- 数据库唯一约束
- 业务状态机控制
- Redis 分布式防重令牌/幂等键
这篇重点讲第 3 种,因为它实现成本低、性能高,也适合大多数 Web 接口。
方案思路
核心流程很简单:
- 客户端请求到达接口
- 服务端根据“用户 + 接口 + 请求参数”生成一个唯一幂等 Key
- 用 Redis 执行
SET key value NX EX seconds - 如果设置成功,说明是第一次请求,继续执行业务
- 如果设置失败,说明短时间内这个请求已经来过,直接拦截
- 业务执行完成后,根据策略决定是否删除 Key
这里面有两个关键点:
1. 为什么用 SET NX EX
因为它是原子操作:
NX:只有 key 不存在才设置成功EX:设置过期时间,避免死锁/脏 key
这比“先查 Redis 再 set”安全得多。后者在并发下会产生竞态条件。
2. Key 应该怎么设计
幂等 Key 一般要包含:
- 调用人标识:用户 ID、设备 ID、API Key 等
- 接口路径:避免不同接口互相影响
- 业务参数摘要:避免同一用户不同请求被错误拦截
例如:
idempotent:10001:/order/create:8f14e45fceea167a5a36dedd4bea2543
其中最后一段一般是对请求参数做摘要,比如 MD5/SHA-256。
整体流程图
flowchart TD
A[客户端发起请求] --> B[拦截器/AOP 获取注解]
B --> C[提取用户标识 接口路径 请求参数]
C --> D[生成幂等 Key]
D --> E{Redis SET NX EX 成功?}
E -- 是 --> F[执行业务逻辑]
F --> G[返回成功结果]
E -- 否 --> H[拦截请求 返回重复提交提示]
前置知识与环境准备
本文示例环境:
- JDK 8+
- Spring Boot 2.x
- Spring Web
- 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>
</dependencies>
application.yml
spring:
redis:
host: 127.0.0.1
port: 6379
timeout: 3000ms
实战代码(可运行)
下面我们做一个最常见的实现方式:
- 自定义注解
@Idempotent - AOP 切面统一拦截
- Redis 原子写入防重
- Controller 演示订单提交接口
第一步:定义返回结果对象
package com.example.demo.common;
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
public ApiResponse() {
}
public ApiResponse(boolean success, String message, T data) {
this.success = success;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "success", data);
}
public static <T> ApiResponse<T> fail(String message) {
return new ApiResponse<>(false, message, null);
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
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;
public class BizException extends RuntimeException {
public BizException(String message) {
super(message);
}
}
第三步:定义幂等注解
这里支持两个常用参数:
expireSeconds:防重时间窗message:重复请求时的提示文案
package com.example.demo.idempotent;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
int expireSeconds() default 5;
String message() default "请求重复,请稍后再试";
}
第四步: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 tryLock(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);
}
}
第五步:构造幂等 Key
这个步骤很关键。很多文章只写个“按 URL 防重”,实际项目里经常不够。
我们这里把:
- 当前用户
- 请求 URI
- 请求参数 JSON
拼起来做摘要。
package com.example.demo.idempotent;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
@Component
public class IdempotentKeyGenerator {
private final ObjectMapper objectMapper = new ObjectMapper();
public String generate(String userId, HttpServletRequest request, Object[] args) {
String uri = request.getRequestURI();
String argsJson = toJson(args);
String source = userId + ":" + uri + ":" + argsJson;
String md5 = DigestUtils.md5DigestAsHex(source.getBytes(StandardCharsets.UTF_8));
return "idempotent:" + userId + ":" + uri + ":" + md5;
}
private String toJson(Object[] args) {
try {
return objectMapper.writeValueAsString(filterArgs(args));
} catch (Exception e) {
return Arrays.toString(args);
}
}
private Object[] filterArgs(Object[] args) {
return Arrays.stream(args)
.filter(arg -> !(arg instanceof HttpServletRequest))
.filter(arg -> !(arg instanceof javax.servlet.http.HttpServletResponse))
.toArray();
}
}
这里我故意过滤了
HttpServletRequest/Response,因为它们序列化后会很乱,而且没业务意义。
第六步:AOP 切面实现统一拦截
package com.example.demo.idempotent;
import com.example.demo.common.BizException;
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 javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.UUID;
@Component
@Aspect
public class IdempotentAspect {
private final RedisIdempotentSupport redisSupport;
private final IdempotentKeyGenerator keyGenerator;
private final HttpServletRequest request;
public IdempotentAspect(RedisIdempotentSupport redisSupport,
IdempotentKeyGenerator keyGenerator,
HttpServletRequest request) {
this.redisSupport = redisSupport;
this.keyGenerator = keyGenerator;
this.request = request;
}
@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 userId = getCurrentUserId();
String key = keyGenerator.generate(userId, request, joinPoint.getArgs());
String value = UUID.randomUUID().toString();
boolean locked = redisSupport.tryLock(key, value, idempotent.expireSeconds());
if (!locked) {
throw new BizException(idempotent.message());
}
return joinPoint.proceed();
}
private String getCurrentUserId() {
String userId = request.getHeader("X-User-Id");
if (userId == null || userId.trim().isEmpty()) {
userId = "anonymous";
}
return userId;
}
}
这里为什么不在 finally 里删 Key?
这是个非常重要的点。
如果你的目的是“防止短时间内重复点击”,那通常不应该立即删除 Key。
因为你一删掉,用户第二次请求马上又能进来,防重窗口就失效了。
也就是说,这套代码更适合:
- 防连点
- 防短时重复提交
- 防前端重试风暴
如果你追求的是“业务级严格幂等”,比如同一个订单号永远只能创建一次,那么 key 的生命周期和业务状态就要更严格绑定,后面我会讲。
第七步:统一异常处理
package com.example.demo.common;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ApiResponse<Void> handleBizException(BizException e) {
return ApiResponse.fail(e.getMessage());
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception e) {
return ApiResponse.fail("系统异常:" + e.getMessage());
}
}
第八步:编写示例接口
模拟一个创建订单接口。
这里为了演示效果,我让线程 sleep 2 秒。
package com.example.demo.order;
public class OrderCreateRequest {
private String productId;
private Integer quantity;
private Long amount;
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;
}
public Long getAmount() {
return amount;
}
public void setAmount(Long amount) {
this.amount = amount;
}
}
package com.example.demo.order;
import com.example.demo.common.ApiResponse;
import com.example.demo.idempotent.Idempotent;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/orders")
public class OrderController {
@PostMapping("/create")
@Idempotent(expireSeconds = 5, message = "订单正在处理中,请勿重复提交")
public ApiResponse<String> createOrder(@RequestBody OrderCreateRequest request) throws Exception {
Thread.sleep(2000);
String orderNo = "ORD-" + UUID.randomUUID().toString().replace("-", "");
return ApiResponse.success(orderNo);
}
}
请求时序图
sequenceDiagram
participant C as Client
participant A as AOP
participant R as Redis
participant S as OrderService
C->>A: POST /orders/create
A->>R: SET key value NX EX 5
alt 首次请求
R-->>A: OK
A->>S: 执行业务逻辑
S-->>A: 返回订单号
A-->>C: success
else 重复请求
R-->>A: null/false
A-->>C: 请求重复,请稍后再试
end
启动与验证
启动 Redis,然后运行 Spring Boot 项目。
测试请求
curl --location --request POST 'http://localhost:8080/orders/create' \
--header 'Content-Type: application/json' \
--header 'X-User-Id: 1001' \
--data-raw '{
"productId": "P10001",
"quantity": 1,
"amount": 19900
}'
如果你在 5 秒内连续发送两次相同请求:
- 第一次:成功创建订单
- 第二次:返回“订单正在处理中,请勿重复提交”
逐步验证清单
你可以按这个顺序验证,比较稳:
-
同一用户 + 同一参数 + 5 秒内重复提交
预期:第二次被拦截 -
同一用户 + 不同参数
预期:允许通过 -
不同用户 + 相同参数
预期:允许通过 -
同一请求在 5 秒后再次提交
预期:允许通过 -
并发压测同一请求
预期:仅一个成功进入业务,其余被拦截
核心原理再深入一层:防重提交 vs 业务幂等
上面的方案其实更偏“时间窗防重”。
但在实际项目里,我建议你分两层看:
第一层:接口层防重
目标是挡住这些请求:
- 短时间重复点击
- 前端重试
- 网络抖动重放
常用方式:
- Redis
SET NX EX - 时间窗一般 3~10 秒
第二层:业务层幂等
目标是保证业务结果只生效一次,比如:
- 同一个支付流水只能处理一次
- 同一个订单号只能创建一次
- 同一张券不能重复发放
常用方式:
- 业务唯一单号
- 数据库唯一索引
- 状态机流转校验
- 幂等表记录请求号
这两层最好一起上。
只做 Redis 防重,在极端情况下仍可能不够;只做数据库唯一约束,用户体验又会比较差。
更稳妥的业务幂等模型
如果你的业务要求更强,比如“同一个请求号只能执行一次,而且要能返回第一次结果”,推荐引入 Idempotency-Key 模型。
典型流程
- 客户端生成一个唯一请求号
- 请求头带上
Idempotency-Key - 服务端先检查这个 key 是否处理过
- 已处理则直接返回历史结果
- 未处理则加锁执行,并缓存结果
这个模型在支付、下单、回调处理里更常见。
stateDiagram-v2
[*] --> INIT
INIT --> PROCESSING: 收到新请求并加锁
PROCESSING --> SUCCESS: 业务成功并保存结果
PROCESSING --> FAIL: 业务失败并记录状态
SUCCESS --> SUCCESS: 相同请求号再次到达,直接返回历史结果
FAIL --> FAIL: 根据策略决定是否允许重试
常见坑与排查
这一部分很重要,很多方案不是不会写,而是上线后会在细节上翻车。
1. 用 if (exists) return; else set 代替原子操作
这是经典坑。
错误写法:
if (!redisTemplate.hasKey(key)) {
redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.SECONDS);
// 执行业务
}
问题在于:
- 两个并发请求都可能在
hasKey时看到不存在 - 然后都 set 成功前进入业务
- 幂等彻底失效
必须使用原子 setIfAbsent。
2. Key 设计过粗,误伤正常请求
比如只按用户 ID + URL 做 key:
idempotent:1001:/orders/create
那用户连续下两笔不同订单,也会被拦掉。
解决办法:
- 把业务参数摘要带进去
- 或者由前端传唯一业务请求号
3. Key 设计过细,导致拦不住
如果参数里包含这些字段:
- 时间戳
- nonce
- 随机串
- 排序不稳定的 JSON
那么同一个业务请求每次算出来的 key 都不一样,防重等于没做。
解决办法:
- 只提取真正有业务意义的字段参与摘要
- 对 JSON 做字段排序或固定序列化
- 排除随机字段
这个坑我自己踩过一次:前端每次都会带一个 requestTime,结果同样的下单请求怎么都防不住。
4. 过期时间设置不合理
- 过短:业务还没执行完,key 已经失效,重复请求又进来了
- 过长:用户真正想再次提交时被误拦
经验建议:
- 普通表单提交:3~10 秒
- 较慢业务接口:10~30 秒
- 强幂等业务:不要只靠过期时间,要结合业务唯一号
5. 业务失败后是否允许重试
这点没有统一答案,要看业务。
场景 A:失败后允许马上重试
例如:
- 下单前参数校验失败
- 外部接口临时超时
可以考虑在失败时删除 Key,但要谨慎。
场景 B:失败后也不允许立刻重试
例如:
- 支付处理中
- 库存锁定中
- 正在调用不可重入外部系统
这时通常保留 Key 到过期更安全。
建议:按接口做策略区分,不要一刀切。
6. 分布式环境下只用本地锁
单机 synchronized 或 ReentrantLock 在单实例测试里看起来有效,但一上多实例部署就失效。
原因很简单:
- A 机器锁不住 B 机器
- 请求被负载均衡打到不同节点就重复执行了
所以跨实例的防重,至少要 Redis、数据库或分布式协调组件。
7. Redis 序列化和 key 可读性问题
如果你用的是 RedisTemplate<Object, Object>,可能会得到一堆不直观的序列化 key,不利于排查。
更推荐:
StringRedisTemplate- key 统一前缀
- value 记录请求标识或 traceId
例如:
idempotent:1001:/orders/create:ae34c...
排查时一眼就能看懂。
安全/性能最佳实践
幂等不只是“能用”,上线后还要考虑安全性和吞吐量。
1. 优先使用客户端业务请求号
对于关键接口,推荐客户端在请求头中传:
Idempotency-Key: 7d3c7d18-7f74-4f57-9ef6-c0dbbaf2c001
好处:
- 语义清晰
- 避免服务端自己拼接参数的不确定性
- 便于问题追踪和日志串联
2. 服务端要校验用户身份与幂等 key 绑定
不要只看 Idempotency-Key 本身,还要结合:
- 用户 ID
- 租户 ID
- 应用 ID
否则理论上可能出现 key 碰撞或恶意复用。
推荐 key 结构:
idempotent:{tenantId}:{userId}:{api}:{idempotencyKey}
3. 不要把整个大对象都序列化参与摘要
大参数会带来:
- CPU 开销增加
- 网络开销增加
- key 不稳定
建议只挑核心业务字段,比如:
- 商品 ID
- 数量
- 金额
- 业务单号
4. 配合数据库唯一索引兜底
这是我最推荐的组合:
- Redis:负责接口层快速拦截
- 数据库唯一约束:负责最终一致性兜底
例如订单表可以对 biz_order_no 建唯一索引:
create table t_order (
id bigint primary key auto_increment,
biz_order_no varchar(64) not null,
user_id bigint not null,
amount bigint not null,
create_time datetime not null,
unique key uk_biz_order_no (biz_order_no)
);
即使 Redis 因异常失效,数据库仍能挡住重复插入。
5. 日志一定要带幂等 key
建议打印这些字段:
- traceId
- userId
- requestUri
- idempotentKey
- 请求参数摘要
- Redis 返回结果
- 执行耗时
这样线上排查时非常省时间。
6. 注意热点 key 与恶意刷接口
如果某些接口被恶意重复调用,Redis 会承受较高访问压力。可以结合:
- 限流
- 用户/IP 维度风控
- 网关层拦截
- 验签机制
幂等不是安全防护的全部,它只是其中一层。
7. 超关键业务建议保存“请求结果”
像支付回调、券发放、订单创建这类高价值接口,更好的做法是:
- 用请求号建状态记录
- 成功后缓存/落库结果
- 相同请求号再次到达,直接返回第一次结果
这比单纯“报重复请求”更友好,也更符合幂等语义。
一个更贴近生产的优化建议
上面的示例已经能跑,但如果你准备上生产,我建议至少再补三点:
优化 1:从请求头优先取幂等号
优先级可以这样设计:
Idempotency-Key- 业务单号
- 用户 + URI + 参数摘要
这样通用性更强。
优化 2:对参数做稳定化处理
比如:
- Map 按 key 排序
- 忽略空值字段
- 排除时间戳、随机数
避免“同一个业务请求,不同 JSON 顺序算出不同 key”。
优化 3:按接口配置失败策略
例如给注解再加一个参数:
boolean deleteKeyOnException() default false;
这样你可以根据业务控制:
- 失败后保留 key
- 失败后删除 key 允许重试
方案适用边界
这套 Redis 防重方案非常实用,但不是万能的。
适合场景
- 表单重复提交防护
- 短时间重复点击
- 创建类接口的快速幂等保护
- 分布式环境下的轻量级防重
不适合单独使用的场景
- 强一致支付核心链路
- 必须返回首次处理结果的接口
- 跨系统长事务幂等
- 需要永久防重的业务
这些场景应结合:
- 唯一业务单号
- 数据库唯一索引
- 幂等状态表
- 消息去重机制
- 状态机控制
总结
我们这篇文章落地了一套基于 Spring Boot + Redis 的接口幂等与重复提交防护方案,核心点可以浓缩成下面几条:
- 后端幂等不能只靠前端防抖
- Redis 防重要用原子
SET NX EX - 幂等 key 设计决定了方案是否好用
- 短时防重和业务级幂等是两层能力
- 生产环境最好配合数据库唯一约束兜底
- 关键接口建议引入
Idempotency-Key与结果复用机制
如果你现在要在项目里快速落地,我建议这么做:
- 普通创建接口:先上本文这种
@Idempotent + Redis方案 - 订单/支付/券发放:再加业务请求号与数据库唯一索引
- 超关键链路:引入幂等状态表,支持结果查询与重复返回
最后说一句比较实战的话:
幂等从来不是一个注解就能彻底解决的问题,它本质上是“接口层防重 + 业务层唯一性 + 数据层兜底”的组合拳。
如果你把这三层补齐,很多重复提交问题基本就稳了。