在分布式系统架构中,接口重复调用、表单重复提交是高频易发、隐蔽性强、风险极高的线上共性问题,极易引发数据错乱、交易异常、库存偏差、脏数据堆积及资源浪费,严重影响业务稳定性与数据一致性,是生产环境亟需标准化治理的基础架构问题。
该问题源于客户端误操作、服务层重试、外部恶意请求三大场景,单纯依赖前端校验仅能优化交互体验,可通过抓包、接口重放等方式绕过,不具备安全防护能力。因此,接口防重与幂等管控必须在服务端架构层面统一实现。
本文结合一线实战与线上故障复盘,系统讲解接口防重完整技术体系,涵盖成因分析、主流方案对比、单机与分布式架构设计、企业级Redis防重源码落地,所有方案均经过生产验证,可直接复用。
一、重复提交核心成因与影响
在分布式系统中,接口重复请求并非单一前端问题,属于分布式架构下普遍存在的通用问题,问题的核心来源可分为以下四类:
① 客户端人为操作:网络延迟、卡顿、用户焦虑重复点击、双击提交、页面刷新回退。
② 前端防护被绕过:按钮置灰、防抖节流仅前端生效,抓包工具可直接绕过限制,无限重放请求。
③ 服务端重试机制:微服务Feign重试、网关重试、负载均衡重试、超时重试,导致接口重复执行。
④ 恶意攻击请求:脚本批量刷接口、爬虫重放、恶意刷数据、薅羊毛。
服务端未设计接口防重逻辑,会产生连锁负面影响:业务数据重复入库、重复下单、库存核算异常、生成无效脏流水、数据库冗余数据膨胀,额外占用服务器算力与存储资源。
❌ 订单重复创建、支付重复扣款,引发资损、客诉
❌ 数据库产生大量重复脏数据,数据一致性错乱
❌ 库存超卖、活动重复参与、积分重复发放
❌ 无效请求堆积,拖垮数据库、占用服务器资源
综上所述,接口缺少服务端防重会产生订单重复、流水脏数据、数据库冗余膨胀、系统资源损耗等诸多隐患。据此明确要求:全部写操作接口(新增、修改、支付、下单)需统一落地服务端防重逻辑。
二、常见防重复提交方案及对比
结合项目实战经验,业界通用防重方案可归纳为五大类。接下来从性能、分布式适配性、实现复杂度、安全程度、适用场景五大维度横向对比,彻底理清选型思路。
实现方案 | 原理简述 | 分布式支持 | 性能 | 安全性 | 业务侵入 | 适用性 |
|---|---|---|---|---|---|---|
前端防抖+按钮禁用 | 前端控制短时间内不可重复点击 | ✅ | 极高 | 极低(可绕过) | 低 | 仅辅助,不能单独使用 |
数据库唯一索引 | 唯一键约束拦截重复数据 | ✅ | 低 | 极高 | 高 | 适合唯一业务字段 |
本地锁(Synchronized/LOCK) | 单机JVM锁控制并发 | ❌ | 高 | 中(集群失效) | 中 | 仅适配单机项目 |
Redis分布式锁 | 基于Key+过期时间抢占锁 | ✅ | 高 | 高 | 低 | 通用高并发场景 |
Token令牌机制(最优方案) | 下发一次性Token,提交即销毁 | ✅ | 极高 | 极高 | 极低(注解实现) | 项目开发首选 |
架构选型结论:
1. 前端防抖只能做体验优化,无法单独作为接口防重的核心方案;
2. 数据库唯一索引适配手机号、订单号等存在单一唯一字段场景,无法覆盖通用业务接口;
3. 本地锁不支持集群,无法适应目前微服务架构的场景;
4. Token令牌+Redis拦截器机制是目前互联网项目通用、无侵入、高性能、分布式通用的最优落地方案。
最终架构选型:
在实际项目组,基本采用「请求指纹 + Redis 短时锁 + 全局拦截器」整套架构。此方案无需改动现有前端业务,由服务端统一完成重复请求拦截,原生支持集群部署且性能表现突出,已是行业通用标准化解决方案。
三、实战方案:基于Token的防重提交架构设计
服务端根据 用户唯一标识 + 请求URL + 请求参数 自动MD5生成全局唯一请求指纹Key,短时内相同指纹请求判定为重复提交,直接拦截。
设计要点:
1. 无需前端传递特殊Token、RequestID、无需做任务调整;
2. 同一用户、同一接口、同一参数短时间重复访问自动拦截;
3. 基于Redis原子操作判定与加锁,杜绝并发穿透;
4. 注解动态开启,业务零侵入;
5. 自动过期,不产生内存堆积。

3. 完整业务执行流程图

四、核心机制与关键避坑设计
在高并发场景下,若采用「先校验、后业务、再删除」的执行顺序,会存在并发穿透漏洞:多个请求同时校验Token有效,同时进入业务逻辑,造成重复数据写入。
高并发场景下,多条相同请求同时进入拦截器,会出现同时查询Key为空、同时放行的穿透漏洞。
解决方案:使用Redis的 SET NX EX 原子命令,保证“不存在则创建、创建成功才放行”,完全杜绝并发穿透。
令牌设置固定过期时间(默认5分钟),可适配绝大多数业务提交耗时。过期机制可自动清理无效缓存数据,避免Redis内存长期占用,同时防止令牌长期有效导致的恶意重放风险。
通过自定义注解标记需要防重的接口,无需修改业务代码,实现功能与业务解耦,符合开闭原则,便于统一管控与后期维护。
指纹Key组成规则:
防重前缀 + 用户ID + 请求URI + 请求参数Body保证:不同用户不互斥、不同接口不互斥、不同参数不互斥,只拦截完全相同的重复请求。
所有代码可直接复制用到项目中,零改造、零侵入。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>import java.lang.annotation.*;
/**
* 标记接口自动开启短时重复请求拦截
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoRepeatSubmit {
/**
* 防重有效时间(秒)默认3秒
*/
int expireTime() default 3;
}import jakarta.servlet.http.HttpServletRequest;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
public class RequestFingerUtil {
/**
* 生成请求唯一指纹Key
* 规则:用户ID + 请求地址 + 请求参数
*/
public static String getFingerKey(HttpServletRequest request, String userId) {
String uri = request.getRequestURI();
String param = request.getQueryString();
if (!StringUtils.hasText(param)) {
param = "";
}
// 拼接唯一特征字符串
String raw = userId + uri + param;
// MD5压缩生指纹
String md5 = DigestUtils.md5DigestAsHex(raw.getBytes());
return "submit:repeat:" + md5;
}
}import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
@Component
public class RepeatSubmitInterceptor implements HandlerInterceptor {
private final StringRedisTemplate stringRedisTemplate;
public RepeatSubmitInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
// 判断接口是否开启防重
NoRepeatSubmit noRepeatSubmit = handlerMethod.getMethodAnnotation(NoRepeatSubmit.class);
if (noRepeatSubmit == null) {
return true;
}
// 模拟获取当前登录用户ID(实际从Token/Session获取)
String userId = getCurrentUserId(request);
// 生成本次请求唯一指纹
String fingerKey = RequestFingerUtil.getFingerKey(request, userId);
int expire = noRepeatSubmit.expireTime();
// Redis NX 原子设置:不存在则设置,存在则返回false,彻底防并发穿透
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
Boolean success = ops.setIfAbsent(fingerKey, "1", expire, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(success)) {
returnError(response, "请勿重复提交请求");
return false;
}
return true;
}
/**
* 模拟获取登录用户ID,项目中替换为JWT/Token解析
*/
private String getCurrentUserId(HttpServletRequest request) {
String userId = request.getHeader("user-id");
return userId == null ? "anonymous" : userId;
}
private void returnError(HttpServletResponse response, String msg) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":400,\"msg\":\"" + msg + "\"}");
}
}import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final RepeatSubmitInterceptor repeatSubmitInterceptor;
public WebConfig(RepeatSubmitInterceptor repeatSubmitInterceptor) {
this.repeatSubmitInterceptor = repeatSubmitInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
}
}import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderController {
/**
* 开启防重的业务提交接口
* 无需前端传Token,纯服务端自动防重
*/
@PostMapping("/submitOrder")
@NoRepeatSubmit(expireTime = 3)
public String submitOrder() {
// 模拟下单业务逻辑
return "业务提交成功";
}
}spring:
redis:
host: 127.0.0.1
port: 6379
database: 0
timeout: 10000ms
六、测试验证
接下来,验证功能是否生效。启动项目正常、Redis连接正常。
curl --location --request POST 'http://127.0.0.1:8080/submitOrder' \
--header 'user-id: 10001' \
--header 'Content-Type: application/json' \
--data '{
"goodsId": 10086,
"num": 2,
"payType": 1
}'
使用相同的接口参数,连续多次请求/submitOrder 接口。
curl --location --request POST 'http://127.0.0.1:8080/submitOrder' \
--header 'user-id: 10001' \
--header 'Content-Type: application/json' \
--data '{
"goodsId": 10086,
"num": 2,
"payType": 1
}'请求结果:重复请求被拦截,无重复数据入库,实现单次令牌单次提交的设计目标。

模拟业务高并发,通过JMeter使用同一个有效Token发起100条并行请求。或是并发请求
@Test
void testConcurrentFinger() throws InterruptedException {
String key = "submit:repeat:testmd5key";
int threadNum = 100;
ExecutorService pool = Executors.newFixedThreadPool(threadNum);
CountDownLatch latch = new CountDownLatch(threadNum);
for (int i = 0; i < threadNum; i++) {
pool.submit(() -> {
Boolean res = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 30, java.util.concurrent.TimeUnit.SECONDS);
if (Boolean.TRUE.equals(res)) {
System.out.println("成功执行线程:" + Thread.currentThread().getName());
} else {
System.out.println("重复执行线程:" + Thread.currentThread().getName());
}
latch.countDown();
});
}
latch.await();
pool.shutdown();
// 只会打印1条成功,证明原子防穿透生效
}测试结果:仅单条请求执行业务逻辑并完成数据入库,其余所有并发请求全部拦截。

总结
本文基于微服务集群架构场景,实现了分布式系统 防重复提交的完整技术方案的设计与工程落地。通过问题溯源、方案选型、架构设计、核心避坑、代码实现、调用规范、风险优化全流程梳理,构建了一套低侵入、高性能、高可用的标准化防重架构。