Spring Boot防重复提交实战:从按钮锁到分布式锁的完整方案

📅 发布时间:2026/10/13 12:54:52
Spring Boot防重复提交实战:从按钮锁到分布式锁的完整方案
先讲一个我踩过的坑。之前负责某商城的下单接口开发环境怎么点都没问题上线当晚用户反馈“订单重复创建”查日志发现同一个用户 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 :disabledsubmitting clicksubmitOrder {{ 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) { DefaultRedisScriptLong 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 EXString 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) { DefaultRedisScriptLong 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 是否被提前释放或删除。这六条如果都能确认没问题重复提交这个老大难基本就能被稳稳摁住。最后说句实在话防重复提交没有放之四海皆准的银弹但把“前端体验层 服务端令牌层 数据库兜底层”组合起来再处理好事务提交顺序绝大多数项目都能在可控成本内解决到位。