首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >架构实战:彻底吃透防重复提交!接口防重设计方案

架构实战:彻底吃透防重复提交!接口防重设计方案

作者头像
章为忠学架构
发布2026-07-08 16:21:46
发布2026-07-08 16:21:46
90
举报
文章被收录于专栏:AI大模型AI大模型

在分布式系统架构中,接口重复调用、表单重复提交是高频易发、隐蔽性强、风险极高的线上共性问题,极易引发数据错乱、交易异常、库存偏差、脏数据堆积及资源浪费,严重影响业务稳定性与数据一致性,是生产环境亟需标准化治理的基础架构问题。

该问题源于客户端误操作、服务层重试、外部恶意请求三大场景,单纯依赖前端校验仅能优化交互体验,可通过抓包、接口重放等方式绕过,不具备安全防护能力。因此,接口防重与幂等管控必须在服务端架构层面统一实现。

本文结合一线实战与线上故障复盘,系统讲解接口防重完整技术体系,涵盖成因分析、主流方案对比、单机与分布式架构设计、企业级Redis防重源码落地,所有方案均经过生产验证,可直接复用。

一、重复提交核心成因与影响

1. 造成重复请求的原因

在分布式系统中,接口重复请求并非单一前端问题,属于分布式架构下普遍存在的通用问题,问题的核心来源可分为以下四类:

① 客户端人为操作:网络延迟、卡顿、用户焦虑重复点击、双击提交、页面刷新回退。

② 前端防护被绕过:按钮置灰、防抖节流仅前端生效,抓包工具可直接绕过限制,无限重放请求。

③ 服务端重试机制:微服务Feign重试、网关重试、负载均衡重试、超时重试,导致接口重复执行。

④ 恶意攻击请求:脚本批量刷接口、爬虫重放、恶意刷数据、薅羊毛。

2. 对生产系统的影响

服务端未设计接口防重逻辑,会产生连锁负面影响:业务数据重复入库、重复下单、库存核算异常、生成无效脏流水、数据库冗余数据膨胀,额外占用服务器算力与存储资源。

❌ 订单重复创建、支付重复扣款,引发资损、客诉

❌ 数据库产生大量重复脏数据,数据一致性错乱

❌ 库存超卖、活动重复参与、积分重复发放

❌ 无效请求堆积,拖垮数据库、占用服务器资源

综上所述,接口缺少服务端防重会产生订单重复、流水脏数据、数据库冗余膨胀、系统资源损耗等诸多隐患。据此明确要求:全部写操作接口(新增、修改、支付、下单)需统一落地服务端防重逻辑。

二、常见防重复提交方案及对比

结合项目实战经验,业界通用防重方案可归纳为五大类。接下来从性能、分布式适配性、实现复杂度、安全程度、适用场景五大维度横向对比,彻底理清选型思路。

实现方案

原理简述

分布式支持

性能

安全性

业务侵入

适用性

前端防抖+按钮禁用

前端控制短时间内不可重复点击

极高

极低(可绕过)

仅辅助,不能单独使用

数据库唯一索引

唯一键约束拦截重复数据

极高

适合唯一业务字段

本地锁(Synchronized/LOCK)

单机JVM锁控制并发

中(集群失效)

仅适配单机项目

Redis分布式锁

基于Key+过期时间抢占锁

通用高并发场景

Token令牌机制(最优方案)

下发一次性Token,提交即销毁

极高

极高

极低(注解实现)

项目开发首选

架构选型结论

1. 前端防抖只能做体验优化,无法单独作为接口防重的核心方案;

2. 数据库唯一索引适配手机号、订单号等存在单一唯一字段场景,无法覆盖通用业务接口;

3. 本地锁不支持集群,无法适应目前微服务架构的场景;

4. Token令牌+Redis拦截器机制是目前互联网项目通用、无侵入、高性能、分布式通用的最优落地方案。

最终架构选型

在实际项目组,基本采用「请求指纹 + Redis 短时锁 + 全局拦截器」整套架构。此方案无需改动现有前端业务,由服务端统一完成重复请求拦截,原生支持集群部署且性能表现突出,已是行业通用标准化解决方案。

三、实战方案:基于Token的防重提交架构设计

1. 核心原理

服务端根据 用户唯一标识 + 请求URL + 请求参数 自动MD5生成全局唯一请求指纹Key,短时内相同指纹请求判定为重复提交,直接拦截。

设计要点:

1. 无需前端传递特殊Token、RequestID、无需做任务调整;

2. 同一用户、同一接口、同一参数短时间重复访问自动拦截;

3. 基于Redis原子操作判定与加锁,杜绝并发穿透;

4. 注解动态开启,业务零侵入;

5. 自动过期,不产生内存堆积。

2. 整体架构图

3. 完整业务执行流程图

四、核心机制与关键避坑设计

在高并发场景下,若采用「先校验、后业务、再删除」的执行顺序,会存在并发穿透漏洞:多个请求同时校验Token有效,同时进入业务逻辑,造成重复数据写入。

1. 原子操作防并发穿透设计

高并发场景下,多条相同请求同时进入拦截器,会出现同时查询Key为空、同时放行的穿透漏洞。

解决方案:使用Redis的 SET NX EX 原子命令,保证“不存在则创建、创建成功才放行”,完全杜绝并发穿透。

2. 令牌过期机制设计

令牌设置固定过期时间(默认5分钟),可适配绝大多数业务提交耗时。过期机制可自动清理无效缓存数据,避免Redis内存长期占用,同时防止令牌长期有效导致的恶意重放风险。

3. 注解式无感接入设计

通过自定义注解标记需要防重的接口,无需修改业务代码,实现功能与业务解耦,符合开闭原则,便于统一管控与后期维护。

4. 指纹生成规则

指纹Key组成规则:

代码语言:javascript
复制
防重前缀 + 用户ID + 请求URI + 请求参数Body

保证:不同用户不互斥、不同接口不互斥、不同参数不互斥,只拦截完全相同的重复请求。

五、核心代码工程实现

技术栈:SpringBoot3 + JDK17 + Redis + 自定义注解 + 全局拦截器。

方案特点:无前端Token、无前置接口、纯服务端自动防重、注解驱动。

所有代码可直接复制用到项目中,零改造、零侵入。

1. 引入Redis依赖

代码语言:javascript
复制
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2. 自定义防重注解

代码语言:javascript
复制
import java.lang.annotation.*;

/**
 * 标记接口自动开启短时重复请求拦截
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoRepeatSubmit {
    /**
     * 防重有效时间(秒)默认3秒
     */
    int expireTime() default 3;
}

3. 请求指纹工具类

用于生成请求唯一指纹Key,规则:用户ID + 请求地址 + 请求参数。

代码语言:javascript
复制
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;
    }
}

4. 全局防重拦截器核心实现

代码语言:javascript
复制
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 + "\"}");
    }
}

5. 注册拦截器配置

代码语言:javascript
复制
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("/**");
    }
}

6. 业务验证测试接口

代码语言:javascript
复制
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 "业务提交成功";
    }
}

7. Redis配置

代码语言:javascript
复制
spring:
  redis:
    host: 127.0.0.1
    port: 6379
    database: 0
    timeout: 10000ms
代码语言:javascript
复制

六、测试验证

接下来,验证功能是否生效。启动项目正常、Redis连接正常。

6.1 首次提交

验证正常单次提交,请求正常放行,业务数据入库,对应Redis Token自动删除,功能正常。
代码语言:javascript
复制
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
}'
请求结果:
6.2 重复提交验证

使用相同的接口参数,连续多次请求/submitOrder 接口。

代码语言:javascript
复制
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
}'

请求结果:重复请求被拦截,无重复数据入库,实现单次令牌单次提交的设计目标。

6.3 高并发穿透测试

模拟业务高并发,通过JMeter使用同一个有效Token发起100条并行请求。或是并发请求

代码语言:javascript
复制
@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条成功,证明原子防穿透生效
}

测试结果:仅单条请求执行业务逻辑并完成数据入库,其余所有并发请求全部拦截。

总结

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-07-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 架构师精进 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 造成重复请求的原因
  • 2. 对生产系统的影响
  • 1. 核心原理
  • 2. 整体架构图
  • 1. 原子操作防并发穿透设计
  • 2. 令牌过期机制设计
  • 3. 注解式无感接入设计
  • 4. 指纹生成规则
  • 五、核心代码工程实现
  • 技术栈:SpringBoot3 + JDK17 + Redis + 自定义注解 + 全局拦截器。
  • 方案特点:无前端Token、无前置接口、纯服务端自动防重、注解驱动。
    • 1. 引入Redis依赖
    • 2. 自定义防重注解
    • 3. 请求指纹工具类
    • 用于生成请求唯一指纹Key,规则:用户ID + 请求地址 + 请求参数。
    • 4. 全局防重拦截器核心实现
    • 5. 注册拦截器配置
    • 6. 业务验证测试接口
    • 7. Redis配置
    • 6.1 首次提交
      • 验证正常单次提交,请求正常放行,业务数据入库,对应Redis Token自动删除,功能正常。
      • 请求结果:
      • 6.2 重复提交验证
    • 6.3 高并发穿透测试
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档