一个人修改订单联系人,另一个人依据稍早读到的订单修改配送时间,最后联系人却恢复成旧值。两次接口调用都成功,错误仍然发生了。这类丢失更新需要把“我依据哪个状态作出修改”交给写入端检查。以下方案面向可控制接口与数据写入逻辑的团队;例子是构造场景,未声称已在真实系统运行实验。
把旧状态作为明确的写入前提
多智能体可靠执行中的工具契约与恢复,要求执行前检查对象状态。本文把这个原则落实到一个更具体的问题:调用方读到订单第七版,准备的更新是否仍有资格提交?问题不是两次请求是否相同,而是两次不同意图是否都建立在已经过时的依据上。
假设甲乙都读取联系人为张某、配送时间为周二的订单。甲把联系人改为李某并提交,服务端进入第八版;乙随后提交包含旧联系人张某、配送时间周三的完整对象。如果服务端只按订单编号覆盖,甲的修改就丢失了。仅让乙在提交前再查询一次也不够:查询结束到写入开始之间,仍可能有第三人修改。
本文采用条件更新契约:读取对象时取得服务端返回的 ETag,提交修改时在 If-Match 中原样携带它。RFC 9110 将该条件用于防止并发覆盖,并要求在执行相应方法前评价条件。[1] 后续设计的关键是让这种判断与实际更新不可分割,而不是只在网关增加一个形式上的头字段。
让版本标记覆盖真正需要保护的状态
ETag 是服务端给出的不透明标记,客户端不应自行计算或递增。本文示例用带引号的 "order-g3-v7" 表示一个标记,实际接口可采用自己的生成方式。If-Match 使用强比较;带 W/ 前缀的弱标记不能据此匹配。[3] 若读接口只提供弱标记,应先补齐适合条件写入的契约,而不是去掉前缀假装变强。
建议在本方案中把对象代次和修订纳入标记,并要求所有相关写入同时更新修订。对象删除后重建不能复用旧代次;否则旧计划可能误把重建对象当作原对象。若应用需要发现“值先改走又改回来”这样的历史变化,采用每次变更推进的修订策略,而不能只比较当前几个业务字段是否相等。
版本保护范围必须说明。若订单响应包含从客户表实时拼接的联系人,而版本只跟随订单表变化,客户表更新可能绕过保护。可以把相关依赖纳入版本与一致性检查,也可以拆分资源并声明此接口只保护订单自身。跨表、多对象约束需要额外事务设计;一个 ETag 不是所有关联业务状态的总锁。
整单版本简单但容易让互不相关的修改冲突。分拆联系人与配送设置可以降低冲突,却可能破坏两者必须同时满足的规则。应按业务不变量选择粒度,而不是为了降低冲突数字无限拆分。对于多语言或不同表示形式,还应确保返回的标记和写接口所检查的表示范围一致,不能混用其他资源的标记。
在写入端完成原子比较与更新
一个可实施的单数据库方案是:先鉴权并确认租户及对象范围,再在同一事务内校验预期代次、修订和业务条件,更新业务字段并推进修订。可以使用带条件的更新语句,其条件同时包含租户、对象、代次和预期修订;只有确实修改一条目标记录才算本次条件写入成功。数据库语法及隔离要求需按所用引擎验证。
也可以采用合适的行锁,让读取、比较和更新处于同一受保护事务中。不能先在普通查询里比对版本,再执行只含对象编号的更新:两个请求可能都通过查询检查,随后相继覆盖。HTTP 条件只是协议入口,存储层的竞争处理才决定保护是否真的成立。
若条件更新未成功,应在不泄露其他租户对象存在性的前提下,按接口规则区分对象不存在、权限拒绝和版本冲突。不能把所有零行结果都解释成网络故障并自动重试。正常的过期版本冲突返回 412;本文建议的接口对缺少必需条件的写请求返回 428。RFC 6585 定义了 428,但并非所有服务都必须采用它,客户端须依据实际契约处理。[2]
事件与外部动作也要放在正确边界。版本冲突的请求不能先发通知或扣库存再返回拒绝;成功变更所需的本地事件可与业务更新一并落事务,随后由受控流程发送。外部服务不自动属于这笔事务。管理后台、批量导入和定时任务如果绕过版本推进,同样会使条件更新失效。
收到冲突后重新决策,不能只换一个标记
回到订单例子,甲持有第七版标记提交成功后,乙继续携带第七版标记的请求应被拒绝。乙需要保留原始快照、希望改变的配送时间,以及重新读到的当前对象。将这三项对照,才知道能否在保留新联系人李某的情况下完成原意。
如果乙只是重新获取第八版标记,仍提交包含张某的旧完整对象,下一次请求就可能合法地覆盖甲的修改。条件校验没有失效,是客户端把旧决策伪装成基于新状态的决策。应向操作人显示冲突差异,或让智能体重新计算受影响条件;需要批准的实质变更仍应绑定新的具体内容。
可以自动合并的范围应预先定义。例如只改配送备注,且新状态仍允许编辑,可生成仅包含已批准变化的新请求;若订单已发货,原来的改配送意图可能已经不允许。局部更新并不天然安全:即使只提交一个字段,也可能依赖另一字段的旧状态。不同字段、不同修改者,不等于没有业务冲突。
本文验收方案对条件失败采用严格拒绝分支。RFC 9110 也允许服务端在能判定所请求变更已成功应用时返回成功,客户端不能在缺少这种证据时自行假定属于该例外。[1] 对现成第三方接口,应先确认其冲突与重复提交语义,而不是把示例策略当作所有实现的统一行为。
把幂等、版本冲突和结果未知分开
版本标记回答“当前状态是否仍满足这个前提”,幂等标识回答“是否已经处理过这一次操作”。建议分别记录,不要拿同一个字段兼任两者。甲乙两次不同操作可能有不同幂等标识,却仍携带同一个旧版本;去重不会阻止它们互相覆盖。
如果甲的写入已经提交,但成功响应丢失,重试携带旧版本可能得到冲突。冲突本身不能证明第一次写入未发生。应利用稳定操作标识、服务端回执或状态查询核实;即使当前字段恰好等于期望值,也未必能证明是甲这次操作造成。需要审计归属时,应保留操作与提交版本的关联记录。
若接口同时支持幂等记录与条件更新,设计时应明确同键同参数的已完成操作如何返回原回执,以及新操作如何检查当前版本。幂等有效期、查询一致性和未知结果的处理也应写入契约。无法确认时保留待核对状态,不要换新操作标识强行再执行一遍。
ETag 也不是授权凭据。知道某个标记不能获得跨租户写权限;版本匹配后仍需检查身份和当前业务规则。条件更新更不能自动解决库存总量、跨订单额度等多对象约束,这些约束应由负责业务事实的事务或专门服务验证。
用并发与故障场景验收保护是否成立
第一组验收用同步屏障让两个不同操作都读到同一版本,然后同时提交互斥变更。该测试契约下应只有一个实际变更提交,另一个得到版本冲突;核对最终对象、修订和事件记录,而不只统计 HTTP 状态。调换到达顺序并重复竞争,排除顺序脚本恰好避开竞态造成的假通过。
第二组覆盖入口:缺少条件按本接口契约拒绝,过期标记和弱标记不能放行,错误租户即使持有相同标记也不得更新。对象删除重建后,旧代次的请求应失败。还要从管理后台与导入路径修改对象,确认这些路径也会使旧标记失效。
第三组覆盖恢复:在提交后、响应到达前断开连接,确认客户端先核对原操作而非生成新意图;模拟冲突后盲目刷新标记的错误客户端,证明业务层能够阻止过时决策或要求重新确认。检查失败请求没有提前触发业务副作用,迟到请求也不能恢复已取消的对象。
最后记录冲突比例、人工合并耗时和重复核对成本。冲突多可能表明资源粒度过大、编辑窗口过长或自动任务持续竞争,不宜直接关闭保护。验收结论应限定到实际覆盖的接口、写入路径与业务规则;本文提供的是实施和测试方法,没有宣称任何具体系统已经通过这些测试。
