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

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