TATECHATLAS
◎ 简体中文
数据与数据库 / 建议

PostgreSQL CHECK 约束:行内规则以及跨行比较为何会破坏转储

PostgreSQL 的 CHECK 约束只能引用被插入或更新的那一行。若 CHECK 与其他行进行比较,它可能通过简单测试,却在 pg_dump 恢复时失败,因为行的加载顺序可能不满足该约束。

本文内容

在 PostgreSQL 中,CHECK 约束是一个仅针对新行或更新后的行进行求值的布尔表达式。它可以引用该行的列、常量、不可变函数和运算符,但不得引用其他行或其他表。PostgreSQL 在定义时并不强制这一限制,因此跨行 CHECK 可以被创建,并且在小规模测试中看似正常工作。然而它无法保证该不变量,因为之后对被引用行的修改可能使条件变为假,而不会重新检查原来的行。文档记载的后果是数据库转储与恢复可能失败:行会以可能不满足该约束的顺序重新加载,即使最终数据库状态是一致的。对于跨行或跨表规则,应使用 UNIQUE、EXCLUDE 或 FOREIGN KEY 约束,或在应用逻辑或触发器中强制执行该规则。还要记住,当表达式求值为 NULL 时 CHECK 会通过,因此当必须排除空值时,应将其与 NOT NULL 配对使用。

允许的 CHECK 约束

PostgreSQL 中的 CHECK 约束是最通用的约束类型。它将一个布尔表达式附加到列或表上,并在插入或更新行时对该表达式求值。该表达式应涉及被约束的列,否则它几乎没有意义。列约束和表约束在许多情况下可以互换,而表约束可以引用同一行的多个列。

该表达式可以使用被检查行的列、字面常量、运算符和函数。它不得引用除新行或更新后的行以外的表数据。这是一项文档记载的限制,而不是风格偏好。约束是针对候选行单独检查的,因此在求值时它无法访问其他行。

一个让许多实践者感到意外的细节是空值处理。当表达式求值为 true 或 null 值时,CHECK 约束即被满足。由于只要任一操作数为 null,大多数表达式就会产生 null,因此 CHECK 本身并不能阻止被约束列中出现空值。要禁止空值,请添加 NOT NULL 约束,它在功能上等价于 CHECK (column IS NOT NULL),但在 PostgreSQL 中更高效。

CREATE TABLE products (
    product_no integer,
    name text,
    price numeric CHECK (price > 0),
    discounted_price numeric,
    CHECK (price > discounted_price)
);

跨行引用的风险

PostgreSQL 不支持引用除被检查的新行或更新行以外的表数据的 CHECK 约束。文档明确指出,违反此规则的 CHECK 在简单测试中可能看似正常工作,但它无法保证数据库不会进入约束条件为假的状态。原因是该条件依赖于其他行,而这些行可能在原始行通过验证之后发生变化。当被引用的行被修改时,没有任何机制会重新检查原始行。

这是一个正确性问题,而不仅仅是性能问题。一个只在插入时成立的约束会带来虚假的完整性感觉。数据库可能漂移到不变量被违反的状态,而在漂移发生的那一刻不会引发任何错误。故障会在之后显现,往往是在最糟糕的时刻。

同样的道理也适用于 CHECK 中使用的函数。读取其他表的函数会引入相同的跨行依赖,即使语法看起来是局部的。该限制关乎表达式能观察到什么,而不是它如何编写。

转储与恢复失败

跨行 CHECK 的文档记载后果是数据库转储与恢复可能失败。在恢复期间,行会按照转储所决定的顺序加载,而该顺序在每一个中间步骤可能不满足该约束。即使完整的数据库状态与该约束一致,恢复也可能失败,因为约束是在插入数据时逐行求值的。

这使得该问题在运维上非常严重。一个在开发环境中能干净恢复的备份,如果行顺序不同、数据量改变了加载顺序,或者转储是在数据生命周期的不同时间点获取的,就可能在生产环境中失败。该失败相对于最终状态而言并非确定性的;它取决于到达该状态所经过的路径。

实际教训是:无法从单行求值的约束不能作为声明式保证来依赖。它可能通过测试,甚至可能在某一次恢复中通过,但它并不提供表面上看起来具有的完整性属性。

推荐的替代方案

当规则确实跨越行或表时,PostgreSQL 提供了为此目的设计的声明式约束。使用 UNIQUE 实现跨行唯一性,使用 EXCLUDE 处理范围和重叠规则,使用 FOREIGN KEY 实现引用完整性。这些约束由数据库针对相关行强制执行,并随着数据变化被正确维护。

对于这些都无法表达的规则,请使用触发器或应用层验证,并清楚地记录该规则不是声明式约束。触发器可以观察其他行,并且可以编写为在相关变更时重新验证,但它自身带有复杂性,必须小心维护。

一个有用的心智模型是问:该规则能否仅从候选行决定。如果可以,CHECK 是合适且低成本的。如果不可以,该规则属于理解这种关系的约束类型,或者属于你接受作为执行点的过程式代码。

CREATE TABLE products (
    product_no integer PRIMARY KEY,
    name text NOT NULL,
    price numeric NOT NULL CHECK (price > 0),
    discounted_price numeric CHECK (discounted_price > 0),
    CONSTRAINT valid_discount CHECK (price > discounted_price)
);

适用条件

  • CHECK 表达式是否仅引用被插入或更新的行的列?
  • 该规则是否涉及其他行或其他表,从而需要改用 UNIQUE、EXCLUDE 或 FOREIGN KEY?
  • 在必须排除空值的地方,可空列是否已与 NOT NULL 配对?
  • 你是否已对该表进行转储和恢复测试,以确认约束在重新加载后仍然有效?
  • 约束是否已命名,以便日后识别和修改?

本文描述的是受支持版本(撰写时为 14 至 18)所记载的 PostgreSQL 行为。错误消息的确切措辞以及转储和恢复的行为可能因版本和所用工具(pg_dump、pg_restore 或逻辑复制)而异。示例仅用于说明,并假设为默认安装且没有自定义约束触发器。本文不详细涵盖延迟约束、排除约束运算符或基于触发器的强制执行;这些需要单独讨论。本文也不声称任何特定的恢复一定会失败,只是说跨行 CHECK 无法保证完整性,并且可能因行的加载顺序而导致恢复失败。

参考来源

  1. PostgreSQL: table expressions ↗
  2. PostgreSQL: constraints ↗
  3. PostgreSQL: aggregate functions ↗
返回顶部 ↑