建模评审用的两栏对照表,左栏是数据库可表达的约束,右栏列着落点与补偿层,右栏多出三行用便签补齐

一、被拒的不是写法,是确定性

有人踩到一条硬边界:想把”这条记录引用的东西必须在另一张表里存在,而且要满足某个条件”写进检查约束,用相关子查询去另一张表查一下,结果被拒绝。这里的边界不是某句写法不被接受,而是检查约束的隐含契约:它要求表达式是确定的且单行自洽。一旦引用其他表,判定就依赖外部可变状态和执行时点——同一行数据在两个时刻可能给出不同答案,而这与约束”要么成立要么不成立”的性质冲突。

这个契约值得被讲清,因为它决定了工程师的心智模型。数据库里的声明式约束之所以有用,是因为它在写入路径上被强制检查、无法靠应用自觉绕过、优化器还能提前利用它做判定。把这些性质合起来看,能写进检查约束的东西天然只能是单行可判定的表达式。理解到这一层,后续的问题就从”怎么写才不被拒”变成”这条规则该落在哪一层,谁来保证它一直成立”。

后半个问题才是交付里真正花钱的地方。跨表存在性校验的正规落点是外键;而外键也表达不了的那部分——“存在一条满足某谓词的记录”——只能落到触发器、应用层事务或任务编排。落点不同,检查时机、可绕过性和排障难度全都不同。建表期没记的账,后期只能靠批量核对作业补,而那属于检测而不是防护。

二、四类落点的能力与代价

把常见的完整性落点摆在一起对照,能看清一件事:约束的落点决定它什么时候被检查,也就决定它能不能被绕过。

落点 能表达什么 检查时机 代价与可绕过性
检查约束 单行内可判定的条件,不引用其他表 写入即检查,优化器可提前判定 表达力受限,跨表规则写不进来
外键 跨表存在性与级联语义 写入即检查,由数据库统一强制 影响并发与运维,改数据顺序有要求,级联行为要提前评审
触发器 复杂条件与跨表校验 绑定在写入事件上 难测,批量路径容易绕过,排障时行为隐蔽
应用层事务与任务编排 业务语义最完整 由代码调用顺序决定 一致性依赖边界,必须配补偿与可见性

表里最容易被低估的是第三行和第四行的差别。触发器看起来”至少在数据库里”,但它能不能被绕过取决于写入方式:批量导入、维护脚本、直接的数据修正走的路径不同,触发器覆盖的范围也就不一致;它的执行时机隐蔽,问题往往在排障时才被发现。应用层事务更灵活,代价是它只覆盖走这条代码路径的写入——任何绕开这条路径的写入都不受保护。这两种落点都必须把一致性边界写清:谁负责、什么时候检查、绕过时会发生什么。

三、能搬回单行的改写与它的代价

有一类规则可以通过建模搬回检查约束能表达的范围内:把跨表的判定结果物化成本表的一个列,再对这个列施加单行约束。例如”所属批次必须处于可提交状态”,可以在本表存一个批次状态列,写入路径负责维护它与来源一致,约束则检查本行的其他字段在这个状态下的取值合法性。

好处是判定回到单行自洽,数据库能强制;代价也很明确:物化列存在更新时机问题,来源状态变了而这一列没跟着变,约束检查的就是一个过期事实。所以这类改写不是免费的简化,而是一次责任转移——你把”跨表一致”换成了”派生值维护正确”,后者需要写入路径、消息任务与补偿逻辑共同保证。做这种改写的判断标准是:该派生值的变更频率低、维护入口收敛、且不一致的后果可被核对作业发现。三条里有一条不满足,就不如把规则放在应用层事务里并写清边界。

还有一类改写是把跨表条件转成对既有关系的引用,即用外键承接存在性,把谓词条件留在应用层。这种分工比硬凑一个触发器更好维护,因为存在性由数据库强制,业务谓词由代码表达,两边的责任是分开的。

四、存在性判定的空值陷阱与静默失败

写到存在性判断时,写法的选择会带来一个隐蔽的行为差异。经验规则是:优先用半连接形式而不是枚举形式。半连接只关心”是否存在满足条件的行”,不关心里面装了什么值;枚举形式把子查询的结果摊成一个值列表再判断,此时有三条前提必须同时成立——关联字段要有索引,否则代价随数据量上升;子查询要返回单列单值;空值必须被显式处理。

第三条最容易出事。枚举形式在子查询返回空值时会让整体判定变成未知:这一行的条件既不成立也不不成立,于是插入不会报错,也不会成功,表现为静默失败。这类失效的可怕之处在于它对代码完全友好——没有异常,没有错误码,调用方以为写进去了,直到下一次读取才发现数据不在。

对应的工程纪律有两条。一是写入路径要把”受影响行数”当作必须检查的返回值,而不是只看有没有抛错;没检查影响行数的写入代码,等同于没有失败处理。二是存在性判定要用可解释的三值来写:成立、不成立、无法判定,第三种要在业务上有明确去向(拒绝、告警还是转人工),而不是留给运行时的未知状态去决定。这两条都不涉及具体产品特性,在任何关系库里都成立。

五、并发窗口里,约束只保证提交那一瞬

即使规则落在数据库层,也要清楚它保证的是提交那一瞬间的状态,不是之后的状态。两条事务交错时,各自在检查时看到的对方还没提交,各自都通过了校验,合起来却破坏了那条规则——典型形态是同一份额度被两条事务各自判定为可用、或者被引用的行恰好在这中间被删。

处理方式只有两条实在的路:一条是把竞争点用锁或串行化边界圈住,让检查与写入落在同一个不可分割的范围内;另一条是承认这个窗口存在,把规则的最终判定交给事后的核对与修复。两条都比”约束已经写了,应该没问题”诚实。这里要守一条纪律:并发语义写不清楚,就不要在方案里承诺已保证。承诺一句”依赖数据库约束”而给不出检查与写入的关系,验收时经不起追问,出问题时也无人能解释。

我们的评审里有一项固定提问:这条规则在并发下最坏会被破坏成什么样,破坏之后多久能被发现。答不上来的,按未完成处理,回到建模台账继续补。

六、台账与验收:把不可表达记成一项资产

洹拓把建模阶段判定为”数据库无法表达”的规则统一记进一张台账,随数据模型一起交付,字段是固定的:规则的业务描述、为什么不可表达、选定落点、检查时机、并发窗口说明、补偿与可见性手段、对应测试用例、责任人。这张表的价值不在于承认局限,而在于让每一条被数据库拒掉的规则都有一个明确的接手方,不会在开发过程中变成”大家都以为对方在管”。

台账字段 要写清的内容
规则描述 用业务语言写出这条不变量,不要只写约束表达式
不可表达原因 违反确定性或单行自洽,还是超出外键表达能力
落点与检查时机 触发器、应用层事务、任务编排;何时被检查
可绕过路径 批量导入、维护脚本、直接修数据是否绕开
并发窗口 交错时最坏结果与处置方式
补偿与可见性 失败时留下什么痕迹,谁先看到
用例与责任人 一条正向一条破坏性用例,落到具体人

交付验收的写法随之改变:每条业务不变量对应一个落点、一个测试用例、一条失败路径的可见性说明,三件缺一件就不算完成。批量核对作业要在这套体系里放对位置——它是检测与修复手段,不是防护。核对周期内产生的不一致由它兜住,核对窗口期内的问题它无法阻止,这一点必须写进运维说明,否则一次事故后所有人都会问”为什么有作业还会出错”。把可表达的交给数据库,把不可表达的记账并指定补偿层,才是完整性这件事在交付上能被验收的样子。