让数据库约束当哨兵:三个被约束「抓现行」的 bug
余位复盘:23514、23P01 和一批幽灵座位——约束不只是防线,还是最诚实的报警器。
之前写过余位用三层防护杜绝超售(行锁、乐观锁、CHECK 约束)。这篇讲那个设计的另一面:约束不只是防线,还是报警器。上线以来有三个 bug,都不是用户报「数据不对」报出来的,而是约束先炸了——PostgreSQL 的错误码直接钉在日志里。约束一响,说明有一条代码路径没按规矩走,而且它死在了写入之前。
案例一:幽灵座位——同一个业务动作,两条实现路径
现象:一个超时订单被系统自动取消后,别的订单选它原来的座位,建单时被「座位已被其他订单占用」拦下。查库:没有任何有效订单占着这个座位。占位的是一条 seat_assignments 残留行,它的主人早已是 timeout_cancelled 状态。
根因:取消订单在余位里不止一条路径。人工取消(cancelOrder)正确地删了座位绑定;但倒计时服务的超时回滚(rollbackOne)是另一个入口,而且它内部还分两个分支:
- 分支 A:租户开了「到期自动取消」,订单转
timeout_cancelled——应该释放座位; - 分支 B:订单转
pending_overdue(超期待处理)——按业务设计保留占座,等计调人工决断。
写分支 A 的时候,注意力全在订单状态机上,order.status 改了,座位绑定忘了。这不是粗心的问题,是结构问题:「取消」这个业务动作的完整语义(改状态 + 释放座位 + 同步住宿)散落在每个调用点,每个新入口都要靠人肉记全。
修复:状态更新后紧跟一条 tx.seatAssignment.deleteMany({ where: { tenantId, orderId } }),五分钟的事。真正值钱的是补的13 条静态不变量断言——一组直接扫描源码的测试,硬性约束「删除座位绑定的调用只允许出现在 cancelOrder 和 rollbackOne 两处」:
// order-seat-release.service.spec.ts(思路示意)
const ALLOWED = ['cancelOrder', 'rollbackOne'];
const FORBIDDEN = ['confirmOrder', 'reducePax', 'renewExpire', 'updatePassengers'];
it.each(FORBIDDEN)('%s 中严禁出现 seatAssignment.deleteMany', (fn) => {
expect(sourceOf(fn)).not.toContain('seatAssignment.deleteMany');
});
以后有人在错误的地方删座位,或者新增第三条取消路径又忘了删,CI 直接红。代码评审靠人眼防回潮,防不了三个月后的自己;不变量测试是把「规矩」写成会咬人的代码。
案例二:23514——一条 CHECK 让整类订单无法取消
多日游版本(V1.5)上线后,带住宿的订单一取消就报错,日志里是 PostgreSQL 的 23514 check_violation,约束名 order_acc_status_check。它的定义大意是:
CHECK (
status NOT IN ('manual_cancelled', 'timeout_cancelled', ...) -- 非终态随意
OR accommodation_status IN ('released', 'not_required') -- 终态必须已释放
)
即订单到达终态时,住宿资源必须已经处理完毕。而 cancelOrder、reducePax 减到 0、倒计时回滚,三条路径全都只写了 status,没人动 accommodation_status——于是取消统统被数据库拒绝,连带 pending 订单到期回滚也失败,库存永不释放。
乍看这是约束「误伤」:它让订单彻底无法取消,比不加约束还糟?算一笔账就知道恰恰相反。没有这条 CHECK,取消会「成功」,但每个被取消的多日游订单都悄悄泄漏一间房的库存。这种资损没有任何报错,只会在几周后盘点时变成一个对不上的数字,然后是一场跨表考古。约束把它变成了上线当天就炸、当天定位、当天修复的显性 bug——报警器的价值就在这里:把「慢性内伤」转化成「急性外伤」。
修复同样讲究收敛。不是在三个调用点各补一行状态赋值,而是写一个纯函数把规则钉死在一处:
function accommodationStatusForCancelled(current: AccStatus | null): AccStatus | null {
if (current === null) return null; // 无住宿业务
if (current === 'not_required' || current === 'released') return current;
return 'released'; // 其余一律释放
}
事务内锁单的工具函数 lockOrderInTx 直接输出这个字段,所有取消路径统一 spread 写入。下次再加第四条取消路径,它想错都难——错误的写法反而更费劲。
案例三:23P01——EXCLUDE 约束校验的是中间状态
价格区间表用 EXCLUDE 约束保证「同渠道同线路的生效区间互不重叠」:
ALTER TABLE pricing ADD CONSTRAINT pricing_no_overlap
EXCLUDE USING gist (
channel_id WITH =,
route_id WITH =,
daterange(valid_from, valid_to, '[]') WITH &&
);
有个操作是嵌套拆分:往已有的 [5/1, 5/31] 中间插入 [5/15, 5/20],旧区间要被拆成 [5/1, 5/14] 和 [5/21, 5/31] 两段。第一版实现是先 create 尾段、再 update 旧区间截短。测试用例 TC-PRICE-003 一跑,23P01 exclusion_violation。
原因值得记进肌肉记忆:非 DEFERRABLE 的约束校验的是每条语句执行后的即时状态,不是事务提交时的最终状态。create 尾段 [5/21, 5/31] 的那一刻,旧记录还是完整的 [5/1, 5/31],两者在 GiST 索引里撞个正着。事务的原子性只保证「要么全成要么全败」,从不保证「中间过程免检」。
两条出路:把约束声明成 DEFERRABLE INITIALLY DEFERRED,校验推迟到提交——但这会让所有经过这张表的事务都失去逐语句的即时反馈,为一个调用点的方便放松全局的松紧带,不值。另一条就是把操作序列理顺:先 update 旧区间截短成前段(腾出空间),再 create 尾段。改完发现,顺序正确的版本恰好也是人类读起来更顺的版本——先收缩、后填入。约束逼你写出的代码,往往就是本来该写的代码。
小结
三个 bug,三种约束触发(CHECK × 2、EXCLUDE × 1),共同点:
- 约束响的时候,脏数据还没落库——它们全部死在写入之前,事后清理成本为零;
- 每次修复都不止修 bug,还把规则收敛到单点:不变量测试、纯函数、明确的语句顺序。约束负责最后一米,收敛负责让下一个人不再走到最后一米;
- 反事实很清楚:当初要是嫌麻烦没加这些约束,三个 bug 都会以静默数据错乱的形式存活数周——幽灵座位让可售库存虚减,住宿泄漏让成本虚增,区间重叠让同一天出现两个价格。
写一条约束十分钟。对着一堆脏数据做考古,以周计。这笔账,每次都值得算。