1. 从一个真实场景说起为什么表单重复提交是个必须解决的问题做过Web开发的人几乎都遇到过这类问题用户点击“提交订单”按钮页面卡了一下用户以为没点上又点了一次结果后台生成了两笔一模一样的订单。或者网络延迟导致浏览器自动重试接口被调了两次库存扣了两回。这类问题在单体应用里可能只是偶发但在微服务架构、分布式系统里几乎就是必然会踩的坑。“防止表单重复提交”这个命题表面上看是一个前端交互问题实际上它的核心落在后端——接口幂等设计。所谓幂等简单说就是同一个请求执行一次和执行N次对系统产生的影响是一样的。这个概念来自数学f(f(x)) f(x)放到接口层面就是不管你调多少次最终的业务状态只应该变化一次。这篇文章要聊的就是怎么从架构层面系统性地解决这个问题。适合有一定后端开发经验、正在做API设计或者正在被重复提交问题困扰的开发者。我会从方案选型、核心原理、代码实现、踩坑经验几个维度展开尽量把每个决策背后的“为什么”讲清楚。2. 方案选型几种主流思路的对比与取舍2.1 前端拦截最省事但最不可靠最直觉的做法是在前端做文章。用户点击按钮后立刻禁用按钮或者加一个loading遮罩让用户没法再点。这种方式实现成本极低几行代码就能搞定。但它的问题也很明显前端拦截只能防“君子”防不了“小人”。用户刷新页面、用Postman直接调接口、网络层自动重试这些情况前端根本管不住。所以前端拦截只能作为第一道防线用来提升用户体验绝不能作为唯一的解决方案。我一般会建议团队前端该做的要做但后端必须兜底。两层防护各司其职。2.2 数据库唯一约束简单粗暴但有效第二种思路是利用数据库的唯一索引。比如订单表里用“用户ID商品ID时间戳”做一个唯一键重复插入就会报错。这种方式的好处是绝对可靠因为数据库层面的约束是最终的、不可绕过的。但它的局限性也很明显只适用于“插入”场景对于“更新”操作就无能为力了。而且唯一键的设计需要仔细考量粒度太粗会误伤正常请求粒度太细又起不到防重的作用。2.3 Token机制灵活可控的主流方案目前业界最常用的方案是Token机制。核心思路是客户端在提交表单之前先向服务端申请一个一次性Token服务端把这个Token存起来通常放Redis提交时带上这个Token服务端校验并删除。如果同一个Token被用了第二次校验就会失败。这个方案的优点是灵活、通用不依赖具体的数据库约束适用于各种业务场景。缺点是多了一次网络交互而且Token的存储和过期策略需要设计好。2.4 分布式锁重场景下的选择在分布式环境下还可以用分布式锁来保证同一时刻只有一个请求能执行。比如用Redis的SETNX命令或者用ZooKeeper的临时节点。分布式锁更适合“并发防重”的场景也就是两个请求几乎同时到达的情况。它的粒度通常比Token更细可以精确到某个用户、某个资源。但分布式锁的引入会增加系统复杂度需要处理锁超时、锁误删等问题不是所有场景都值得上。2.5 方案对比与选型建议方案可靠性实现复杂度适用场景主要局限前端拦截低极低所有场景的辅助手段可被绕过数据库唯一约束高低插入类操作不适用更新操作Token机制高中表单提交、API调用需额外存储分布式锁高高高并发并发防重复杂度高我的建议是Token机制为主数据库唯一约束为辅前端拦截做体验优化。这三层组合起来能覆盖绝大多数场景。分布式锁只在特定高并发场景下才需要引入。3. Token机制的核心细节与实操要点3.1 Token的生成与存储Token的生成方式有很多种可以用UUID也可以用雪花算法甚至可以用“用户ID时间戳随机数”拼接后做哈希。关键要求是全局唯一且不可预测。如果用UUID建议用版本4它是基于随机数生成的碰撞概率极低。存储方面Redis是最常见的选择。原因有三一是Redis的读写性能足够高不会成为瓶颈二是Redis支持自动过期天然适合Token这种临时凭证三是Redis的原子操作如DEL、GETDEL能保证校验和删除的原子性。存储结构我一般用简单的String类型key是Token值value可以是用户ID或者业务标识方便排查问题。过期时间根据业务场景设定一般5到30分钟比较合理。太短了用户还没提交就过期了太长了又失去了防重的意义。注意Token的过期时间一定要设置否则Redis里会堆积大量无效Token既浪费内存又增加管理成本。3.2 校验与删除的原子性这是Token机制里最容易出问题的地方。很多人的实现是这样的先GET判断Token是否存在如果存在再DEL。这两步操作不是原子的在高并发下会出现问题两个请求同时GET到Token存在然后都执行了DEL结果两个请求都通过了校验。正确的做法是用Redis的GETDEL命令Redis 6.2支持或者用Lua脚本把GET和DEL包在一起执行。如果Redis版本较低可以用DEL的返回值来判断DEL命令会返回被删除key的数量如果返回1说明删除成功返回0说明key不存在。-- Lua脚本实现原子校验并删除 local token KEYS[1] local value redis.call(GET, token) if value then redis.call(DEL, token) return value else return nil end这个脚本在Redis中执行时是原子的不会被打断。调用时把Token作为KEYS传入即可。3.3 Token的传递方式Token可以通过多种方式传递给服务端放在请求头里、放在表单隐藏域里、放在URL参数里。我推荐放在请求头里比如自定义一个X-Idempotent-Token头。这样做的好处是Token不会出现在URL里避免被日志记录或者被浏览器历史记录保存。如果是在传统的表单提交场景非Ajax那就只能放在隐藏域里了。这种情况下要注意对Token做HTML转义防止XSS攻击。3.4 业务标识的绑定单纯的Token只能保证“这个Token只用一次”但没法保证“这个Token只能用于这个业务”。比如用户A拿到了Token理论上可以用它去提交用户B的表单。所以在存储Token的时候最好把业务标识也存进去校验时一并检查。具体做法是value存一个JSON包含用户ID、业务类型、业务参数哈希等信息。校验时先比对用户ID是否匹配再比对业务参数是否一致。这样即使Token泄露攻击者也没法用它来提交其他业务。4. 完整实操流程从申请Token到提交校验4.1 整体交互时序整个流程分三步客户端加载表单页面时向服务端申请一个Token服务端生成Token存入Redis返回给客户端客户端提交表单时带上Token服务端校验并删除这个流程看起来简单但每一步都有细节需要注意。下面我以一个订单提交场景为例把关键代码写出来。4.2 服务端生成Token的接口GetMapping(/api/idempotent/token) public ResultString generateToken(RequestParam Long userId, RequestParam String bizType) { // 生成唯一Token String token UUID.randomUUID().toString().replace(-, ); // 构建存储值绑定用户和业务类型 MapString, Object tokenData new HashMap(); tokenData.put(userId, userId); tokenData.put(bizType, bizType); tokenData.put(createTime, System.currentTimeMillis()); // 存入Redis设置10分钟过期 String redisKey idempotent:token: token; redisTemplate.opsForValue().set(redisKey, JSON.toJSONString(tokenData), 10, TimeUnit.MINUTES); return Result.success(token); }这段代码里Token的key加了业务前缀idempotent:token:方便在Redis里区分不同用途的key。过期时间设了10分钟这个值可以根据实际业务调整。value里存了用户ID和业务类型为后续校验做准备。4.3 提交接口的校验逻辑PostMapping(/api/order/submit) public Result submitOrder(RequestBody OrderRequest request, RequestHeader(X-Idempotent-Token) String token) { // 原子校验并删除Token String redisKey idempotent:token: token; String tokenData redisTemplate.execute(TOKEN_LUA_SCRIPT, Collections.singletonList(redisKey)); if (tokenData null) { return Result.fail(请勿重复提交); } // 解析Token数据校验业务归属 JSONObject data JSON.parseObject(tokenData); if (!data.getLong(userId).equals(request.getUserId())) { return Result.fail(Token与用户不匹配); } if (!data.getString(bizType).equals(order_submit)) { return Result.fail(Token与业务类型不匹配); } // 执行业务逻辑 return orderService.submit(request); }这里用了前面提到的Lua脚本做原子校验。校验通过后还要比对用户ID和业务类型确保Token没有被挪用。只有全部通过才真正执行业务逻辑。4.4 前端配合的实现前端在页面加载时调一次Token接口把Token存到变量里。提交时把Token放到请求头里。let idempotentToken ; // 页面加载时获取Token async function initToken() { const res await fetch(/api/idempotent/token?userId123bizTypeorder_submit); const data await res.json(); idempotentToken data.data; } // 提交表单 async function submitOrder(orderData) { const res await fetch(/api/order/submit, { method: POST, headers: { Content-Type: application/json, X-Idempotent-Token: idempotentToken }, body: JSON.stringify(orderData) }); // 提交后无论成功失败都重新获取Token await initToken(); return res.json(); }注意最后一步提交后要重新获取Token。因为原来的Token已经被消费掉了如果用户想再提交一次比如修改了订单内容就需要新的Token。4.5 参数计算与选择依据关于Token的过期时间我一般按这个公式估算过期时间 用户平均填写表单时间 × 3。比如一个订单表单用户平均填3分钟那过期时间设10分钟就比较合适。这样既不会因为太短导致用户还没提交就过期也不会因为太长导致Redis里堆积太多无效Token。关于Redis的内存占用可以这样估算假设每个Token的keyvalue大约200字节10万个并发用户同时持有Token占用内存约20MB。这个量级对Redis来说完全不是问题。但如果你的系统有百万级并发那就需要考虑分片或者用更紧凑的存储结构。5. 常见问题与排查技巧实录5.1 Token失效太快怎么办有同学反馈说用户填完表单提交时提示“请勿重复提交”排查发现是Token过期了。这种情况通常是过期时间设得太短。解决办法有两个一是延长过期时间二是前端在Token快过期时自动刷新。我一般会在前端加一个定时器在Token过期前1分钟自动重新获取。这样用户感知不到Token的存在体验更流畅。5.2 高并发下仍然出现重复提交如果用了Token机制还是出现重复提交大概率是校验和删除不是原子的。检查一下你的代码是不是先GET再DEL。如果是改成Lua脚本或者GETDEL命令。还有一种可能是Redis主从切换导致的数据丢失。Token刚写入主节点还没来得及同步到从节点主节点挂了从节点升主Token就丢了。这种情况下校验会失败但不会导致重复提交。真正会导致重复提交的是相反的情况Token已经被删除但从节点还没同步读请求打到从节点又读到了旧的Token。解决办法是Token的读写都走主节点或者用RedLock这类更严格的分布式锁。5.3 用户刷新页面导致Token丢失用户填了一半刷新页面原来的Token就丢了。重新加载页面会获取新Token这没问题。但如果用户是在提交过程中刷新可能第一次提交已经成功了第二次提交带着新Token又执行了一遍。这种情况需要在业务层面做兜底。比如订单提交接口可以用“用户ID商品ID时间窗口”做唯一约束即使Token校验通过了数据库层面也能拦住重复订单。5.4 常见问题速查表问题现象可能原因排查方法解决方案提示重复提交但实际是首次Token过期检查Redis中key是否存在延长过期时间或前端自动刷新高并发下重复提交校验删除非原子检查代码是否先GET再DEL改用Lua脚本或GETDEL刷新页面后重复提交Token重新生成查看日志中Token是否不同业务层加唯一约束兜底Token被挪用未绑定业务标识检查value中是否含用户ID存储时绑定用户和业务类型Redis内存暴涨Token未设过期检查TTL是否为-1生成Token时必须设过期时间5.5 几个容易忽略的细节第一个细节是Token的清理。虽然Redis会自动过期但如果你的业务量很大过期时间又设得比较长内存占用还是会比较可观。可以加一个定时任务定期扫描并清理过期的Token。不过Redis的惰性删除定期删除机制通常够用了除非你有特殊需求。第二个细节是日志记录。每次Token校验失败时建议记录一条日志包含Token值、用户ID、请求IP、时间戳。这样出现问题时可以快速定位是哪个环节出了岔子。但注意不要把完整的Token打到日志里可以只打前8位避免安全风险。第三个细节是降级策略。如果Redis挂了怎么办是直接放行所有请求可能导致重复提交还是拒绝所有请求影响可用性这个需要根据业务场景权衡。对于订单这类核心业务我倾向于拒绝请求让用户稍后重试。对于非核心业务可以降级为放行同时记录日志告警。6. 进阶话题幂等设计的更多思考6.1 幂等键的设计原则Token本质上是一个幂等键。设计幂等键时有几个原则值得遵循唯一性同一个业务操作幂等键必须唯一。不同业务操作的幂等键必须不同。可追溯通过幂等键能追溯到具体的业务操作。比如从Token的value里能看出是哪个用户、哪个业务。时效性幂等键应该有过期时间不能永久有效。否则随着时间推移存储成本会越来越高。不可预测幂等键不能被轻易猜到否则攻击者可以伪造。UUID或者加密哈希都是不错的选择。6.2 幂等与并发的区别很多人把幂等和并发控制混为一谈其实它们是两个不同的问题。幂等解决的是“同一个请求被执行多次”的问题关注的是最终状态的一致性。并发控制解决的是“多个不同请求同时执行”的问题关注的是资源竞争和锁的问题。举个例子用户提交订单这是幂等问题。两个用户同时抢一件库存这是并发问题。前者用Token机制解决后者用分布式锁或者乐观锁解决。当然有些场景下两者会交织在一起需要组合使用。6.3 不同业务场景的幂等策略不是所有接口都需要幂等。查询接口天然幂等不需要额外处理。删除接口通常也幂等删一次和删多次结果一样。真正需要关注的是创建和更新操作。创建操作的幂等可以用Token机制也可以用唯一约束。更新操作的幂等通常用版本号或者状态机来实现。比如订单状态从“待支付”变成“已支付”可以用乐观锁UPDATE orders SET statuspaid WHERE id? AND statuspending。如果影响行数为0说明已经被更新过了直接返回成功即可。6.4 幂等设计的成本与收益引入幂等设计会增加系统的复杂度多了一次Token申请的网络交互多了一个Redis的依赖多了一些代码逻辑。这些成本是否值得取决于你的业务场景。对于金融、电商这类对数据一致性要求极高的场景幂等设计是必须的。对于内部管理系统、日志上报这类场景可能就不需要那么严格。我的建议是核心业务必须做非核心业务按需做。不要为了幂等而幂等过度设计反而会引入新的问题。7. 我在实际项目中的几点体会做了几个涉及幂等设计的项目之后我最大的体会是幂等不是一个技术问题而是一个业务问题。技术方案只是手段真正重要的是搞清楚业务上哪些操作需要幂等、幂等的粒度是什么、失败了怎么处理。另一个体会是不要指望一种方案解决所有问题。Token机制、唯一约束、分布式锁它们各有适用场景组合使用才能覆盖全面。我在实际项目中通常是Token机制打头阵数据库唯一约束做兜底前端拦截做体验优化三层防护下来基本没再出过重复提交的问题。最后分享一个小技巧在测试阶段可以用JMeter或者wrk这类工具模拟并发提交看看你的幂等方案能不能扛住。我一般会模拟100个并发请求同时提交同一个Token观察最终只有1个成功、99个失败这样才算验证通过。这个测试很简单但能发现很多隐藏的问题。
阅读完成 · 觉得有帮助?