避免库存并发超卖的关键,是让“仍有足够可用量”的判断与预占写入处于同一个受控数据库操作,并把业务请求的唯一身份、结果及取消释放纳入同一事务设计。先查余额再无条件写回,即使外面包了事务也不够。本篇限定 PostgreSQL 18、同一主库、预先存在的库存行和整数件数;提供设计示例与预期,不宣称已在生产系统实测。
定义预占守恒,别把报表余额当作写入许可
假设服务要为订单临时保留库存,在确认发货前允许取消。一个库存桶由租户、仓库和 SKU 共同确定;SKU 是库存管理中的物料或商品编码。本文不处理批次有效期、序列号分配、可替代物料及跨库原子提交。若真实业务需要这些条件,必须把它们纳入对象身份和事务范围,不能继续沿用只按 SKU 判断的示例。
在没有收货、盘点和出库的封闭测试中,定义初始数量 Q、当前可用量 A 和有效预占量 R,要求 A≥0、R≥0 且 A+R=Q。预占 q 件使 A 减 q、R 增 q;取消使相反变化;重复调用不应再次改变它们。数量必须是正整数且单位为件,不能把一箱与一件混算。生产系统存在收发调整时,应改为与完整库存流水对账,而不是永远使用初始 Q。
SQL 多查询快照一致性解决的是读取时是否混用了版本;库存预占还必须控制写入竞争。一个 AI 助手可以先查询可用量帮助用户决策,但这份查询结果只是当时可见状态。工具层接受预占请求时仍要重新执行受保护的检查,不能因为模型刚回答“有货”就跳过数据库约束。
先用两笔构造订单说明故障。库存为 10 件,订单 A 要 7 件,订单 B 要 6 件。若两个会话都先读到 10,再分别用应用算出的余额写回 3 和 4,两个订单都可能得到成功承诺,最终余额却只显示某一次覆盖结果。即使改成无条件的 available=available-q,也可能得到 -3。一个是丢失更新,另一个是缺少不足量条件;修复其中一个不自动修复另一个。
把可用量条件放进实际 UPDATE
单库存桶的核心语句可写为:UPDATE stock SET available = available - $4 WHERE tenant_id = $1 AND warehouse_id = $2 AND sku = $3 AND available >= $4 RETURNING available;。这里 $1 至 $4 是驱动绑定参数,$4 是已经验证的正整数 q;不要由模型拼接 SQL。三列业务键必须唯一,返回一行表示这次语句实际完成了扣减,返回零行表示没有符合全部条件的行。
PostgreSQL 18 的 Read Committed 行为使这个模式有明确依据:更新目标行遇到并发写入会等待,对方提交后重新检查更新后的行是否仍满足 WHERE。[1] 因而不是先读到充足就永久获得许可。此处只对已存在的单行库存和行内条件作论证,不能外推为任意跨行条件都在这个隔离级别安全。
仍用 10 件库存,会话 A 执行扣 7 的条件更新,得到 3,但暂不提交;会话 B 执行扣 6 的同类更新。B 等待 A 的事务结局。A 提交后,B 对余额 3 重新检查,条件不成立,返回零行。若 A 回滚,B 则可以基于恢复的 10 扣减,得到 4。最终哪笔成功取决于顺序,不承诺先到的网络请求一定先获得库存。
零行结果不宜一律翻译为“缺货”。它也可能是租户或仓库错误、库存桶不存在,或业务范围不可见。可在授权范围内读取对应桶来区分错误,但这个后续解释不应成为无条件补扣依据。不要向无权访问者透露其他租户是否有该 SKU;用户可见错误类别与内部诊断信息可以不同。
stock 表至少应具有 tenant_id、warehouse_id、sku 的联合主键,以及 available bigint NOT NULL CHECK (available >= 0)。请求数量也需 NOT NULL 与正数限制。官方约束文档说明,CHECK 表达式为 NULL 并不会像 FALSE 那样拒绝记录,因此非空要求不能省略。[2] CHECK 是最后一道保护,不能代替业务键唯一、正确事务和请求结果账本。
用同一个业务键记录一次请求的全部结果
条件更新能阻止这次并发扣到负数,却不能阻止同一订单超时后又扣一次。建议建立 reservation_request,以 (tenant_id, request_key) 为唯一键,保存仓库、SKU、数量、操作类型或规范化参数摘要、状态、预占标识及业务返回结果。请求键由上游为一次业务意图稳定生成并持久保存,网络重试沿用;相同字符串配不同数量必须拒绝,不能按新请求处理。
一个可实现的单库流程是:BEGIN;插入请求行,初始状态 processing,使用 ON CONFLICT (tenant_id, request_key) DO NOTHING RETURNING request_key;若返回一行,则当前事务拥有该请求的新执行权。随后执行条件扣减,成功后写预占明细并把请求改成 reserved;不足时记 rejected 及确定原因;最后 COMMIT。只有提交成功并确认结果后,才向调用者报告已预占。
若插入返回零行,使用下一条 SELECT 在同一主库读取已有请求,比较全部业务参数,然后返回记录中的结果,不再扣库存。INSERT 的冲突处理由唯一索引约束支撑。[3] 在 Read Committed 下,冲突行可能在插入语句的快照中不可见,因此不能强求同一语句的普通查询分支一定能读到它;后续语句拥有新的可见性边界。[1]
该流程要求首次请求的 processing、扣减、预占明细和最终结果在一个事务内提交,不单独提交 processing 后再启动另一个扣减事务。这样并发同键插入会等到原事务结束:原事务成功则读已提交结果,原事务回滚则可取得插入机会。若查询仍读不到记录,应作为状态异常核查,不能因为“没看到”就立即换键执行。
业务拒绝也要有稳定语义。本方案把不足量的 rejected 结果持久化,同键重试返回原拒绝;后来补货并不使旧请求自动成功。用户明确发起新的业务尝试时才使用新的意图标识,并与前次关联。这样能避免一个已经告知失败的操作在后台重试中突然占货。是否选择该语义由产品约定,但不能在实现中忽而缓存拒绝、忽而重新扣减。
预占明细建议包含 reservation_id、请求键、库存桶、数量、state 和 created_at。reserved、released、consumed 是不同状态,且合法转移必须受控制。请求结果可以引用预占标识,当前状态从明细读取;不要为了保存当时回复而让查询把已取消预占继续显示为有效。历史结果与当前状态都应带清楚的时间与含义。
显式行锁适合复杂行内判断,但锁的生命周期必须明确
第二种实现是先 SELECT ... FOR UPDATE 锁定库存行,在同一连接和事务中读取最新可用量,判断足够后扣减并写明细。PostgreSQL 的行锁会阻止冲突修改,通常持续到事务结束;普通不加锁查询并不因此全部停止。[4] 与条件 UPDATE 相比,显式锁便于在读取后执行多个相互依赖的行内检查,代价是更多语句与更长的持锁窗口。
对于只检查 available≥q 的单行操作,条件更新更紧凑。若需要同时核查该桶的冻结标志、允许出库状态等,可以把行内条件放进同一 UPDATE,或加锁后逐项判断并记录原因。两种方案都要求取消、出库、调整等其他写路径遵守一致协议;不能一条路径有锁,另一条脚本直接覆盖余额,就声称系统整体安全。
显式锁并不能锁住一个根本不存在的库存行。若业务允许按需建立库存桶,必须另外设计唯一键冲突处理及创建语义,本篇采用预建库存行避免混淆。也不要用 SKIP LOCKED 让竞争的订单默默跳过目标库存,再把跳过解释成缺货;队列消费者跳过已锁任务与用户要求某一 SKU 预占不是同一种业务承诺。
若订单同时依赖多个库存桶,应按统一顺序获取它们的锁或执行更新,并把整单预占放在一个事务中。跨行总额约束、动态挑选组合与可替代料需要更完整的串行化设计,可能考虑 Serializable 及全事务重试。升级隔离级别有冲突和重试代价,不是免费开关;所有参与约束的写路径都需要一起评估。
绝不在持锁期间等待模型生成说明、人工批准或外部支付接口。先完成必要的受控数据库动作并结束事务,再生成解释;需要后续业务步骤时,通过明确状态与后续事务衔接。否则一次缓慢调用可能把热点库存桶排成队列,最终表现为接口超时与大量重复请求。
端到端时间线:提交成功但回复丢失时怎么办
构造一份可复核时间线:租户 T、仓库 W、SKU S 的初始可用量为 10;A 使用键 order-A-reserve-1 请求 7,B 使用 order-B-reserve-1 请求 6。A 插入自己的请求、扣到 3、写入 7 件有效预占并提交。B 使用另一键竞争同一库存,条件更新零行,保存不足量拒绝。数据库最终应为可用 3、有效预占总量 7、一个成功请求和一个拒绝请求。
现在让 A 的数据库提交成功,但网络在响应到达客户端前断开。调用者看到超时,不能据此认定回滚。它以原键及原参数重试,插入请求时发生唯一冲突,随后读取原结果,得到同一预占标识;可用量仍为 3。成功调用次数可能是两次,业务预占只能是一笔。核对证据应是请求表和明细表,而非 HTTP 成功条数。
若断开实际发生在提交前且事务被回滚,重试则可以取得原键的新执行权并正常预占。两个分支必须由持久记录区分。客户端无法确认 COMMIT 结果时,先在主库按原键查询;若原事务仍在进行,查询与重试要遵循等待和超时策略,不能从有复制延迟的只读副本读到空结果后宣布可以重建。
本例不把数据库事务扩展成对外部系统的原子承诺。如果预占成功后要向仓储或消息系统发送事件,可以把待发送事件与业务结果一起提交,再由独立发送者投递,消费者按事件身份去重。发送与消费仍需各自的确认和恢复设计;不能在数据库提交前发“预占成功”消息,让其他系统执行一个最终可能回滚的动作。
业务查询应返回请求状态、预占标识、数量、库存桶及当前明细状态,并由调用层检查租户权限。日志保留键、事务阶段、错误类别与耗时即可支持定位,避免默认复制完整订单与客户资料。AI 的职责可以是解释结果,实际是否成功必须由受控工具返回的已提交证据决定。
取消和到期释放:只允许一方真正归还库存
取消预占不能简单执行 available=available+q,因为取消请求也会重复,定时到期释放还可能与用户取消竞争。建议在释放事务中先锁定该预占明细,验证所属租户、库存桶和状态。只有 state=reserved 的记录才有资格释放;随后更新库存加回明细中保存的数量,并把明细改为 released,全部在同一事务提交。
数量取自原预占明细,不取取消请求随意传入的数字。若明细已经 released,则返回已释放,不再加回;如果已经 consumed,则不能当成普通取消,需要业务定义的退货或冲销流程。归还前还应确认库存更新恰好命中一个合法桶,失败则整个事务回滚,避免状态已释放而库存未恢复。
沿用案例,A 的 7 件预占第一次取消后,可用量从 3 回到 10,有效预占从 7 变为 0;第二次取消结果相同,余额不能变成 17。让用户取消与到期工作者同时竞争同一明细,后获得锁的一方应看见已释放状态。若期间没有其他库存动作,A+R 仍为 10;有其他动作时则与相应流水一起核对。
锁顺序要覆盖完整生命周期。可以约定:涉及既有预占的动作先锁明细,再锁其库存桶;新建请求先取得请求键,再更新库存并新增明细。不要让另一个出库路径先锁库存、再回头等待同一明细,形成反向等待。对多桶订单还需统一桶排序,并用实际调用图检查是否存在隐含的逆序锁。
到期扫描只是找到候选者,不等于完成释放。工作者取得明细锁后应按当前状态及数据库时间重新判断是否仍到期,处理并发延期或已出库。若一个工作者中途失败,重跑必须通过同样状态检查,不能按曾经扫描到的名单直接加回库存。到期时间和成功释放时间应分别记录,便于解释库存为什么暂时仍被占用。
六组失败验收:检查输入、结果和持久证据
第一组是竞争不足量:初始 10,两个不同键分别要 7 和 6,用两个会话控制先后并让首个事务停在提交前。预期最多一笔预占成功,另一笔等待后拒绝;首个回滚时另一笔可成功。检查返回行数、事务结束顺序、可用量和有效明细之和。若两笔都成功,检查条件是否在实际写语句中、连接是否指向同一主库,以及是否存在旁路写入;修正后交换顺序复验。
第二组是同键并发与参数冲突:两条连接同键同数量请求 7,预期仅一条明细、一次扣减和同一预占标识;改成同键请求 8,预期参数冲突,不能改写原结果。检查联合唯一键、绑定参数与明细数量。若重复扣减,修正请求键持久化与原子事务边界,再让并发请求分别在插入前、更新后和提交前相遇。
第三组是提交窗口断线:分别模拟提交前回滚,以及提交后响应丢失。两种情况下都只按原键核查恢复,最终恰好一笔或按业务拒绝结果零笔有效预占。检查主库账本而非客户端异常文字;若恢复产生第二键,先停止该重试路径并人工核对重复承诺,再修复键的生命周期。故障注入只能在隔离测试环境进行。
第四组是释放竞争:第一次取消、重复取消、到期与取消同时发生,以及出库与取消竞争。预期只有合法的 reserved 转移能归还一次,已 consumed 的记录不走普通释放。检查状态历史、余额变化和更新行数。若出现余额 17 或已释放仍占用,修复状态锁及同事务归还,并对中途异常回滚重新执行测试。
第五组是整单部分不足:订单需要 S1 两件、S2 四件,初始分别为 5 和 3。在本例“整单全成或全败”的契约下,第二桶不足时第一桶扣减及相应明细也必须回滚,最终仍为 5 和 3。可以使用事务内保存点回滚扣减后记录稳定拒绝,或回滚整笔再采用经过设计的结果记录流程;不能提交半单却返回整单失败。检查异常是否被捕获后误提交,再补成功双桶与逆序竞争用例。
第六组是非法输入与死锁:测试数量 0、负数、NULL、超出允许范围、跨租户键以及两个订单逆序访问多桶。非法输入应在扣减前拒绝,死锁或序列化冲突应保留错误分类并回滚。PostgreSQL 文档要求序列化失败重试完整事务,且其他错误不能一律视为瞬时故障。[5] 修正锁序后按原业务键、完整参数、有限次数重试,核对没有遗留半笔明细。
维护边界:热点吞吐、完整对账与可控回退
这套设计不能使一个热门库存行无限并行。多个更新最终仍需协调同一行,峰值请求会形成等待;平均请求量低也可能有瞬时集中。监控应分开看成功、业务不足量、同键命中、参数冲突、锁等待、死锁及结果未知,不把所有非成功都归为数据库故障。压测同时保留真实的热门 SKU 分布与重复请求比例,均匀随机访问容易高估容量。
等待与语句超时要服从请求总期限,重试设置次数和退避上限,具体数值由业务时限确定。超时只说明调用没有按时拿到结果,不证明数据库动作被撤销;发生在提交窗口时仍按账本核查。限流和排队可以保护数据库,却必须说明排队不等于已占库存,避免界面提前向用户承诺成功。
定期按同一可对账视图汇总有效预占,并与库存变动流水核对。检查孤立明细、缺失库存桶、长时间 processing、已释放但仍有效的重复记录、长期未消费也未到期处理的预占。发现差异先限制受影响桶的新增承诺,保留证据并追溯写入路径,不用一条批量归零命令抹掉责任。生产对账需要完整收发调整口径,不能只比较本例的两张表。
上线可以先在隔离环境执行上述时间线,再以受控流量验证实际连接池、事务传播与重试中间件。回退时停止新增预占,继续允许按原记录查询与合法释放,确认没有在途事务后再切换写路径。回退应用代码不等于应删除幂等账本;账本保留期必须覆盖允许重试和业务追溯的时间,过早删除会让旧请求获得再次扣减机会。
本文依据 PostgreSQL 18 官方文档制定协议,并核对构造示例的数量守恒;未运行数据库并发实验,读者应在自己的驱动、连接池、触发器与权限配置中复现。它不提供跨数据库的全局不超卖保证,也不替代实体库存准确性。可用量判断、请求身份、状态转移与持久证据共同成立,才构成一条能够安全重试的库存预占路径。
