先讲一个我踩过的坑。之前负责某商城的下单接口,开发环境怎么点都没问题,上线当晚用户反馈“订单重复创建”,查日志发现同一个用户 ID 在 200ms 内连发了两次请求,后端两个线程同时校验库存都通过,于是生成了两笔订单、扣了两次库存。这个问题的官方名字叫“用户重复提交”,但真正处理起来你会发现它从来不是单一层面的问题:前端要防,后端要防,数据库还要防。尤其是 Spring Boot 项目里,通常还叠着事务、缓存、分布式部署这些复杂条件,稍有不慎就会从“少数的重复”变成“线上事故”。
这篇文章我会把我做过的几套方案完整梳理一遍,包括前端交互、数据库唯一约束、Token 一次性令牌、Redis 分布式锁,以及最容易被忽略的“事务提交和锁释放顺序”问题。无论你是刚接触 Spring Boot 的新人,还是已经写过几年业务代码的开发,应该都能从里面找到直接能用的组合实践。
1. 先分清你遇到的是哪种“重复提交”
很多人一看到“重复提交”就直接想到“加按钮锁”,结果上线后该重复还是重复,因为不同场景下的重复提交,成因和对抗手段完全不同。我习惯先分三类来看。
1.1 用户误操作型:手速快、多点了几次
这是最典型的一种:提交按钮没做 loading 态,用户点了没反应,又快速点了几下;或者页面网络慢,用户以为没提交成功,刷新之后又提交了一次。
这类问题的特征是请求来源相同、间隔通常几百毫秒到几秒、请求参数几乎一样。它的压力不大,但业务后果很直接:如果是订单接口,会建出多笔一模一样的订单;如果是扣款接口,会连续扣多次款。
解决思路通常是三层叠加:前端按钮置灰 + 后端短时间内去重 + 数据库唯一约束兜底。只做前面任意一层都不够,原因后面细说。
1.2 网络重试型:客户端超时后的自动补偿
移动端 App 里很常见。用户在弱网环境下提交订单,请求其实已经到了服务端,但因为响应超时,App 层判断“失败”,于是自动重试了一次。服务端如果没做幂等,就会处理两次。
这种类型和“用户手抖”最大的区别是:两次请求之间可能隔了十几秒甚至更久,单纯用“短时间内加锁”是挡不住的。它需要的是一把跨时间的“幂等锁”或“幂等记录”,让相同业务编号的请求在后续一段时间内只能成功一次。
1.3 并发竞争型:两个请求同时进入核心代码
恶意脚本、爬虫、秒杀活动中的高频刷新,都可能制造这种并发竞争。两个请求几乎同时到达,同时通过了业务校验,同时执行插入,最后同时成功。
这是最危险的一类。并发重复往往意味着校验结果、库存扣减、积分发放这些“先查后改”的操作被并发执行了两次,单靠数据库唯一约束虽然能兜底主键冲突,但如果不做幂等提示和状态管理,往往会直接抛一个 DuplicateKeyException 给用户,体验很差。
1.4 防“重复提交”和“幂等”不是一回事
区分清楚这两个概念,能帮你少走弯路。
“防重复提交”通常指的是:同一用户短时间内对同一操作只放行一次,典型手段是 Token、按钮锁、分布式锁。
“幂等”指的是:同一个请求执行一次和执多次,系统最终的数据状态一致,典型手段是唯一索引、状态机、幂等表。
实际项目里两者会交叉使用。比如下单接口,我会用 Token 防止用户反复点击,同时用“业务订单号唯一索引”保证即使 Token 被绕过,数据库也会拦住重复插入。而像支付回调这类本来就可能被消息系统重复推送的接口,我几乎只靠幂等,不靠防重复,因为“重复来源”根本不在用户操作层面。
| 场景类型 | 典型表现 | 主要手段 | 兜底手段 |
|---|---|---|---|
| 用户误操作 | 几百毫秒内连续多次请求 | 前端置灰 + Token 一次性消费 | 数据库唯一约束 |
| 网络重试 | 间隔数秒再次请求 | 请求唯一编号幂等判断 | 幂等表 |
| 并发竞争 | 多线程同时通过校验 | Redis 分布式锁 | 唯一约束 + 事务回滚 |
我建议你接到“重复提交”需求时,先别急着写代码,问自己一句:这个重复到底从哪来?判断完之后再去选方案,事半功倍。
2. 前端按钮和数据库唯一约束:先把最简单的问题解决掉
很多人觉得前端做按钮置灰不够“高级”,觉得那是表面功夫。我的观点恰恰相反:前端处理是成本最低、对用户体验提升最明显的一层。如果前端什么都不做,每次重复提交都要后端去判断、去查库,系统压力浪费得毫无意义。
2.1 前端交互层的常规操作
Spring Boot 项目通常搭配 Vue、React 这类前端框架。最简单的做法是提交按钮在点击后立刻进入 loading 状态并禁用,等接口返回后再恢复。
以 Vue 3 为例,大致是这个思路:
<template> <button :disabled="submitting" @click="submitOrder"> {{ submitting ? '提交中...' : '提交订单' }} </button> </template> <script setup> import { ref } from 'vue' const submitting = ref(false) async function submitOrder() { if (submitting.value) return submitting.value = true try { // 调用下单接口 } finally { submitting.value = false } } </script>还可以再加一层防抖:短时间内重复触发只保留最后一次。注意,这里说的是“最后一次”而不是“第一次”,因为对于提交类按钮,用户连续点击时通常希望保留第一次请求,之后的自动忽略。你可以把这两者结合,但最终要靠后端判定,前端只负责减少无效流量。
也有一部分团队会采用“提交成功后跳转新页面”的方式,比如跳转到订单详情页,原页面被替换,天然阻断重复提交。这种方式在结果页依赖服务端返回数据时比较有效。
2.2 前端限制挡不住有预谋的重复
前端法子最大的问题在于:只能拦“人”,拦不住“程序”。
用户用浏览器插件重复提交、客户端 SDK 自动重试、别人直接拿接口地址拼参数调用,这些都完全绕过了前端按钮状态。所以我在技术评审里反复强调:前端防重复是体验优化,后端防重复才是业务保障。如果预算有限,优先级一定是后端高于前端。
2.3 数据库唯一约束才是硬保障
不管前面做了多少层校验,最终能把“重复数据”拦死在持久化层的,还得靠数据库唯一约束。这里我举一个常见的幂等表设计方案。
假设你要做一笔订单创建接口,给每个请求生成一个业务唯一键。比如用户 ID + 业务类型 + 业务单号。创建一张幂等记录表:
CREATE TABLE idempotent_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, biz_type VARCHAR(32) NOT NULL COMMENT '业务类型:order/payment', biz_id VARCHAR(64) NOT NULL COMMENT '业务唯一编号', request_body TEXT COMMENT '请求内容快照', status TINYINT NOT NULL DEFAULT 0 COMMENT '0处理中 1成功', created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_type_biz_id (biz_type, biz_id) ) COMMENT '幂等记录表';关键就在uk_biz_type_biz_id这个唯一索引上。业务代码里,在事务内先尝试插入这条幂等记录,如果插入成功,说明当前请求拿了“头筹”,可以继续执行后续业务;如果插入时抛了DuplicateKeyException,说明同一个业务请求已经处理过,直接返回“重复提交”提示。
Service 层的伪代码如下:
@Transactional public void createOrder(OrderCreateRequest request) { boolean first = tryInsertIdempotentRecord( "order", request.getUserId() + ":" + request.getOrderNo() ); if (!first) { throw new RepeatSubmitException("订单已提交,请勿重复操作"); } // 继续实际的订单创建逻辑 orderMapper.insert(buildOrderDO(request)); }注意,插入幂等记录和插入订单数据必须在同一个事务里。如果分开两个事务,先插入幂等记录成功、后续业务失败并回滚,那么幂等记录就会一直留在表里,把用户真正想重新提交的请求误杀。
2.4 业务唯一键怎么设计
很多人喜欢用“用户 ID + 时间戳”做唯一键,这其实不严谨。因为时间戳是前端传的还是后端生成的?如果是后端生成的,那同一请求被重试两次,生成的时间戳很可能不一样,唯一键也就失效了。
我推荐用三种来源组合:
- 业务自身已经有的编号,比如订单号、支付流水号。
- 前端生成的一次性 UUID,作为请求幂等键传给后端。
- 后端根据关键参数计算出的哈希值,比如用户 ID + 商品 ID + 数量。
最稳妥的做法是:前端在提交页面初始化时生成一个 UUID,放到请求头Idempotency-Key里,同一个页面操作一直带着它。这样同一用户的同一次“业务意图”,无论重试几次,携带的幂等键都一样。后端拿它作为唯一键的一部分,非常可靠。
3. Token 一次性令牌:适合常规 Web 表单的服务端方案
数据库唯一约束虽然稳,但它属于“事后拦截”,请求已经进入了业务层,还占用了数据库连接。如果系统里大量接口都要防重复,就需要一个更前置、更轻量的方案。Token 一次性令牌是使用最广泛的一种。
3.1 核心流程:先领牌,后消费
思路不复杂:用户打开提交页面时,后端先生成一个一次性 Token 返回给前端;前端提交业务请求时必须带上这个 Token;后端校验 Token 存在后立刻删除,然后执行业务;如果第二次提交拿着同一个 Token 来,发现已经被删了,就拒绝。
实际项目中我一般把 Token 存在 Redis 里,不放在 Session 里。Session 在分布式部署下默认有会话黏性问题,而 Redis 是共享存储,每个实例都能访问到同一份 Token。
3.2 用 Redis + Lua 保证消费原子性
Token 方案最关键的细节是“校验并删除”必须是一个原子操作。如果在 Java 代码里先get再delete,中间一旦有并发请求同时读到 Token 还存在,两个请求就都通过了。解决办法是用一段 Lua 脚本让 Redis 原子执行。
private static final String CONSUME_TOKEN_SCRIPT = "if redis.call('exists', KEYS[1]) == 1 " + "then return redis.call('del', KEYS[1]) " + "else return 0 end"; public boolean consumeToken(String tokenKey) { DefaultRedisScript<Long> script = new DefaultRedisScript<>(CONSUME_TOKEN_SCRIPT, Long.class); Long result = stringRedisTemplate.execute(script, List.of(tokenKey)); return result != null && result == 1L; }生成 Token 的接口也很简单:
public String createSubmitToken(String scene, String userId) { String token = UUID.randomUUID().toString().replace("-", ""); String key = "submit:token:" + scene + ":" + userId + ":" + token; stringRedisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(10)); return token; }前端伪代码大致是:
- 页面加载时调用
GET /api/token获取 token。 - 提交表单时把 token 放到请求参数或请求头中。
- 点击按钮后立即禁用按钮。
- 如果收到“重复提交”错误,提示用户“正在处理中”,不做二次提交。
这套设计在 Spring Boot 单机、分布式场景下都能跑,因为 Token 状态完全由 Redis 集中管理。
3.3 容易被忽略的几个小细节
第一,Token 的 TTL 不要设太短。用户可能填写一个复杂表单需要几分钟甚至更久,Token 一旦中途过期,用户点提交时会被误判为重复提交。我一般按业务耗时上限的 3 到 5 倍设置,普通表单 10 到 15 分钟,比较合理。
第二,Token 的 key 一定要包含用户维度。如果不带用户 ID,那用户 A 拿到的 Token 被用户 B 拿来提交,也能通过校验,这就会变成越权漏洞。Key 设计成submit:token:{scene}:{userId}:{token}能防止串用。
第三,Token 和业务参数之间最好在服务端做绑定。比如把关键的请求摘要存到 Token 对应的 value 里,提交时校验摘要是否一致,防止用户拿 A 场景的 Token 提交 B 场景的请求。简单做法是把场景标识直接拼进 Key,复杂做法是校验请求头里的业务参数摘要。
4. Redis 分布式锁 + 请求唯一ID:高并发场景的硬底牌
Token 方案能挡住绝大多数重复提交,但挡不住一类特殊情况:两个完全相同的请求,在极短时间窗口内同时到达。为什么?因为“检查 Token 是否存在”和“删除 Token”虽然被 Lua 保证原子了,但如果业务执行时间较长,比如下单要查库存、调用支付、扣减积分,整个过程花了 3 秒,另一个并发请求在 3 秒后带上一个新的合法 Token 进来,Token 校验是能通过的,这时候还是要靠“业务唯一键”或分布式锁来保证真正只有一个执行。
4.1 为什么需要分布式锁
Spring Boot 项目一旦多实例部署,传统的 synchronized、ReentrantLock 都只在单进程内生效。两个请求分别打到两台机器上,本地锁互相感知不到,因此需要一把基于 Redis 的全局锁。
我这里说的分布式锁,和前面的 Token 不是替代关系,而是配合关系。Token 负责拦住“同一个页面操作”的重复请求;分布式锁负责拦住“同一业务键”的并发请求。
4.2 基于 Redis 的加锁与安全释放
最简单的加锁写法是用setIfAbsent,也就是 Redis 的SET NX EX:
String lockKey = "lock:order:" + request.getUserId() + ":" + request.getOrderNo(); String requestId = UUID.randomUUID().toString(); Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(10)); if (!Boolean.TRUE.equals(locked)) { throw new RepeatSubmitException("订单处理中,请勿重复提交"); } try { // 业务逻辑 createOrder(request); } finally { releaseLock(lockKey, requestId); }释放锁时千万不要直接delete lockKey,因为如果业务执行时间超过了锁的 TTL,锁自动过期了,第二个请求又加锁成功,此时第一个请求结束去释放锁,就会把第二个请求的锁误删。所以删除前要判断 value 是不是自己的 requestId,再把删除操作做成 Lua 脚本。
private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] " + "then return redis.call('del', KEYS[1]) " + "else return 0 end"; public void releaseLock(String lockKey, String requestId) { DefaultRedisScript<Long> script = new DefaultRedisScript<>(RELEASE_LOCK_SCRIPT, Long.class); stringRedisTemplate.execute(script, List.of(lockKey), requestId); }这套写法的重点是:加锁要带随机唯一 ID,释放要校验身份。这样才能避免并发场景下的误删问题。
4.3 锁的粒度与超时时间怎么定
锁粒度太大,比如直接把“用户 ID”作为锁 key,等于让同一个用户的所有订单操作串行化,响应速度会明显下滑。锁粒度太小,比如把整个请求体 hash 都作为 key,又可能导致两次语义相同的请求因为某个非关键字段不同而没被拦住。
我实践下来的经验是:锁 key 尽量贴近业务唯一键。下单接口用userId + orderNo,库存扣减用userId + skuId,转账用userId + tradeNo。这个键既要能表达“同一笔业务”,又不能太粗。
TTL 则要根据业务最大执行时间来判断。普通接口 3 到 10 秒足够;如果涉及外部 RPC 或支付回调,建议 15 到 30 秒,并配合监控告警,防止锁超时太短导致并发穿透。
4.4 分布式锁不是银弹,数据库兜底还是要有
请记住这句话:分布式锁是“并发闸门”,数据库唯一约束是“最后一条防线”。
分布式锁有可能因为 Redis 重启、网络分区、锁意外过期而失效。如果完全依赖锁,极端情况下还是会漏。最稳的组合是:分布式锁挡并发 + 唯一索引挡持久化重复 + 异常提示友好化。这也是我在订单、支付、库存这一类核心接口里一直坚持的结构。
5. 最容易出问题的细节:事务、锁、幂等记录的先后顺序
方案本身不复杂,真正让我在项目里熬夜排查的,往往是“先后顺序”问题。下面这几个坑,我相信很多做过 Spring Boot 事务开发的人都遇到过。
5.1 先删 Token 再执行业务,业务失败后用户被卡死
假设提交请求后,后端先消费 Token,再创建订单。结果订单创建失败抛了异常,事务回滚。Token 已经被删了,用户在页面上重新点击提交,又会因为 Token 不存在而被拒绝,相当于一次业务失败把用户的提交资格也带走了。
更合理的做法是把 Token 的消费放到业务执行、事务提交之后。如果业务失败,用户还可以拿原 Token 重试一次,或者重新申请 Token。
5.2 锁先释放,事务后提交:并发窗口重现
这是最隐蔽的一个坑。很多开发者在方法里这么写:
try { lock.lock(); orderMapper.insert(order); // 这里只是执行了 SQL,事务还没提交 } finally { lock.unlock(); }事务正常情况下会在方法退出、切面生效后提交。但finally里的锁释放发生在事务提交之前。也就是说:线程 A 插入订单后释放了锁;线程 B 在同一时间拿到锁,也开始插入订单。此时 A 的事务可能还没提交,B 查询数据库看不到 A 的数据,于是校验通过,再次插入成功。重复数据就这么产生了。
这种情况压测很难复现,因为它依赖“事务提交延迟 + 并发抢锁”同时发生,但线上并发量一大,就变成偶发故障。
5.3 解决顺序问题的标准做法
我的标准做法是:把锁释放和 Token 删除放到事务提交后的回调里。
Spring 的TransactionSynchronizationManager提供了afterCommit回调,可以在事务提交后执行清理动作:
@Transactional public void createOrder(OrderCreateRequest request) { // 业务数据插入 orderMapper.insert(order); // 注册事务提交后的回调 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { // 释放锁 / 删除token / 更新幂等记录状态 releaseLock(lockKey, requestId); consumeToken(tokenKey); } }); }这样能保证:业务数据真正落库之后,才放行其他请求。如果事务回滚了,锁没释放、Token 没删除,用户重试时仍可以重新走完整流程,不会出现“卡死”。
5.4 幂等记录建议和业务同事务,但不要让它成为瓶颈
幂等记录的插入一定要和业务操作放在同一个事务里,否则前面说的“记录成功、业务失败”问题会反复出现。插入时捕获DuplicateKeyException,翻译成友好提示。
另外,幂等记录表会越积越大。建议给它加一个定期清理策略,或者按天分表。如果业务量很大,可以在唯一索引命中的时候直接把状态更新为成功,而不是每次都 insert 一条新记录,减少表体积膨胀。
6. 不同体量项目的可落地组合和验证方法
方案讲了不少,最后给出一套我在实际项目里经常采用的组合矩阵,以及验证方法。你可以按团队体量和业务场景去选。
6.1 不同业务场景的推荐组合
| 业务场景 | 前端处理 | 服务端防重 | 数据兜底 |
|---|---|---|---|
| 普通表单提交(单机部署) | 按钮 loading + 防抖 | Token 一次性消费 | 业务表加唯一索引 |
| 订单、支付接口(分布式部署) | 按钮 loading + 页面跳转 | Redis 分布式锁 + 请求唯一ID | 幂等记录表 + 唯一索引 |
| 支付回调、消息消费 | 无前端 | 根据业务单号查状态幂等处理 | 业务状态更新带上条件 |
| 秒杀、库存扣减 | 按钮置灰 + 订单结果页 | 分布式锁 + 用户维度限流 | 数据库乐观锁/版本号 |
比如一个普通内部管理系统的日志提交接口,用“前端防抖 + Token”就足够了,不需要上分布式锁。而一个用户直接面对的下单接口,我会上全套:前端 loading、后端 Token、Redis 锁、幂等表和唯一索引,缺一不可。
6.2 压测验证怎么做
很多项目验收时只测“功能能跑”,不测“重复提交是否真的拦住”。我建议至少写一个并发验证的冒烟测试:
- 用 Jmeter 或自己写一个多线程程序,设置 20 个线程同时发起同一个订单号的下单请求。
- 断言结果中成功数 = 1,其余返回重复提交异常。
- 查询数据库,确认幂等表和订单表里各只有一条记录。
- 再模拟一次“业务执行 2 秒”的场景,观察锁的持有时间和释放时间,确保事务提交后锁才被释放。
我自己就遇到过压测时 20 个线程全部打进来、数据库唯一约束虽然挡住了重复,但服务端抛了一大堆 DuplicateKeyException,导致接口成功率看起来很低的情况。后来加上分布式锁和友好的重复提交提示,压测结果才正常。
6.3 上线前最后过一遍这份自查清单
根据经验,我会在代码评审时重点检查这几个点:
- 唯一索引是否真的建上了,是不是只依赖了主键 ID。
- 幂等记录和业务表是否在同一个事务里。
- 锁的 value 是否携带唯一请求 ID,释放时有没有做身份校验。
- Token 消费和删除是否为原子操作。
- 分布式锁的 TTL 是否大于业务最大执行时间。
- 事务提交之前,锁和 Token 是否被提前释放或删除。
这六条如果都能确认没问题,重复提交这个老大难基本就能被稳稳摁住。最后说句实在话:防重复提交没有放之四海皆准的银弹,但把“前端体验层 + 服务端令牌层 + 数据库兜底层”组合起来,再处理好事务提交顺序,绝大多数项目都能在可控成本内解决到位。