题目在实际项目中用户可能会因为网络抖动或手快连点导致同一个请求被后端处理了多次。比如支付接口被调了两次用户扣了两次钱或者注册接口被调了两次同一个手机号注册了两个账号。请从技术角度说说如何防止这类重复提交问题。参考答案一、先搞清楚重复提交到底是怎么发生的重复提交的来源大致有三类这三种场景的共同点是后端收到的请求里有两个或多个请求代表的其实是同一件事。那么问题的本质就变成了后端如何识别出这件事我已经做过了这个能力在技术上有专门的名字——幂等性。幂等性同一个操作执行一次和执行多次产生的效果完全一样。就像你在电梯里反复按1 楼按钮——按一次是去 1 楼按 10 次还是去 1 楼电梯不会给你挖个地下室。二、解法全景图在开始写代码前先把所有解法摊开心里有个地图①前端防抖成本最低但不可靠——用户关了 JS 就形同虚设②网关限流/去重Nginx 能拦住部分但不够精细③业务层幂等核心方案也是面试重点④数据库唯一约束最后的兜底防线咱们一层一层看重点放在第③层——因为面试官最想听的就是这个。三、前端层——第一道篱笆便宜但不管饱!-- 最简单的防连点点击后按钮置灰 -- button idsubmitBtn onclickhandleSubmit()立即支付/button script let submitting false; function handleSubmit() { if (submitting) { console.log(别点了正在提交呢...); return; } submitting true; const btn document.getElementById(submitBtn); btn.disabled true; btn.innerText 提交中...; fetch(/api/pay, { method: POST, body: formData }) .then(res res.json()) .then(data { if (data.success) { alert(支付成功); } else { // 失败了恢复按钮允许用户重试 submitting false; btn.disabled false; btn.innerText 立即支付; } }) .catch(() { // 网络异常同样恢复 submitting false; btn.disabled false; btn.innerText 网络异常请重试; }); } /script这段代码很简单但它有个致命问题只能防用户手快防不了网络重试。比如用户页面卡了刷新页面后又点了一次——这是一个全新的页面submitting变量已经被重置了。再者要是有恶意用户直接拿 Postman 调你的接口前端防抖跟没防一样。所以前端的防抖是锦上添花不是雪中送炭。真正的防线在后端。四、业务层——这场仗的主战场后端的防重复提交方案有三个主流玩法难度逐级递增方案复杂度适用场景一句话概括Token 令牌机制⭐表单提交、注册、下单先领号再办事Redis 分布式锁⭐⭐高并发支付、扣库存同一时刻只有一个人能办这件事唯一 ID Redis SETNX⭐⭐通用幂等、最灵活用唯一请求 ID 做已处理标记4.1 Token 令牌机制推荐新人首选这是最容易理解、也最常用的方案。思路跟去银行办业务差不多进门先取号 → 喊到你的号了 → 把号交给柜员 → 柜员核验号码 → 办业务 → 销毁号码。同一个号不能用两次。翻译成技术语言就是步骤 1进入表单页面 → 后端生成一个唯一 Token → 存到 Redis返回给前端 步骤 2用户填写表单提交时把 Token 一起带上 步骤 3后端收到请求 → 用 Token 去 Redis 里查 → 有好的删掉 Token处理业务 → 没有说明这个 Token 已经用过了 → 拒绝返回请勿重复提交时序图如下Java 代码实现RestController RequestMapping(/api) public class OrderController { Autowired private StringRedisTemplate redisTemplate; // 步骤 1进入页面时申请一个 Token GetMapping(/token) public ResultString getToken() { // 用 UUID 生成全局唯一标识 String token UUID.randomUUID().toString().replace(-, ); // 存到 Redis5 分钟过期防止用户开了页面不提交Token 永远占着坑 redisTemplate.opsForValue().set(idempotent:token: token, 1, 5, TimeUnit.MINUTES); // 前端拿到后藏在表单的 hidden input 里或者放在请求头里 return Result.success(token); } // 步骤 23提交时校验并删除 Token PostMapping(/submit-order) public ResultString submitOrder(RequestHeader(Idempotent-Token) String token, RequestBody OrderDTO order) { // 关键一步校验 Token 是否存在存在就删掉 // 这里必须用 校验 删除 一步完成不能用先查后删 Boolean deleted redisTemplate.delete(idempotent:token: token); if (Boolean.FALSE.equals(deleted)) { // Token 不存在 → 已经被处理过了 → 拒绝 return Result.fail(请勿重复提交); } // Token 存在且已被删除 → 安全执行业务 orderService.createOrder(order); return Result.success(下单成功); } }关键细节——为什么必须用 DELETE 而不是先 GET 再 DELETE// ❌ 错误写法先查后删并发下仍然可能重复处理 String value redisTemplate.opsForValue().get(idempotent:token: token); if (value ! null) { redisTemplate.delete(idempotent:token: token); // ← 中间有时间窗口 // 线程 A 刚 get 到值、还没 delete 的时候 // 线程 B 也 get 到了值 → 两个线程都进入业务逻辑 → 重复处理 orderService.createOrder(order); } // ✅ 正确写法Redis 的 DELETE 本身返回是否删除成功 // 同一个 Key 只能被成功删除一次天然原子 Boolean deleted redisTemplate.delete(idempotent:token: token);一句话总结Redis 的delete方法返回true/false表示是否删除了东西——这个返回值本身就是天然的分布式锁。Token 机制的优缺点优点缺点实现简单理解成本低需要额外一次申请 Token的请求适用于大多数表单提交场景Token 有过期时间超时后用户需刷新页面天然幂等同一 Token 只能消费一次对每个需要幂等的接口都要改造4.2 唯一请求 ID Redis SETNX最灵活方案Token 机制好用但它有个前提——你得先申请 Token。有些场景下请求本身已经带了一个能唯一标识这件事的 ID比如支付回调的商户订单号out_trade_no消息队列的消息 IDMessageId前端主动生成的请求 IDRequest-Id这时候就可以直接用这个 ID 做幂等标记不用额外申请 Token 了。核心思路用 Redis 的SETNXSET if Not eXists接口把请求 ID 当成已处理凭证。RestController RequestMapping(/api) public class PaymentController { Autowired private StringRedisTemplate redisTemplate; /** * 支付接口——幂等版本 */ PostMapping(/pay) public ResultString pay(RequestBody PayDTO payDTO) { // 商户订单号同一笔交易的订单号是唯一的 String outTradeNo payDTO.getOutTradeNo(); String idempotentKey pay:processed: outTradeNo; // 核心SETNX 原子性保证 // setIfAbsent 返回 true → 说明是第一次处理 // setIfAbsent 返回 false → 说明已经有线程在处理或已处理完 Boolean firstTime redisTemplate.opsForValue() .setIfAbsent(idempotentKey, processing, 30, TimeUnit.MINUTES); if (Boolean.FALSE.equals(firstTime)) { // 不是第一次直接返回已处理 // 注意这里可以进一步区分正在处理还是已经处理完 String status redisTemplate.opsForValue().get(idempotentKey); if (done.equals(status)) { return Result.success(支付已完成无需重复操作); } else { return Result.fail(支付正在处理中请稍后查询结果); } } try { // 执行业务逻辑 boolean paySuccess paymentService.doPay(payDTO); if (paySuccess) { // 处理成功更新状态为 done redisTemplate.opsForValue().set(idempotentKey, done, 30, TimeUnit.MINUTES); return Result.success(支付成功); } else { // 处理失败删除幂等标记允许重试 redisTemplate.delete(idempotentKey); return Result.fail(支付失败请重试); } } catch (Exception e) { // 异常了也要删除标记否则用户永远无法重试 redisTemplate.delete(idempotentKey); throw e; } } }这里有三个值得展开的点① 为什么要区分 processing 和 done 两种状态想象一下第一个请求正在处理中可能调了第三方支付接口比较慢第二个重复请求来了。如果你看到幂等 Key 存在就直接返回已处理但实际上第一个请求还没处理完——用户看到已处理一查余额发现钱没扣就会很迷惑。用两种状态区分后processing告诉用户我们正在处理别急done告诉用户已经搞定了。② 业务失败要删除幂等标记吗要。如果业务失败了比如余额不足不删除幂等标记的话用户换张卡重试都重试不了——因为幂等标记还在系统以为这个请求已经被处理了。③ 异常了也要删除标记吗这里分两种情况讨论网络/系统异常调用第三方超时、数据库挂了应该删除因为用户拿到异常后大概率会重试如果不知道业务成功还是失败比如调用银行接口超时钱到底扣没扣这时候不该草率删除而是保持processing状态让用户主动查询。后面再通过定时对账来处理这种悬而未决的订单4.3 Redis 分布式锁适合同一资源不能同时操作的场景Token 和 SETNX 解决的是同一个请求不能处理两次的问题。还有一种场景是同一个资源不能同时被两个请求操作——比如两个人同时给同一个订单退款。这时候分布式锁登场RestController RequestMapping(/api) public class RefundController { Autowired private StringRedisTemplate redisTemplate; PostMapping(/refund) public ResultString refund(RequestBody RefundDTO refundDTO) { String orderId refundDTO.getOrderId(); String lockKey refund:lock: orderId; String lockValue UUID.randomUUID().toString(); // 锁的值用于安全释放 // 加锁SETNX 过期时间 Boolean gotLock redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS); if (Boolean.FALSE.equals(gotLock)) { return Result.fail(该订单正在退款处理中请勿重复操作); } try { // 执行业务 refundService.doRefund(refundDTO); return Result.success(退款成功); } finally { // 释放锁用 Lua 保证原子性 // 千万不能直接 DEL——万一 A 的锁过期了、B 又加了锁A 去 DEL 会把 B 的锁删掉 String script if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end; redisTemplate.execute( new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), lockValue ); } } }分布式锁和幂等 Token 的区别对比维度幂等 Token / SETNX分布式锁关注点这件事做过了没有这个资源有人在操作吗失败行为返回已处理等待或拒绝典型场景表单提交、支付回调退款、库存扣减Key 粒度按请求 ID按资源 ID五、数据库层——最后的铁闸不管上层怎么设计数据库的唯一约束是一定要加的。这是最后一道防线也是成本最低、最可靠的保障。-- 示例 1订单表的商户订单号加唯一索引 ALTER TABLE t_order ADD UNIQUE INDEX uk_out_trade_no (out_trade_no); -- 示例 2幂等表方案——单独建一张表记录所有已处理的请求 ID CREATE TABLE t_idempotent_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, request_id VARCHAR(64) NOT NULL COMMENT 请求唯一标识, business_type VARCHAR(32) NOT NULL COMMENT 业务类型如 PAY, REFUND, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request_id (request_id) ) COMMENT 幂等记录表;配合 Java 代码Service public class PaymentService { Autowired private IdempotentRecordMapper idempotentRecordMapper; Autowired private OrderMapper orderMapper; Transactional public void processPayment(String requestId, PayDTO payDTO) { // 步骤 1尝试插入幂等记录利用唯一约束兜底 try { IdempotentRecord record new IdempotentRecord(); record.setRequestId(requestId); record.setBusinessType(PAY); idempotentRecordMapper.insert(record); } catch (DuplicateKeyException e) { // 唯一键冲突 → 这个请求已经被处理过了 throw new BusinessException(请勿重复提交); } // 步骤 2执行真正的业务逻辑 orderMapper.updatePayStatus(payDTO.getOrderId(), PAID); // 两个操作在同一个事务里 // 如果业务失败回滚幂等记录也被回滚了 → 允许用户重试 // 这才是正确的打开方式 } }数据库方案的优劣✅ 绝对可靠——数据库的唯一约束是 ACID 保证的不可能出现两个相同的request_id✅ 不需要 Redis架构更简单❌ 性能不如 Redis——MySQL 的写入速度远低于 Redis❌ 高并发下可能成为瓶颈所以通常的做法是Redis 做快速拦截挡 99.9%MySQL 唯一约束做兜底挡那 0.1% 的极端情况。六、消息队列场景的幂等如果你的系统用了消息队列RocketMQ / Kafka那幂等问题更不能忽视——因为消息队列天然就是至少投递一次的语义消费者收到重复消息是大概率事件。Component RocketMQMessageListener( topic order-topic, consumerGroup order-consumer-group ) public class OrderMessageConsumer implements RocketMQListenerMessageExt { Autowired private StringRedisTemplate redisTemplate; Override public void onMessage(MessageExt message) { String msgId message.getMsgId(); // RocketMQ 的消息 ID 全局唯一 // 用消息 ID 做幂等和前面 SETNX 模式一模一样 Boolean firstTime redisTemplate.opsForValue() .setIfAbsent(mq:consumed: msgId, 1, 1, TimeUnit.HOURS); if (Boolean.FALSE.equals(firstTime)) { log.warn(消息已被消费跳过: msgId{}, msgId); return; // 直接 ACK不重复处理 } // 正常消费逻辑 try { processOrderMessage(message); } catch (Exception e) { // 消费失败 → 删除幂等标记 → MQ 会重试下次还能处理 redisTemplate.delete(mq:consumed: msgId); throw e; // 抛出异常让 MQ 知道消费失败 } } }七、一张全景对比表方案实现方式性能可靠性复杂度适用场景前端防抖JS 按钮置灰⭐⭐⭐⭐⭐辅助手段不可依赖Token 机制Redis DELETE 原子性⭐⭐⭐⭐⭐⭐⭐表单提交、注册、下单SETNX 幂等Redis SETNX⭐⭐⭐⭐⭐⭐⭐支付回调、通用幂等分布式锁Redis SETNX Lua 解锁⭐⭐⭐⭐⭐⭐⭐⭐同资源互斥操作数据库唯一约束UNIQUE KEY⭐⭐⭐⭐⭐⭐最终兜底防线数据库幂等表 事务INSERT 唯一约束⭐⭐⭐⭐⭐⭐⭐不依赖 Redis 的场景八、常见坑点与避坑指南坑现象正确姿势删 Token 和业务不在同一事务Token 删了业务失败了 → 用户想重试也重试不了业务失败时把 Token 补回去或用 try-catch 回补锁释放时误删别人的锁线程 A 的锁过期了线程 B 加了新锁线程 A 去 DEL 把 B 的锁删了解锁前用 Lua 判断 value 是否为自己持有的幂等 Key 不设过期时间Redis 内存被无尽堆积的幂等 Key 撑爆务必设置过期时间根据业务一般 10 分钟到 24 小时只在前端防抖后端裸奔用户用 Postman 调接口重复提交畅通无阻后端幂等是底线前端只是锦上添花高并发下只靠 MySQL 唯一约束几万个请求同时插入幂等表 → 死锁超时Redis 快速拦截 MySQL 兜底幂等标记永远不删业务失败后无法重试业务失败要删除标记异常时视情况删除九、汇总推荐的标准打法对于大多数业务场景一套组合拳是最稳妥的用一句话总结就是前端拦、Redis 挡、MySQL 兜——不是一道防线而是一道道防线层层兜底才能睡得着觉。十、面试口语答案模版如果面试官问后端接口如何防止重复提交你可以按这个顺序说防止重复提交的核心是保证接口的幂等性——同一个请求不管来多少次效果和来一次一样。先说原因重复提交通常有三个来源——用户手快连点、网络抖动导致客户端重试、消息队列的重复消费。解决方案我一般分几层来考虑前端层很简单按钮点击后置灰加上 loading 状态但这只能防用户手快防不了网络重试和恶意调用。业务层是核心主要有三种方案第一种是 Token 机制进入页面时后端生成一个唯一 Token 存 Redis提交时用 DELETE 命令校验并删除 Token。Redis 的 DELETE 返回值天然原子——删成功就是第一次删失败就是重复提交。第二种是唯一请求 ID 加 Redis 的 SETNX比如支付回调里的商户订单号本身就是唯一 ID用 SETNX 标记已处理还可以区分 processing 和 done 两种状态来给用户更好的提示。第三种是分布式锁适用于同一资源不能同时操作的场景比如同一笔订单不能同时退款两次。用 Redis SETNX 加 Lua 脚本安全解锁。数据库层我会在关键字段上加唯一约束或用一张幂等记录表配合事务这是最后兜底的——因为 Redis 是缓存的语义极端情况下可能丢数据而数据库的 ACID 保证不会漏。如果是消息队列场景用消息 ID 加 SETNX 做幂等标记就行消费失败时删除标记让 MQ 重试。最后还有几个容易踩的坑锁释放要用 Lua 判断 value 防止误删别人的锁幂等 Key 一定要设过期时间业务失败时要回补标记让用户能重试。十一、一句话总结重复提交不是你会不会遇到而是什么时候遇到。在后端做幂等不是优化是底线——你可以不写前端防抖但不可以没有后端幂等。记住一个原则前端拦是情分后端防是本分。