防止表单重复提交与API接口幂等设计:从原理到落地的完整方案

📅 发布时间:2026/10/19 16:02:17
防止表单重复提交与API接口幂等设计:从原理到落地的完整方案
1. 从一个真实的翻车现场说起去年帮一个做电商的朋友排查线上问题凌晨两点被电话叫醒后台订单列表里出现了大量重复记录同一个用户、同一个商品、同一个收货地址连续下了十几单而且每单都扣了库存。第一反应是用户恶意刷单查了日志才发现用户只是手抖点了两下提交按钮加上当时网络抖动前端请求重试机制又补了一刀后端接口没有任何防护来一个请求就落一条订单。这个场景就是典型的表单重复提交问题。它不是什么高深的安全漏洞但杀伤力一点不小重复下单、重复扣款、重复发券、重复提交工单轻则数据脏乱重则真金白银的损失。很多团队在业务初期根本不会考虑这件事等到出事了才回头补而补的时候往往又补得不对——加个前端按钮置灰就以为万事大吉结果换个接口工具照样能刷。这篇内容我想把防止表单重复提交和API 接口幂等设计这两件事掰开揉碎讲清楚。它们经常被混为一谈其实是两个层次的问题前者是用户交互层面的防护后者是服务端数据一致性的底线。适合后端开发、全栈工程师、以及正在做支付、订单、库存这类强一致业务的技术同学参考。读完你应该能自己设计一套扛得住并发、经得起重试的幂等方案而不是只会抄一段 Redis 加锁的代码。2. 先搞清楚重复提交到底从哪来2.1 用户手抖只是冰山一角大部分人理解重复提交第一反应就是用户点了两次按钮。这只占实际场景的一小部分。我把常见的触发路径列一下你会发现真正的坑远比想象中多触发来源具体表现是否容易被忽视用户重复点击手抖、着急、页面卡顿误以为没提交常见容易防浏览器刷新/回退提交后按 F5浏览器提示重新提交表单很常见容易漏网络超时重试客户端等不到响应自动重发请求隐蔽最难防网关/负载均衡重试上游超时后向另一台机器重发极隐蔽运维层面恶意刷接口用脚本高频调用下单/领券接口安全层面消息队列重复消费消费端 ack 丢失导致消息重投分布式场景高发你会发现从第三行开始问题就已经不是前端加个 disabled能解决的了。网络重试和网关重试这两个场景用户完全无感知前端防护形同虚设。这就是为什么幂等必须做在服务端而且要做在业务逻辑的最内层。2.2 为什么前端防护永远不够前端能做的是什么按钮点击后置灰、提交后跳转、加个 loading 遮罩。这些手段能挡住 80% 的普通用户误操作成本极低该做还是要做。但它的本质是防君子不防小人而且防不住任何技术层面的重试。我见过有团队把防重逻辑全压在前端后端接口裸奔。结果测试同学用 Postman 连点几下数据库直接多出几条记录。更麻烦的是前端防护一旦和用户体验冲突——比如按钮置灰后请求失败用户想重试却点不动——反而制造了新问题。所以正确的定位是前端防护是体验优化服务端幂等是数据底线两者都要有但重心在后端。2.3 幂等和防重提交不是一回事这两个词经常被混用我习惯把它们分开理解防重复提交偏交互层目标是同一个用户的一次操作意图只产生一次业务结果。它关注的是短时间窗口内的重复请求。接口幂等偏数据层目标是同一个请求用唯一标识区分执行一次和执行 N 次对系统状态的影响相同。它不关心时间窗口关心的是请求本身的唯一性。举个例子用户下单防重提交保证他连点五次只生成一单而幂等保证的是哪怕这五次请求因为网络原因分散在五秒内到达不同服务器最终数据库里也只有一单。前者是后者的一个子集场景后者是更普适的工程约束。理解了这层区别你设计方案时就不会只盯着时间窗口这一个维度。3. 幂等方案选型没有银弹只有取舍3.1 唯一索引最朴素也最可靠的一道闸如果只能选一种幂等方案我会毫不犹豫推荐数据库唯一索引。原因很简单它由数据库保证原子性不依赖任何外部组件不会因为缓存宕机、网络分区而失效。做法是给业务表加一个业务唯一键比如订单表的order_no或者更通用的request_id。每次请求进来先尝试插入如果捕获到唯一键冲突异常就说明这个请求已经处理过了直接返回之前的结果。ALTER TABLE order_info ADD UNIQUE KEY uk_request_id (request_id);try { orderMapper.insert(order); } catch (DuplicateKeyException e) { // 说明重复请求查询已有记录返回 return orderMapper.selectByRequestId(order.getRequestId()); }这里有个细节很多人踩坑唯一键的选择。用自增 ID 没意义用时间戳会冲突正确做法是用业务上有意义的唯一标识比如用户ID 业务类型 客户端生成的 UUID。客户端在发起请求前生成一个 UUID 放在请求头里服务端拿它做唯一键这样即使同一用户并发提交也能精准识别。注意唯一索引方案在高并发下会有性能损耗因为冲突时会走异常流程。但相比数据错乱这点损耗完全值得。而且可以通过分库分表、缩短事务范围来缓解。3.2 Redis 令牌适合高频场景的轻量方案唯一索引虽好但每次冲突都要走一次数据库异常QPS 高了之后数据库压力不小。这时候可以用 Redis 做前置拦截。核心思路是令牌机制用户进入表单页面时服务端生成一个全局唯一的 token存进 Redis 并返回给前端提交时带上这个 token服务端用DEL或Lua 脚本原子性地删除它删除成功说明是首次提交删除失败说明已经提交过。-- 原子性校验并删除 if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end为什么必须用 Lua 脚本因为先 GET 再 DEL这两步之间有时间窗口并发请求可能同时 GET 到值然后都执行 DEL结果两个请求都通过了校验。Lua 脚本在 Redis 里是原子执行的能彻底堵住这个窗口。这个方案的优势是性能极高适合秒杀、抢券这类瞬时高并发场景。缺点是依赖 Redis 可用性如果 Redis 挂了防护就失效了。所以我的建议是Redis 令牌做前置拦截数据库唯一索引做最终兜底两层配合既快又稳。3.3 状态机用业务状态天然防重有些业务本身就有状态流转比如订单从待支付到已支付从已发货到已完成。这种情况下幂等可以直接用状态机来实现。做法是更新时带上当前状态作为条件UPDATE order_info SET status PAID, pay_time NOW() WHERE order_no ? AND status PENDING;如果这条 SQL 影响行数为 0说明订单状态已经不是待支付了要么已经支付过要么被取消直接返回即可。这种方案不需要额外的存储逻辑内聚在业务里非常优雅。但它有个前提业务必须有明确的状态流转。像提交评论这种没有状态的概念就用不了。而且状态机的幂等粒度是状态变更如果同一个状态变更请求重复到达它挡不住——这时候还得靠唯一索引或 token 补位。3.4 三种方案横向对比方案适用场景性能可靠性实现复杂度数据库唯一索引所有写操作尤其强一致业务中极高低Redis 令牌高频、瞬时并发场景高中依赖 Redis中状态机有明确状态流转的业务高高低实际项目里我通常不会只用一种。支付、下单这类核心链路唯一索引 状态机双保险领券、秒杀这类高并发场景Redis 令牌 唯一索引兜底。选型的核心原则是越靠近钱和库存的操作越要用最可靠的方案。4. 手把手落地一套完整的幂等拦截4.1 客户端要配合做什么服务端方案再完善客户端不配合也会打折扣。客户端需要做三件事第一生成并携带请求唯一标识。在用户点击提交的那一刻生成一个 UUID比如用uuid.v4()放进请求头X-Request-Id。注意这个 UUID 要在一次操作意图内保持不变如果用户改了表单内容重新提交应该生成新的 UUID。第二提交按钮防抖。点击后立即置灰防止用户连点。但要注意请求失败后要恢复按钮可用否则用户无法重试。第三不要自动重试写操作。很多 HTTP 客户端库默认会对失败请求重试这在 GET 场景没问题但 POST 下单接口自动重试就是灾难。一定要显式关闭写接口的自动重试或者确保重试时携带同一个X-Request-Id。// 前端生成请求ID的示例 function submitOrder(formData) { const requestId generateUUID(); return fetch(/api/order, { method: POST, headers: { Content-Type: application/json, X-Request-Id: requestId }, body: JSON.stringify(formData) }); }4.2 服务端拦截器的设计思路服务端我习惯用一个统一的拦截器或切面来处理幂等而不是在每个业务方法里手写。这样业务代码保持干净幂等逻辑集中管理。设计思路是这样的定义一个Idempotent注解标注在需要幂等的接口上注解里可以配置过期时间、提示信息等参数。拦截器在方法执行前从请求头取出X-Request-Id拼上用户 ID 和接口路径组成 Redis key尝试写入。写入成功则放行写入失败则直接返回请勿重复提交。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Idempotent { long expireSeconds() default 10; String message() default 请勿重复提交; }拦截器核心逻辑String key idempotent: userId : request.getRequestURI() : requestId; Boolean success redisTemplate.opsForValue() .setIfAbsent(key, 1, expireSeconds, TimeUnit.SECONDS); if (Boolean.FALSE.equals(success)) { throw new BizException(message); }这里用setIfAbsent对应 Redis 的SET NX EX是关键它把判断是否存在和写入合并成一个原子操作避免了并发窗口。过期时间要设置合理太短挡不住慢重试太长会占用内存。一般业务设 10 到 30 秒足够秒杀场景可以设 5 秒。4.3 唯一键冲突后的优雅返回前面提到唯一索引方案冲突后会抛异常。但直接把异常抛给用户是很糟糕的体验——用户看到系统错误会以为下单失败可能又点一次。正确做法是捕获冲突异常后查询已有记录并返回让用户看到下单成功。public OrderResult createOrder(OrderRequest req) { try { Order order buildOrder(req); orderMapper.insert(order); return OrderResult.success(order); } catch (DuplicateKeyException e) { // 重复请求返回已有订单 Order exist orderMapper.selectByRequestId(req.getRequestId()); if (exist ! null) { return OrderResult.success(exist); } throw e; // 不是重复请求导致的冲突继续抛出 } }这里有个容易忽略的点捕获异常后要判断是不是真的重复请求。如果唯一键冲突是因为其他字段比如用户名唯一导致的直接返回已有订单就错了。所以查询时要严格用request_id去查查不到说明是别的冲突该抛还得抛。4.4 分布式锁在幂等里的正确用法有些场景光靠唯一索引和 token 不够比如检查库存再扣减这种需要先读后写的操作。这时候需要分布式锁来保证同一时刻只有一个请求在处理。但分布式锁用在幂等里有个常见误区把锁当成幂等方案。锁只能保证串行执行不能保证执行一次。两个请求串行进来第一个扣了库存第二个还是会扣。所以锁必须配合状态检查使用String lockKey lock:order: orderNo; RLock lock redissonClient.getLock(lockKey); try { if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { Order order orderMapper.selectByNo(orderNo); if (order.getStatus() ! PENDING) { return; // 已处理直接返回 } // 执行扣减逻辑 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }注意tryLock的等待时间和持有时间要设置合理。等待时间太短会导致请求直接失败太长会拖慢响应持有时间要大于业务执行时间否则锁提前释放会出问题。我一般设等待 3 秒、持有 10 秒具体看业务耗时调整。5. 那些年我踩过的幂等坑5.1 过期时间设太短慢重试照样穿透有一次做支付回调我把幂等 key 的过期时间设成了 5 秒。测试环境一切正常上线后偶尔出现重复入账。排查发现某些渠道的回调因为网络原因会延迟十几秒才重试这时候 key 早就过期了第二次回调又被当成新请求处理。这个坑的本质是过期时间必须大于业务的最大重试窗口。不同业务的重试策略不一样支付回调可能要等几分钟普通表单提交几秒就够。设置之前一定要搞清楚上游的重试机制宁可设长一点也不能设短。后来我把支付回调的过期时间改成了 24 小时问题再没出现过。5.2 用请求参数做唯一键结果被序列化坑了有段时间我用请求体的 MD5作为幂等 key想着这样不用客户端传 UUID。结果发现同一个请求有时候能识别成重复有时候识别不了。查了半天才发现JSON 序列化时字段顺序不固定同样的数据序列化出来 MD5 不一样。这个坑提醒我幂等 key 必须由客户端显式生成并传递不要试图从请求内容里推导。内容可能因为序列化、编码、字段顺序等原因变化推导出来的 key 不可靠。UUID 虽然看起来笨但它稳定、唯一、可控这才是幂等 key 该有的品质。5.3 拦截器顺序不对幂等失效Spring 的拦截器和过滤器执行顺序是有讲究的。我曾经把幂等拦截器放在了一个自定义的权限过滤器后面结果发现未登录用户的重复请求没被拦住——因为权限过滤器先返回了 401幂等拦截器根本没执行。后来我把幂等拦截器的优先级调到最前面确保它在任何业务逻辑之前执行。这个经验是幂等拦截应该尽可能靠前在参数校验、权限校验之前就完成这样才能覆盖所有请求路径。当然如果幂等 key 需要用户 ID那就得等认证完成之后这个顺序要按实际依赖来排。5.4 只防了接口没防消息重复消费接口层的幂等做完了我以为高枕无忧。结果某次消息队列抖动同一条扣库存消息被消费了两次库存又对不上了。这才意识到消息消费也需要幂等而且它和接口幂等是两套独立的防护。消息幂等的做法类似给每条消息一个唯一 ID消费前先查这个 ID 是否处理过处理过就跳过。可以用数据库的唯一索引也可以用 Redis 记录已消费的 ID。关键是消费逻辑本身要幂等不能依赖消息只来一次这个假设。6. 高并发场景下的进阶考量6.1 幂等 key 的存储选型低并发时用 Redis 存幂等 key 完全够用但到了每秒几万请求的级别单个 Redis 实例可能扛不住。这时候有几个方向一是分片把幂等 key 按用户 ID 哈希分散到多个 Redis 实例降低单实例压力。二是本地缓存 Redis 二级热点 key 放本地减少网络往返。三是改用布隆过滤器做前置判断虽然它有误判率但用在判断是否重复场景误判为已存在只是让用户重试一次影响可控。不过我要提醒一句不要过早优化。大部分业务的 QPS 根本到不了需要分片的程度先把唯一索引和 Redis 令牌用扎实等真有性能瓶颈了再考虑这些。我见过太多团队一上来就搞复杂架构结果维护成本高得离谱收益却微乎其微。6.2 幂等和事务的边界幂等逻辑和数据库事务的关系要理清楚。理想情况下幂等 key 的写入和业务数据的写入应该在同一个事务里要么都成功要么都失败。但 Redis 不支持事务回滚这就产生了不一致的可能Redis 写成功了业务事务回滚了用户重试时会被误判为重复提交。解决思路有两种。一种是先执行业务事务成功后再写幂等 key但这样在事务提交和写 key 之间有窗口并发请求可能穿透。另一种是用数据库表存幂等记录和业务数据同事务牺牲一点性能换一致性。我倾向于后者用在核心链路前者用在允许极小概率重复的非核心场景。6.3 幂等失败后的补偿再完善的方案也可能有漏网之鱼所以要有补偿机制。常见做法是定时对账扫描业务表找出同一request_id或同一业务唯一键的多条记录标记异常并触发人工或自动处理。对账的粒度可以是分钟级、小时级或天级看业务敏感度。支付类业务建议分钟级对账普通业务天级即可。对账脚本本身也要幂等避免重复补偿造成二次伤害。7. 一套可以直接抄的落地清单把上面的内容浓缩成一份可执行的清单你照着做基本能覆盖 90% 的场景客户端提交前生成 UUID 放请求头按钮点击后置灰关闭写接口自动重试。网关层对写接口做限流防止恶意高频刷。拦截器层用Idempotent注解 RedisSET NX EX做前置拦截过期时间按业务重试窗口设置。业务层核心写操作加数据库唯一索引冲突后查询已有记录优雅返回。状态流转有状态的业务用条件更新WHERE status ?做二次防护。消息消费消费端用消息 ID 做幂等不假设消息只来一次。对账补偿定时扫描异常重复数据自动或人工处理。最后分享一个我自己的习惯每次设计新接口我都会问自己三个问题——这个接口重复调用会怎样客户端会重试吗上游网关会重试吗三个问题答完幂等方案基本就清晰了。幂等不是加个注解就完事它是对整个请求链路的重试行为的系统性防御。想清楚链路方案自然就对了。