在 CRUD 应用中防止重复记录需要多层防护:数据库唯一约束、幂等键,以及防止重复提交的 UI 状态。

重复记录就是你的应用把同一件事存了两次。可能是同一次结账生成了两笔订单、两张内容相同的支持工单,或者在同一注册流程中创建了两个账户。在 CRUD 应用里,重复记录通常看起来像普通的独立行,但从整体数据角度看它们就是错误的。
大多数重复是从正常操作开始的。有人因为页面感觉慢而点击了两次创建。移动端上双击很容易被忽略。即使是小心的用户,如果按钮看起来仍然可用且没有明确的正在进行提示,也会再试一次。
然后是更混乱的中间层:网络和服务器。请求可能超时并被自动重试。某些客户端库在认为第一次尝试失败时会重复发送 POST。第一次请求可能已经成功,但响应丢失了,用户再试一次就会创建第二条。
不能只靠一层来解决这个问题,因为每一层只看到部分情况。UI 可以减少意外的重复提交,但不能阻止不良连接导致的重试。服务器可以检测重复,但需要可靠的方法来识别“这是同一次创建的重试”。数据库可以强制执行规则,但前提是你已经定义了什么叫“同一件事”。
目标很简单:即便相同的请求发生两次,也要让创建安全。第二次尝试应该变成无操作(no-op)、干净的“已创建”响应,或者受控的冲突,而不是第二行数据。
很多团队把重复当作数据库的问题。实际上,重复通常在更早的时候产生,也就是同一次创建动作被触发多次的时候。
用户点击“创建”后什么也没发生,又点了一次。或者先按回车,再点按钮。移动端上会出现两次快速点击、重叠的触摸和点击事件,或者某个手势被识别了两次。
即便用户只提交了一次,网络也可能重复请求。超时可能触发重试。离线应用可能在重新连接时重新发送队列中的“保存”操作。有些 HTTP 库会在特定错误上自动重试,你可能直到看到重复行才知道发生了什么。
服务器有时会故意重复工作。作业队列会重试失败的任务。Webhook 提供者常常会多次发送同一事件,尤其是当你的端点较慢或返回非 2xx 状态时。如果你的创建逻辑由这些事件触发,就要假设会出现重复。
并发会产生最隐蔽的重复。两个标签页在毫秒级别内提交相同表单。如果你的服务器先做“是否存在?”再插入,两次请求都可能在任何一方插入前通过检查。
把客户端、网络和服务器视为重复的独立来源。你需要在这三处都布置防御。
如果你只想要一个可靠的位置来阻止重复,把规则放在数据库里。UI 修复和服务器检查会有帮助,但在重试、延迟或两个用户同时操作时它们可能失效。数据库唯一约束是最终权威。
先选一个与人们对记录的认知一致的现实世界唯一性规则。常见示例:
对看起来唯一但并非真正唯一的字段要小心,比如全名。
确定规则后,用唯一约束(或唯一索引)在数据库中强制执行它。这会让数据库在两次几乎同时到达的插入出现违反时拒绝第二次插入。
当约束触发时,决定用户应看到什么。如果创建重复总是错误的,就以明确信息阻止它(例如“该邮箱已被使用”)。如果重试常见且记录已存在,通常更好的做法是把重试当作成功并返回现有记录(例如“你的订单已创建”)。
如果你的创建语义是真正的“创建或重用”,upsert(插入或更新)可能是最干净的模式。例如“按邮箱创建客户”可以插入新行或返回已有行。只有在匹配业务含义时才使用 upsert。如果针对同一键可能到达略有不同的载荷,就要决定哪些字段允许更新,哪些字段必须保持不变。
唯一约束不能取代幂等键或良好的 UI 状态,但它提供了一个其余层都可以依赖的硬性保护。
幂等键是一个代表一次用户意图的唯一标记,比如“只创建这笔订单一次”。如果同样的请求再次发送(双击、网络重试、移动端恢复),服务器应把它视为重试,而不是新的创建。
这是在客户端无法判断第一次尝试是否成功时,使创建端点安全的最实用工具之一。
最受益的端点是那些重复代价高或会造成混淆的流程,例如订单、发票、支付、邀请、订阅以及会触发邮件或 webhook 的表单。
在重试时,服务器应返回第一次成功尝试的原始结果,包括相同的创建记录 ID 和状态码。为此,需要保存一条以(用户或账户)+ 端点 + 幂等键为键的小型幂等记录。既要保存最终结果(记录 ID、响应体),也要记录“进行中”状态,以防两次几乎同时的请求都创建到新行。
把幂等记录保留足够长的时间以覆盖真实的重试窗口。常见基线是 24 小时。对于支付类流程,许多团队会保留 48–72 小时。TTL 可以让存储受控并与可能的重试时长一致。
如果你用像 Koder.ai 这样的对话式生成器来生成 API,仍然要显式支持幂等性:接受客户端发送的键(头或字段),在服务端强制“相同键返回相同结果”。
幂等性让创建请求可以安全重复。如果客户端因为超时重试(或用户点了两次),服务器会返回相同结果而不是创建第二条记录。
Idempotency-Key)里很方便,放在 JSON 体里也可以。关键细节在于“检查 + 存储”必须在并发下安全。实践中,经常对 (scope, key) 的幂等记录添加唯一约束,并把冲突视为重用信号。
if idempotency_record exists for (user_id, key):
return saved_response
else:
begin transaction
create the row
save response under (user_id, key)
commit
return response
举例:某客户点击“创建发票”,应用发送键 abc123,服务器创建了发票 inv_1007。如果手机信号中断并重试,服务器会返回相同的 inv_1007 响应,而不会生成 inv_1008。
测试时不要只停留在“防双击”的场景。模拟客户端在请求超时但服务器已完成的情况下再次重试,并用相同的键重试。
服务器端防护很重要,但许多重复始于用户的正常但重复操作。良好的 UI 让安全路径显而易见。
在用户提交后立即禁用提交按钮。从第一次点击就禁用,而不是在验证后或请求开始后才禁用。如果表单可以通过多种控件提交(按钮与回车),锁定整个表单状态,而不仅仅是一个按钮。
显示清晰的进度状态,回答一个问题:正在进行吗?一个简单的“正在保存…”标签或旋转图标就足够。保持布局稳定,避免按钮跳动诱发第二次点击。
一套简单规则能阻止大多数重复提交:在提交处理开始时设置 isSubmitting 标志,在它为真时忽略新的提交(包括点击与回车),并在收到真实响应前不要清除它。
慢响应时很多应用会出错。如果你在固定计时器(例如 2 秒)后重新启用按钮,用户可能在第一次请求仍在进行时又提交一次。只有在尝试完成后才重新启用。
成功后,尽量避免用户再次提交。跳转到新记录页面或列表,或显示带已创建记录的清晰成功状态。避免在屏幕上保留同样填写好的表单并且按钮仍然可用的状态。
最棘手的重复 bug 来自日常“奇怪但常见”的行为:两个标签页、刷新,或手机丢失信号。
首先要正确限定唯一性的范围。“唯一”很少意味着“在整个数据库中唯一”。它可能意味着每个用户唯一、每个工作区唯一或每个租户唯一。如果你与外部系统同步,可能需要基于外部来源及其外部 ID 来限定唯一性。一个安全的做法是把你真正想要的唯一性写成一句话(例如“每个租户每年一个发票号”),然后在数据库中强制执行它。
多标签行为是经典陷阱。UI 的加载状态只对单个标签有用,但跨标签无效。这时服务器端的防护必须能独立支撑。
后退和刷新也会触发意外的重新提交。成功创建后,用户常常刷新页面“确认”,或按后退并重新提交仍然看起来可编辑的表单。倾向于展示已创建的视图而不是保留原表单,并让服务器处理安全的重放。
移动端带来中断:切到后台、不稳定网络和自动重试。请求可能已经成功,但应用没有收到响应,于是恢复时再次尝试。
最常见的失败模式是把 UI 当作唯一防线。禁用按钮和加载动画有用,但无法覆盖刷新、不稳定的移动网络、用户打开第二个标签页或客户端 bug。服务器与数据库仍需能够判断“这个创建已经发生过”。
另一个陷阱是为错误的字段设置唯一性。如果你在不是真正唯一的字段上加了唯一约束(姓氏、四舍五入的时间戳、自由格式标题),会阻止合法记录。相反,使用真实标识符(例如外部提供者 ID)或有范围限制的规则(每用户、每天或每父记录唯一)。
幂等键也容易被错误实现。如果客户端在每次重试时都生成一个全新的键,就会每次都创建新记录。要在整个用户意图的生命周期内保持相同的键。
还要注意重试时返回的内容。如果第一次请求创建了记录,重试应返回相同结果(至少相同记录 ID),而不是模糊错误,让用户再次尝试。
如果唯一约束阻止了重复,不要用“出了点问题”来掩盖。用平实的语言说明发生了什么:"此发票编号已存在。我们保留了原记录,未创建第二条。"
发布前做一次专门针对重复创建路径的快速检查。最好的效果来自叠加防护,这样一次漏点、重试或慢网络也无法创建两条记录。
确认三件事:
一个实用的直觉测试:打开表单,快速点击两次提交,中途刷新再继续尝试。如果你能创建两条记录,真实用户也会。
想象一个小型发票应用。用户填写一张新发票并点击创建。网络很慢,屏幕没有马上变化,他们又点了创建。
只有 UI 保护时,你也许会禁用按钮并显示旋转图标。这有帮助,但仍不足。某些设备上双击可能穿透防护,超时后重试仍会发生,或者用户可能从两个标签页提交。
只有数据库唯一约束时,你可以阻止精确重复,但用户体验可能很糟糕。第一次请求成功,第二次因约束失败,用户看到错误,虽然发票已经创建。
最干净的结果是幂等键加唯一约束:
第二次点击后的简单 UI 提示可以是:“发票已创建 — 我们忽略了重复提交并保留了第一次请求。”
打好基础后,下一步的改进集中在可见性、清理和一致性上。
为创建路径添加轻量级日志,这样你可以区分真实用户动作和重试。记录幂等键、相关唯一字段以及结果(已创建 vs 返回已有 vs 拒绝)。不需要重型工具也能开始。
如果已有重复存在,用明确规则和审计轨迹来清理。比如保留最早的记录为“胜者”,把相关行(支付、明细)重新关联,把其他记录标记为已合并而不是删除。这样支持和报表会更简单。
把你的唯一性和幂等性规则写在一个地方:什么是唯一、作用域是什么、幂等键保留多久、错误长什么样子、UI 在重试时应如何表现。这样可以防止新端点无意间绕过安全措施。
如果你在 Koder.ai (koder.ai) 上快速构建 CRUD 界面,值得把这些行为作为默认模板的一部分:在 schema 中加入唯一约束,在 API 中实现幂等创建端点,并在 UI 中加入清晰的加载状态。这样速度就不会以脏数据为代价。
重复记录是当同一个现实世界的实体被存储了两次,例如针对一次结账生成了两笔订单,或者为同一问题创建了两个工单。它通常发生于同一次“创建”操作被执行多次,原因包括用户重复提交、重试机制或并发请求。
因为第二次创建可以在用户不注意时被触发,例如移动端的双击、按回车后再点按钮等。即便用户只提交了一次,客户端、网络或服务器的重试也可能再次发起创建请求,所以不能假设“POST 只执行一次”。
不能完全依赖。禁用按钮并显示“正在保存…”可以减少误点,但无法阻止来自不稳定网络的重试、刷新、多标签页或 webhook 重发等情况。你还需要服务器和数据库层面的防护。
数据库唯一约束是最后一道防线,它能在两个插入同时到达时阻止第二条记录入库。把规则设在数据库时,确保它反映真实的业务唯一性(通常是有范围限制的,比如每个租户或每个工作区内唯一)。
两者都需要。唯一约束根据字段规则阻止基于值的重复(比如发票编号),而幂等键则保证同一次创建意图在重试时返回相同结果。二者结合既能保证数据正确,又能给出更好的重试体验。
为每次用户意图(例如点击“创建发票”)生成一个键,在该次操作的所有重试中重用它。它应在超时或应用恢复时保持不变,但不要用于不同的创建意图。
在服务端按作用域(例如用户或账户)、端点和幂等键保存一条幂等记录,并把首次成功请求返回的响应存下来。如果相同键再次到达,返回保存的响应和相同的记录 ID,而不要再创建新行。
采用并发安全的“检查 + 存储”方式,通常对幂等记录本身在(作用域 + 键)上加唯一约束。这样两次几乎同时到达的请求不会都认为自己是“第一个”,其中一个会改为重用已保存的结果。
保留幂等记录的时间应覆盖真实的重试窗口,常见默认是约 24 小时;对支付类流程可延长到 48–72 小时。为幂等记录设置 TTL 可以限制存储增长,并与客户可能重试的时间匹配。
当明确是同一意图的重试时,把它视为成功重试并返回原始创建记录(相同 ID),不要只给出模糊错误。如果是必须唯一的情况(例如邮箱),则返回清晰的冲突信息,说明已经存在什么以及接下来发生了什么。