CRUD 应用中的竞态条件可能导致重复订单和错误的总计。了解常见冲突点以及通过约束、锁和 UX 防护的实用修复方法。

当两个(或更多)请求几乎同时更新相同的数据,且最终结果依赖于时序时,就会发生竞态条件。每个请求单独看都没问题,但放在一起就会产生错误的结果。
一个简单的例子:两个人在同一秒钟内点击保存同一条客户记录。一个更新了邮箱,另一个更新了电话号码。如果两次请求都提交了完整记录,第二次写入可能会覆盖第一次,导致一个更改无声无息地丢失。
在响应快速的应用中你更容易遇到这种情况,因为用户可以在单位时间内触发更多操作。它也会在繁忙时段激增:闪购、月末报表、大规模邮件活动,或者任何一段请求积压打到同一行时。
用户很少会报告“竞态条件”这个术语。他们报告的是症状:重复的订单或评论、丢失的更新(“我保存了,但它恢复了”)、奇怪的总计(库存变为负数、计数器回退),或状态意外翻转(被批准,然后又回到待处理)。
重试会让问题更糟。用户双击、在慢响应后刷新、从两个标签页提交,或在网络不稳时让浏览器/移动端重发请求。如果服务器把每个请求都当作新的写入,你可能会出现两个创建、两次扣款或两次应只发生一次的状态变更。
大部分 CRUD 应用看起来很简单:读一行,改一个字段,保存。但问题是你的应用无法控制时序。数据库、网络、重试、后台作业和用户行为都会重叠发生。
一个常见触发点是两个人同时编辑同一条记录。两人都加载了相同的“当前”值,都做了有效更改,最后保存的那次会默默覆盖前一次。没有人做错事,但一次更新丢失了。
单个用户也会造成同样的问题。保存按钮被双击、前后快速切换、或慢连接促使用户再次按提交,都可能发送相同的写入两次。如果端点不是幂等的,你可能会创建重复项、重复扣款或状态前进两步。
现代使用场景增加了更多重叠。多个标签页或设备在同一账号登录会触发冲突更新。后台任务(发送邮件、计费、同步、清理)可能会触碰与网页请求相同的行。客户端、负载均衡器或任务运行器的自动重试可能会重复已经成功的请求。
如果你在快速发布功能,同一条记录往往会从比预期更多的地方被更新。如果你使用像 Koder.ai 这样的聊天驱动构建器,应用增长可能更快,因此把并发当作常态而不是边缘情况是值得的。
竞态条件很少在“创建记录”的演示里出现。它们出现在两个请求几乎同时触及同一事实源时。知道常见热点有助于你从一开始就设计安全的写入。
任何看起来像“就加 1”的东西在高并发下都可能崩溃:点赞、浏览计数、合计、发票号、票号。危险的模式是先读取值、加上增量、然后写回。两个请求可能读取到相同的起始值并互相覆盖。
像 草稿 -> 提交 -> 审核通过 -> 已付款 这样的工作流看似简单,但碰撞很常见。当两种操作几乎同时可执行(例如同时批准和编辑,或取消和付款)时,问题就来了。没有防护,你可能得到跳过步骤、回退或在不同表中显示不同状态的记录。
把状态变更当成契约:只允许下一步合法的转换,拒绝其他任何请求。
剩余座位、库存数量、预约时段和“剩余容量”字段造成经典的超卖问题。两个买家同时结账,都看到了可用性,最终都成功。如果数据库不是最终裁判,迟早会卖出超过库存的商品。
有些规则是绝对的:每个账户一个邮箱、每个用户一个活动订阅、每个用户一个未结购物车。这类规则经常在你先检查(“是否已存在?”)然后插入时失败。在并发下,两个请求都可能通过检查。
如果你通过像 Koder.ai 这样的方式快速生成 CRUD 流,尽早把这些热点写下来,并通过约束和安全写入来保障,而不仅仅是 UI 检查。
许多竞态条件起于一个无聊的现象:同一动作被发送了两次。用户双击、网络慢所以再点一次、手机误识别两次点击。有时这并非有意:POST 后刷新页面,浏览器会提示重新提交。
当这发生时,后端可能并行地执行两次创建或更新。如果两者都成功,你会得到重复项、错误的总计,或运行两次的状态变更(例如两次批准)。它看起来随机,因为它取决于时序。
最安全的做法是纵深防御。修复 UI,但假设 UI 会失效。
对大多数写入流可应用的实用改进:
示例:用户在移动端对同一张发票连续点击“支付”两次。UI 应阻止第二次点击。服务器应在看到相同的幂等键时拒绝第二个请求,并返回原始成功结果,而不是重复扣款。
状态字段看起来简单,直到两件事同时尝试修改它。用户点击批准的同时,自动任务把同一条记录标记为过期,或者两个团队成员在不同标签页同时操作。两次更新都可能成功,但最终状态取决于时序,而不是你的规则。
把状态视为一个小型状态机。维护一张允许移动的简短表(例如:Draft -> Submitted -> Approved,和 Submitted -> Rejected)。然后每次写入都检查:“从当前状态允许这次移动吗?”如果不允许,就拒绝,而不是默默覆盖。
乐观锁可以让你在不阻塞其他用户的情况下捕捉过期更新。添加一个版本号(或 updated_at),并在保存时要求其匹配。如果别人已经在你加载之后更改了该行,你的更新会影响 0 行,你可以显示一条清晰提示:“此项已更改,请刷新后重试。”
一个简单的状态更新模式:
此外,把状态变更集中在一个地方。如果更新散落在多个界面、后台任务和 webhook 中,你会漏掉规则。把它们放在一个统一的函数或端点后面,每次都强制相同的转换检查。
最常见的计数器错误看起来无害:应用读取一个值,加 1,然后写回。在高并发下,两个请求可能都读到同一个数字并写入相同的新数字,从而丢失一次增量。这在测试中通常“看起来可行”,所以很容易被忽略。
如果某个值只是要递增或递减,让数据库在一条语句中完成。数据库可以在许多请求同时到来时安全地应用更改。
UPDATE posts
SET like_count = like_count + 1
WHERE id = $1;
同样的思路适用于库存、浏览计数、重试计数,以及任何可以表示为“new = old + delta”的场景。
当你把派生数(order_total、account_balance、project_hours)存储起来并从多个地方更新时,总计经常出错。如果你可以从源行(明细行、账目条目)计算总计,就能避免一类漂移错误。
如果必须为了性能存储总计,就把它当成关键写入。把对源行和存储总计的更新放在同一事务中。确保只有一个写者能同时更新同一个总计(加锁、受保护的更新,或单一所有者路径)。添加约束防止不可能的值(例如库存不得为负)。然后定期运行后台核对,重新计算并标记不匹配项。
一个具体例子:两个用户同时向同一个购物车添加商品。如果每个请求都读取 cart_total、加上商品价格然后写回,其中一个添加可能会丢失。如果你在一个事务中更新购物车项和购物车总计,即便在大量并发点击下,总计也会保持正确。
如果你想减少竞态,先从数据库开始。应用代码会重试、超时或被执行两次。数据库约束是最后的防线,即使两个请求同时到达也能保证正确性。
唯一约束阻止那些“绝不该发生但确实发生”的重复:邮箱地址、订单号、发票 ID,或“每个用户仅有一个活动订阅”规则。当两个注册同时到达时,数据库会接受一行并拒绝另一行。
外键防止引用错误。没有外键时,一个请求可能删除父记录,而另一个创建指向不存在父记录的子记录,留下难以清理的孤儿行。
检查约束保持值在安全范围并强制简单的状态规则。例如 quantity >= 0、评分在 1 到 5 之间,或状态只限于允许集合。
把约束失败当成预期结果而不是“服务器错误”。捕获唯一、外键和检查违规,返回清晰消息如“该邮箱已被使用”,并记录细节以便调试但不要泄露内部信息。
示例:在延迟期内两人都点击“创建订单”。如果在 (user_id, cart_id) 上有唯一约束,你不会得到两个订单,而是一个订单和一次干净可解释的拒绝。
有些写入不是单条语句。你先读一行、检查规则、更新状态、也许还插入审计日志。如果两个请求同时做这套操作,它们可能都通过检查并都写入。这是经典的失败模式。
把多步写入包在一个数据库事务中,这样所有步骤要么全部成功要么全部失败。更重要的是,事务给了你一个控制同时修改相同数据的手段。
当同一时间只允许一个参与者编辑记录时,使用行级锁。例如:锁定订单行,确认它仍处于“pending”状态,然后把它翻为“approved”并写审计条目。第二个请求会等待,然后重新检查状态并停止。
根据碰撞频率选择:
把锁持有时间保持短。持锁期间尽量少做工作:不要进行外部 API 调用、不要做慢文件操作、不要大循环。如果你在像 Koder.ai 这样的工具中构建流程,把事务只限于数据库步骤,其他工作在提交后再做。
挑一个在碰撞时会造成金钱或信任损失的流。一个常见的是:创建订单、预留库存,然后把订单状态设为已确认。
写下你当前代码的精确步骤,按顺序。明确记录读取了什么、写了什么,以及“成功”的含义。碰撞往往隐藏在读取与后续写入之间的空隙里。
一个在大多数栈中可行的加固路径:
增加一个测试来证明修复有效。对同一产品和数量同时发起两个请求,断言恰好只有一个订单被确认,另一个以可控方式失败(无负库存、无重复预留行)。
如果你快速生成应用(包括使用像 Koder.ai 这样的平台),在少数关键写入路径上执行这套检查仍然值得。
最大的问题之一是过度信任 UI。禁用按钮和客户端检查有帮助,但用户可能双击、刷新、打开两个标签页或重放请求。如果服务器不是幂等的,重复就会漏过。
另一个隐蔽的错误:你捕获了数据库错误(比如唯一约束违例)但仍然继续工作流。这常常导致“创建失败,但我们仍然发送了邮件”或“付款失败,但我们仍然把订单标记为已付”。一旦副作用发生,回滚很困难。
长事务也是陷阱。如果你在事务打开期间调用邮件、支付或第三方 API,你会持有锁的时间超过必要。这会增加等待、超时和请求互相阻塞的概率。
在没有单一真相源的情况下混合后台任务和用户动作会造成分裂式状态。任务重试并更新一行时,用户同时在编辑它,双方都认为自己是最后的写入者。
一些“修复”实际上并不能解决问题:
如果你使用像 Koder.ai 这样的聊天到应用工具,同样的规则适用:要求服务器端的约束和清晰的事务边界,而不仅仅是更友好的 UI 保护。
竞态条件常常只在真实流量下显现。一次发版前检查可以在不重写代码的情况下捕捉最常见的冲突点。
从数据库开始。如果某些东西必须唯一(邮箱、发票号、每个用户唯一的活动订阅),把它做成真实的唯一约束,而不是应用层“先检查”的规则。然后确保你的代码准备好应对约束失败并返回清晰、安全的响应。
接着检查状态。任何状态变更(Draft -> Submitted -> Approved)都应该基于显式的允许转换集合进行验证。如果两个请求尝试移动同一记录,第二个应该被拒绝或成为无操作,而不是产生一个中间态。
一个实用的预发布检查清单:
如果你在 Koder.ai 上构建流程,把这些作为验收标准:生成的应用在重复和并发下应该安全失败,而不仅仅通过理想路径测试。
两个员工打开同一采购请求。双方在几秒内都点击了批准。两个请求到达服务器。
可能发生的问题是混乱的:请求被批准了两次,发送了两次通知,任何与批准相关的统计(预算使用、日批准计数)可能增加 2。单次更新看来都有效,但它们发生了碰撞。
下面是一个在 PostgreSQL 风格数据库中很有效的修复方案。
增加一条规则,保证每个请求只能有一条批准记录。例如,把批准存到单独表并对 request_id 强制唯一约束。现在即使应用代码有 bug,第二次插入也会失败。
批准时在一个事务内完成整个转换:
如果第二个员工来晚了,他们会看到 0 行被更新或唯一约束错误。无论哪种情况,只有一次变更生效。
修复后,第一位员工会看到“已批准”并得到正常确认。第二位员工会看到友好的提示:"该请求已被他人批准。请刷新以查看最新状态。"没有持续旋转、没有重复通知、没有静默失败。
如果你在像 Koder.ai 这样的平 台(Go 后端 + PostgreSQL)中生成 CRUD 流,可以把这些检查统一写在批准动作里,然后在其他“只有一方胜出”的操作中复用。
当你把竞态当成可重复执行的流程而不是一次性错误排查时,修复会变得最简单。把注意力放在少数关键写入路径上,并在抛光其他内容前把它们做得稳稳当当。
先列出你的顶级冲突点。在很多 CRUD 应用里是同一组三类:计数器(点赞、库存、余额)、状态变更(Draft -> Submitted -> Approved)和重复提交(双击、重试、慢网络)。
一套经得起考验的流程:
如果你在 Koder.ai 上构建,Planning Mode 是把每个写入流程按步骤和规则映射清楚再生成 Go + PostgreSQL 代码的好地方。在发布新约束或更改锁行为时,快照与回滚也很有用,可以在遇到边缘情况时快速回退。
随着时间推移,这会成为一种习惯:每个新写入特性都有约束、事务计划和并发测试。这样 CRUD 应用中的竞态问题就不再是惊喜。