自定义表单重复入库怎么办?从幂等锁到唯一索引的完整方案

自定义表单重复入库怎么办?从幂等锁到唯一索引的完整方案 做表单系统做久了几乎都会撞上同一类怪事客户那边明明只点了一次提交后台自定义表单里却躺了好几条一模一样的数据。去年我就在一个企业站后台遇到过用户报了个名数据库里多了七条重复记录时间全部集中在几秒以内。查完日志才发现用户在页面上点了保存没反应又点了两次前端因为接口超时自动重试了一次网关还额外做了一次重试一次正常提交被完整拆成了三份数据库自然照单全收。这个问题表面看是“多了一条”里子其实是整个提交链路压根没有幂等控制。这篇文章专门聊自定义表单场景下的重复入库问题。从问题定位、后端幂等校验、数据库唯一索引兜底、前端防连点这几个层面给出一套可以直接照抄的思路和代码。适合正在做管理后台表单模块的同学也适合被线上重复数据折磨过的全栈和运维兄弟参考。1. 先别急着改代码把重复入库的来源摸清楚1.1 重复提交到底是从哪一层冒出来的绝大多数重复入库不是单一原因造成的而是前端、网络、服务端三层的“巧合叠加”。我通常会把来源分成三类来判断。第一类是用户层面。页面提交按钮没有loading状态用户点一下没反应下意识再点一下移动端弱网时甚至可能连续点了五六下。这类重复的特征是请求时间间隔很短通常不到一秒而且大概率来自同一个IP和同一台设备。第二类是前端代码层面。按钮没有做防连点处理Axios请求发送后也没有拦截相同请求甚至有部分项目在接口超时后会自己写setTimeout重试。这类问题比用户手滑更严重因为同一段时间内前端会主动发出多条相同请求数据一模一样连requestId都没变。第三类是服务端和网络链路层面。接口处理慢导致客户端超时重试网关或Nginx配置了retry转发策略RPC框架自带了失败重试这些都会让原来只有一次的请求在服务端被重复执行。最坑的是这类重复往往有较长的时间间隔从几秒到几十秒不等单看应用日志很难一眼定位。1.2 怎么快速确认你遇到的是哪一类排查重复入库不要上来就写代码先看日志。我会按下面这几个步骤来定位打开后端接口日志按同一用户标识或同一表单ID筛出那几分钟内的所有提交记录观察时间间隔。时间间隔在几百毫秒内的优先怀疑用户连点和前端没做防重。时间间隔在几秒到十几秒并且请求内容完全一致优先查是不是超时重试或者网关重试导致的。打开浏览器F12开发工具切到Network面板重新提交一次看是不是真的连续发了两条请求。如果前端就发了两条问题大概率在前端。查Nginx或网关的access log看同一个请求是否被转发到后端多次。这里要特别留意HTTP方法、请求路径和Body内容。有一个核心认识必须先建立起来普通业务表单可以拿“用户ID 活动ID”做唯一索引但自定义表单不行。因为自定义表单的字段是动态配置的没有稳定不变的业务键可以拿来当幂等键。自定义表单场景下能拿来做幂等判定的唯一可靠凭证就是“当前这次提交动作本身”。换句话说我们需要为每次提交生成一个全局唯一的凭证让后端的每一次处理都认这个凭证。这就是全链路幂等的起点。2. 核心防线后端幂等校验为每次提交发一张“身份证”2.1 为什么选择“请求ID Redis分布式锁”先说结论我们给每次表单提交生成一个全局唯一的requestId前端把requestId带到后端后端基于Redis的SETNX原子操作做幂等校验。同一requestId的请求只有第一个能继续执行后续的都会被标记为重复提交。Redis的SETNX天然适合“只允许一个请求成功”这个语义。命令是原子的不管多少实例同时执行结果都一样。为什么不用synchronized或者本地锁因为现在后端基本是多个实例部署本地锁只能锁住一个进程挡不住多节点并发。为什么不在数据库里先查一遍再插可以做但会放大数据库压力而且在高并发下“先查后插”本身就存在竞态。自定义表单场景中这份“身份证”就是requestId我建议由前端生成UUID。这样做的原因是当请求在网络上超时重试时前端生成并缓存的requestId可以保持不变后端能识别出这是同一次提交。如果交给后端在接口入口生成客户端超时重试时后端根本收不到原请求重新生成的requestId就被当成一次全新提交了。2.2 幂等拦截器完整实现我先定义一个注解用来标记哪些接口需要幂等校验。Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface Idempotent { // 锁的过期时间单位秒默认30秒 long expire() default 30; }然后实现一个Spring拦截器在接口执行前完成幂等校验。Component public class IdempotentInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod (HandlerMethod) handler; Idempotent idempotent handlerMethod.getMethodAnnotation(Idempotent.class); if (idempotent null) { idempotent handlerMethod.getBeanType().getAnnotation(Idempotent.class); } if (idempotent null) { return true; } String requestId request.getHeader(X-Request-Id); if (StringUtils.isBlank(requestId)) { requestId request.getParameter(requestId); } if (StringUtils.isBlank(requestId)) { throw new BizException(400, 缺少请求ID); } String key idempotent:form: requestId; Boolean success redisTemplate.opsForValue() .setIfAbsent(key, requestId, idempotent.expire(), TimeUnit.SECONDS); if (success null || !success) { throw new BizException(400, 请勿重复提交); } } return true; } }在接口上只需要加一行注解PostMapping(/submit) Idempotent(expire 30) public Result submit(RequestBody FormSubmitReq req) { // 业务逻辑 }这套写法的关键点是setIfAbsent的三参数版本。如果不设置过期时间一旦业务处理过程中发生异常锁永远不会释放用户就永远无法提交了。设置了过期时间即使代码崩溃锁到点也会自动消失不会造成长久的死锁。2.3 锁释放的巨大坑位删除时机和原子性很多人拿到上面的代码喜欢在业务方法最后写一句redisTemplate.delete(key)这实际上埋着两个雷。第一个坑是“删锁顺序”。如果业务逻辑在一个事务里你在事务提交之前就把Redis锁删了第二个请求马上就能通过校验并尝试插入数据但第一个请求的数据还没提交数据库里查不到第二个请求就会继续执行插入最后两个事务都提交重复数据照样产生。正确做法是事务提交完成之后再删除锁最好在事务的afterCommit回调里执行或者确保锁的生命周期覆盖整个事务提交动作。第二个坑是“误删别人的锁”。如果第一个请求处理时间超过了锁过期时间锁会自动消失第二个请求此时会成功获取锁。如果第一个请求处理完后直接执行delete删除的其实是第二个请求的锁导致第二个请求的幂等校验失效。正确的删除必须校验value是不是自己写入的那个。这里推荐用Lua脚本保证“校验删除”的原子性private static final String UNLOCK_SCRIPT if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; public void unlock(String key, String requestId) { DefaultRedisScriptLong script new DefaultRedisScript(UNLOCK_SCRIPT, Long.class); redisTemplate.execute(script, List.of(key), requestId); }这套逻辑单独拿出来看并不复杂但在实际项目里我发现很多人忽略了“锁什么时候删”这个问题结果就是明明写了幂等校验重复数据却照样出现。记住幂等锁不是锁到“业务逻辑执行完”而是要锁到“事务提交完”。如果有外部调用比如发短信、推送MQ建议放在事务提交之后再执行避免长事务和长锁的连带问题。关于过期时间的设置也需要注意。30秒是我个人比较常用的值因为表单提交接口正常响应在几百毫秒到一两秒30秒足够覆盖前端的超时重试窗口。如果你设置了1秒第一次请求处理用了3秒锁早就失效了第二次请求照样能进来。如果设置太长比如10分钟用户在弱网环境真正重新提交一次会被系统误判为重复提交体验会很差。经验是过期时间至少大于接口的预期最大处理时间推荐设置为10到30秒之间。3. 数据库兜底自定义表单表加唯一索引最后一道闸门3.1 为什么有了Redis锁还要加唯一索引Redis锁能挡住绝大多数重复请求但它不是100%可靠。要知道Redis不是数据库它有自己的故障模式进程重启丢数据、网络分区导致锁不可用、key因为过期时间设置的bug提前失效。真遇到这种情况幂等校验就形同虚设。所以要有一道完全独立于应用逻辑的兜底防线那就是数据库层面的唯一约束。关系型数据库里的唯一索引是物理级的保证只要referenced字段重复数据库从底层就拒绝写入不管你应用代码想不想让它进去。在自定义表单场景下我们在提交记录主表上加一个request_id字段并为它建立唯一索引。这么做以后就算代码层的幂等锁全部失效同一个requestId的第二次插入也会被数据库直接拒绝重复数据根本进不了表。ALTER TABLE form_submit_record ADD COLUMN request_id varchar(64) NOT NULL DEFAULT COMMENT 全局请求ID AFTER id; CREATE UNIQUE INDEX uk_request_id ON form_submit_record (request_id);这里有一个非常重要的细节字段必须设置成NOT NULL DEFAULT 不要允许NULL。原因很简单MySQL的InnoDB引擎中唯一索引对NULL值默认是“多个NULL可以共存”的。只要你允许request_id为空业务端哪怕某一笔请求漏传requestId数据库也会让它插入成功唯一索引兜底立刻失效。用空字符串反而是安全的因为空字符串会被当成一个具体值多个空串仍然会触发唯一冲突。3.2 插入时捕获重复键异常别让数据库报错直接暴露给用户加了唯一索引之后重复提交的请求会在插入时抛异常。我们要在业务代码里把这个异常拦下来转换成友好的提示而不能让用户看到500错误。Transactional(rollbackFor Exception.class) public void submit(FormSubmitReq req) { FormSubmitRecord record new FormSubmitRecord(); // 设置表单字段和 requestId try { formRecordMapper.insert(record); } catch (DuplicateKeyException e) { if (e.getMessage().contains(uk_request_id)) { throw new BizException(400, 请勿重复提交); } throw e; } }这个方法里有个容易忽略的坑如果同一张表存在多个唯一索引比如你之前给手机号字段也建过唯一索引当手机号重复时也会抛出DuplicateKeyException。如果代码一股脑全部拦下来当成“重复提交”就会误伤正常业务。所以判断时要检查异常信息里是否包含我们指定的索引名uk_request_id或者直接检查错误码和底层SQLState确保只有requestId冲突才走幂等提示。唯一索引还会带来一个并发行为值得了解一下当请求A持有一个事务且插入了一个requestId但尚未提交时请求B尝试插入相同requestId并不会立刻报唯一冲突而是会阻塞等待请求A的事务提交或回滚。如果请求A长时间不提交请求B会一直阻塞。这个行为不是bug是InnoDB为了保证事务隔离性而做的锁等待。解决思路是控制事务内不做事耗操作比如不要把短信发送、第三方回调等放在插入同一个事务里事务里尽力只做数据库写操作。4. 前端联动防连点和请求去重同样要坐实4.1 按钮状态和防双击的常规操作后端做得再好前端也不能完全放弃防护。毕竟后端锁的意义是保证数据一致性而前端防重更多是改善用户体验、降低后端和Redis的无谓压力。最简单直接的方案就是提交按钮在请求期间置为加载态禁止再次点击。以Vue为例button :disabledsubmitting clickhandleSubmit {{ submitting ? 提交中... : 提交 }} /buttonasync handleSubmit() { if (this.submitting) return; this.submitting true; try { const requestId crypto.randomUUID(); await axios.post(/api/form/submit, { formId: this.formId, formData: this.formData, requestId }); } finally { this.submitting false; } }这里有两个容易犯的低级错误。第一个是只在提交成功时恢复按钮状态失败时忘记恢复导致用户再也无法提交。必须用finally去恢复。第二个是误以为登录态的全局拦截器能替代按钮自身的loading结果在某个页面漏掉了loading控制用户多点几下就又造出重复数据了。4.2 requestId的生成时机其实有讲究前端生成requestId看起来简单生成时机却容易踩坑。我见过两种做法进入页面时生成一次或者点击提交时生成一次。进入页面时生成一次的好处是同一页面短期内反复提交失败再提交requestId不变后端能识别为“同一提交意图”。坏处是如果用户在一个表单页面里要做多次独立的提交比如同一表单连续提交两份不同内容requestId复用会导致第二次提交被误判为重复。点击提交时生成一次的优点是每次都是新请求不容易误伤但弱网超时后用户手动再次点击时原来的requestId已经丢了第二次点击会被当成新请求如果第一次请求其实成功写库了就会产生重复数据。我的实际经验是判断业务场景。绝大多数自定义表单提交每个页面只对应一次有效提交用户提交成功后页面会跳转或关闭这时候推荐“生成一次并复用”策略——进入页面时生成requestId存放在闭包或状态中重复点击、失败重试都沿用同一个ID。只有那种明确支持“同一页面多次提交不同内容”的场景才在每次点击时重新生成。如果你拿不定主意稳妥做法是点击时生成配合数据库唯一索引兜底至少不会出现同一requestId误拦截的问题。5. 高并发场景与Redis故障时的降级策略5.1 多实例部署下分布式锁的必要性单机部署时synchronized或者ConcurrentHashMap能临时顶一下幂等校验但生产环境一旦是多实例部署本地锁根本不通用。你不可能让每个实例各自维护一份“已处理的requestId集合”因为请求会负载均衡地打到不同实例上A实例刚写入B实例并不知道。Redis分布式锁是这一场景下的标准解法。SETNX的QPS很高单个实例支撑表单提交这种量级毫无压力。如果担心Redis单点问题可以给Redis做高可用部署但对表单提交场景来说通常加一个数据库唯一索引兜底就足够了。在架构设计里我们叫它“控制面幂等 数据面兜底”即业务层的校验尽量前置但数据库层面永远有一个不会失效的底牌。5.2 Redis不可用时如何优雅降级Redis总有不可用的时候比如GC停顿、网络抖动、集群升级。这时候如果幂等校验直接抛异常用户的提交请求会被全部拒绝损失太大。更合理的降级策略是捕获Redis连接异常跳过业务层的幂等校验把防重复的责任完全交给数据库唯一索引。因为不管重复请求有多少个只要requestId相同数据库最后都会拒绝第二次插入。用户看到的结果可能依然是“提交成功”或“请勿重复提交”但不会产生脏数据。这个过程要小心一点降级时不能因为Redis锁没拿到就返回成功而是要继续走到业务逻辑让数据库去做最终裁决。有人会问那我在降级时维护一个本地ConcurrentHashMap缓存请求ID行不行只能说单实例内部有效多实例下没有意义而且内存记录还需要清理策略复杂度不低。我的建议是不要自己造轮子Redis不可用时直接信任数据库唯一索引足够简单且可靠。6. 常见问题与排查速查表6.1 典型问题与解决方案对照我把实际项目中遇到过的问题和对应的解决方案整理成了一个表方便排查时直接查。问题现象可能原因处理办法加了Redis锁仍然重复入库锁过期时间设置太短业务处理未完成锁已消失延长过期时间到10秒以上并为表加唯一索引兜底请求ID不同但数据内容完全一样客户端每次点击都重新生成requestId改用进入页面生成一次的策略或者结合用户ID做业务幂等数据库唯一索引存在但重复仍然发生字段允许NULL多个NULL未被唯一索引拦截将字段改为NOT NULL DEFAULT 重建索引前端连续点击两次第一次请求未结束第二次已发起按钮没有loading或disabled控制在请求期间限制按钮状态使用标志位防重入接口超时后客户端重试导致重复网关或前端超时重试机制确认重试时复用requestId关闭不安全的自动重试请求能被幂等锁拦截但用户反馈点击提交没反应锁过期时间过长用户合法重试被误判为重复缩短过期时间到30秒以内合理设置锁的语义6.2 踩坑记录一些值得写进团队规范的经验我第一次给自定义表单加幂等处理时图省事用“时间戳 随机数”拼了一个requestId结果线上出现了两条重复记录查了很久才发现是两个请求撞了同一个ID。后来统一改成UUID全横线格式再没出过这个问题。严格说UUID碰撞概率极低但既然有现成的标准算法没有必要自己搞拼拼凑凑的方案。还有一个经验是关于锁的“覆盖范围”。有些人把分布式锁只放在Controller层的某一段代码上结果业务里有一段异步流程在锁释放后才开始执行最后同样产生了重复数据。我的习惯是锁的范围必须覆盖从接收请求到事务提交的全部业务路径异步操作如果不能同步等待结果就尽量放到幂等成功之后再触发。做成这整套方案之后我自己的经验是不要指望某一个手段能100%解决重复入库“前端防连点 后端Redis幂等锁 数据库唯一索引”这三层缺一不可。其中数据库唯一索引反而是我最先落地的因为代码没改完、Redis没配好也完全不影响它兜底新的重复数据从索引生效那一刻就会被拒之门外。这套顺序值得推荐给所有在做表单系统的人。