后端 · PostgreSQL · 余位

三重防护杜绝超售:一个排座系统的并发控制

余位的库存安全设计:行锁、乐观锁、数据库约束,缺一不可。

余位是一个给地接旅行社用的排座 SaaS。这类业务里最不能出的事故就是超售:同一班次最后两个座位,两个计调同时下单,双双成功——第二天车上多出两个人,没座。

这篇讲余位怎么在数据库层面把这条路彻底堵死。结论先行:一层防护不够,要三层。

第一层:事务内行锁

下单的关键路径是「读库存 → 判断够不够 → 扣减」。这三步之间只要有并发窗口,就可能双卖。最直接的办法是在事务里用 SELECT ... FOR UPDATE 锁住班次的库存行:

BEGIN;
SELECT remaining FROM trip_inventory WHERE trip_id = $1 FOR UPDATE;
-- 应用层判断 remaining >= 人数
UPDATE trip_inventory SET remaining = remaining - $2 WHERE trip_id = $1;
COMMIT;

同一班次的下单被串行化,先到先得,后到的事务在锁上等待,拿到锁时读到的已经是扣减后的数字。

第二层:乐观锁

行锁只保护走这条路径的代码。系统大了之后总有别的写入口——批量导入、管理端改库存、退款回补。给库存行加 version 字段,所有更新都带上版本校验:

UPDATE trip_inventory
SET remaining = $new, version = version + 1
WHERE trip_id = $1 AND version = $expected;

影响行数为 0 就说明数据被并发修改过,重试或报错,防止互相覆盖。

第三层:数据库 CHECK 约束

前两层都是「代码写对了才生效」。人会写出 bug:某个新接口忘了拿锁、某次重构把校验删了。最后一道防线要放在离数据最近的地方:

ALTER TABLE trip_inventory ADD CONSTRAINT chk_remaining CHECK (remaining >= 0);

就算上面全部失守,扣成负数的那条 UPDATE 也会被数据库直接拒绝。宁可让用户看到一次报错,不可让车上多出一个人。

经验

  • 应用层校验只是用户体验,不是正确性保证:表单提示「仅剩 2 座」很好,但它防不住并发。
  • 正确性约束应该长在数据上,而不是散落在每个调用方:新人写了一条绕过服务层的 SQL,CHECK 约束照样兜底。
  • 三层各管一段:行锁管主路径的吞吐与顺序,乐观锁管旁路写入的覆盖,CHECK 管所有人都想不到的那种 bug。