跳至内容

4.4 用约束表达不变量

约束把一句业务命题变成所有写入口共享、并发下原子执行的失败条件。它的价值不止是“挡脏数据”:命名、类型、检查时点、支持索引与 SQLSTATE 共同组成可审计合同。本节从常用约束走到排他与延迟检查,同时严格说明每种机制不能做什么。

4.4.1 主键、唯一、外键与检查约束

选择约束先看规则的作用域:

不变量PostgreSQL 机制自动支持结构
一行有唯一且非空身份PRIMARY KEYunique B-tree + NOT NULL
一个/一组值在表中唯一UNIQUEunique B-tree
引用必须存在FOREIGN KEY被引用端必须有合格 unique;引用端不自动建索引
当前行布尔命题成立CHECK无索引
列值存在NOT NULL专用高效检查
任意两行不能满足一组冲突运算EXCLUDE指定 access method 的索引

命名约束就是命名失败

v1 不依赖 PostgreSQL 自动生成名:

CONSTRAINT product_price_minor_bounds
  CHECK (current_unit_price_minor BETWEEN 0 AND 1000000000000)

调用方可以按 SQLSTATE 类别处理,又能记录 constraint name 定位具体不变量:

SQLSTATEcondition本章例子
23505unique_violation重复 order_no
23503foreign_key_violation不存在的引用
23514check_violation非法币种/状态时间
23502not_null_violation必填列为空
23P01exclusion_violation时间段重叠

不要依赖英文 error message 文本,它会随版本、locale 和上下文变化。也不要假设多项同时违反时必定先报告某一个:约束检查顺序不是 API 合同。本章反例每次只制造一个目标错误,并验证 constraint name。

pg_constraint.conname 不是数据库全局唯一;定位时至少带 relation/schema。目录查询:

SELECT
    c.conrelid::regclass AS relation,
    c.conname,
    c.contype,
    c.convalidated,
    c.condeferrable,
    c.condeferred,
    pg_catalog.pg_get_constraintdef(c.oid, true) AS definition
FROM pg_catalog.pg_constraint AS c
WHERE c.conrelid IN (
    'shop.sales_order'::regclass,
    'shop.sales_order_item'::regclass,
    'shop.payment'::regclass
)
ORDER BY relation::text, c.conname;

PK、unique 与 identity 不是同义词

identity 生成候选值,PK 维护唯一/非空并声明主要行身份。一个表只能有一个 PK,却可以有多个业务 unique:

sales_order_pkey                    (order_id)
sales_order_order_no_key            (order_no)
sales_order_request_key             (customer_id, request_key)
sales_order_order_currency_key      (order_id, currency_code)

最后一个复合 unique 看似冗余,因为 order_id 已唯一;它是复合 FK 的合法目标,使 line/payment 必须携带与 order 相同的 currency。这个语义换来一个额外 unique index,成本在 4.5 明确记账。

默认情况下,unique constraint 把多个 NULL 视为互不相等,所以 nullable unique column 仍可有多个 NULL。PG15+ 提供 NULLS NOT DISTINCT,但本书 PG14–18 共同行为不能无条件使用。最简单的业务键通常应 NOT NULL

FK 既是存在性,也是生命周期

本章延续:

  • order→customer:ON DELETE RESTRICT
  • line→product:ON DELETE RESTRICT,历史快照仍保留引用;
  • line→order:ON DELETE CASCADE,line 是 order 的组成部分;
  • payment→order:ON DELETE RESTRICT,支付记录有独立保留要求。

FK 自动要求被引用列可唯一查找,却不会自动为 child referencing columns 建索引。删除/更新 parent 时 PostgreSQL 需要在 child 找引用;数据大时通常要为 child FK 设计索引,但索引列序和其他查询可以合并考虑,不能由 FK 机械生成器盲加。

CHECK 只维护当前行的稳定命题

PostgreSQL 明确不支持把其他行/表查询塞进 CHECK 并承诺持续一致。下面不是合法方向:

CHECK (
  amount_minor <= (
    SELECT sum(line_total_minor)
    FROM shop.sales_order_item
    WHERE order_id = payment.order_id
  )
)

其他行后来变化时不会自动重检,dump/restore 也可能破坏假设。跨行“互不重叠”可用 EXCLUDE,引用用 FK,唯一用 UNIQUE;真正跨表业务命令留给事务/trigger/constraint trigger 等受验证实现。

CHECK 还把 NULL 结果视为通过,所以必填列另加 NOT NULL。v1 的 money bounds、ASCII 格式和状态时间都是只引用当前行、对同一输入稳定的表达式,符合边界。

4.4.2 排他约束与 btree_gist 的适用条件

UNIQUE 只能表达“这些键不能相等”。许多业务冲突是“时间段不能重叠”“圆不能相交”“同笼不能出现不同动物”。EXCLUDE 接受一组 operator,要求任意两行比较时,至少有一个 operator 结果为 false 或 NULL。

单资源预约:

CREATE TEMP TABLE booking_window (
  booking_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  slot       tstzrange NOT NULL,
  CONSTRAINT booking_window_nonempty
    CHECK (NOT isempty(slot)),
  CONSTRAINT booking_window_no_overlap
    EXCLUDE USING gist (slot WITH &&)
);

&& 为 range overlap。排他约束自动建立 GiST index,两个重叠 slot 触发 23P01。把边界统一为 [) 很重要:09:00–10:00 与 10:00–11:00 不重叠,避免相邻时段同时包含 10:00。

多资源为什么需要 btree_gist

若每个 room 分别不能重叠:

EXCLUDE USING gist (
  room_id WITH =,
  slot    WITH &&
)

GiST 原生理解 range overlap,但普通 bigint/text equality 需要相应 GiST operator class。btree_gist 为常见标量类型提供类似 B-tree 的 GiST operator classes,适合这种“标量相等 + 空间/范围运算”的多列 GiST。

它不是“更快的 B-tree”:

  • 官方文档明确说通常不会优于标准 B-tree;
  • 它不能像 B-tree unique index 那样维护普通唯一性;
  • 它的价值是让不同运算共存于 GiST/EXCLUDE。

在 Pigsty L1 先观察:

SELECT
    name,
    default_version,
    installed_version
FROM pg_catalog.pg_available_extensions
WHERE name = 'btree_gist';

本章实测 btree_gist_available=true,但单列 slot 实验无需创建 extension,因此不改变数据库 extension 状态。若真实模式需要它,再由配置/迁移明确:

CREATE EXTENSION btree_gist;

Pigsty 负责把所需软件包/内核扩展交付到节点;CREATE EXTENSION 仍是目标 database 中的 DDL,要纳入 owner、schema、版本和备份恢复合同。

并发正确性

EXCLUDE 的优势不是语法短,而是把冲突交给 access method 与约束在并发写入中仲裁,避免应用“先查没有重叠,再插入”之间的竞态。应用仍可预查给友好提示,但最终以数据库 exclusion_violation 为准。

选择前确认:

  • 冲突关系可由可索引 operator 精确表达;
  • NULL/空 range 是否允许;
  • 边界是 [) 还是其他形式;
  • 是否需要按资源、租户等额外等值维度隔离;
  • 索引/锁竞争在目标写入量下可接受。

4.4.3 仅对支持类型使用 DEFERRABLE,并说明事务末校验代价

DEFERRABLE 不是“让所有约束最后再查”的通用开关。PostgreSQL 只允许:

  • UNIQUE;
  • PRIMARY KEY;
  • EXCLUDE;
  • REFERENCES / FOREIGN KEY。

NOT NULL 与 CHECK 不可延迟。当前文档若写出 CHECK (...) DEFERRABLE,DDL 本身就是错的。

两个维度

DEFERRABLE / NOT DEFERRABLE
    能否由事务改变检查时点

INITIALLY IMMEDIATE / INITIALLY DEFERRED
    每个事务开始时的默认检查时点

NOT DEFERRABLE 是默认,不能用 SET CONSTRAINTS 推迟。deferrable 且 initially immediate 默认在每条语句后检查,可以在事务中改为 deferred;initially deferred 默认到提交时检查。

实验中有两个唯一 slot:

('A', 1), ('B', 2)

要在一条/一组操作中交换为 A=2、B=1,中间状态可能碰到唯一值。定义:

CONSTRAINT display_slot_slot_key
UNIQUE (slot_no)
DEFERRABLE INITIALLY IMMEDIATE

事务内:

SET CONSTRAINTS display_slot_slot_key DEFERRED;
UPDATE display_slot
SET slot_no = CASE slot_no WHEN 1 THEN 2 WHEN 2 THEN 1 END;
SET CONSTRAINTS display_slot_slot_key IMMEDIATE;

最后一条会立即检查当前状态;若仍冲突,就在此处失败,不必等 COMMIT。这既是实验验收,也是长事务中提早暴露错误的方法。

代价与限制

延迟检查会把失败推到更远位置,事务可能做了大量工作后才回滚,并在事务期间保留用于待检查状态的资源。官方还指出:

  • deferrable constraint 不能作为 INSERT ... ON CONFLICT 的 conflict arbiter;
  • deferrable uniqueness 可能显著慢于 immediate uniqueness;
  • FK 被引用的 unique/PK 必须是 non-deferrable 合格键。

所以“以后批量导入方便”不足以把所有键改成 deferrable。先有一个需要事务内暂时不一致、提交时恢复的具体流程,再付成本。

v1 所有业务约束保持 immediate。状态转换也在每次 UPDATE 时立即拒绝;订单 command 没有证明需要暂时进入图外状态。隔离的 constraint-lab.sql只演示正确适用点,不改变核心模式。

目录验收:

SELECT
    conrelid::regclass,
    conname,
    contype,
    condeferrable,
    condeferred,
    convalidated
FROM pg_catalog.pg_constraint
WHERE conrelid = 'display_slot'::regclass;

临时 unique 应为 deferrable=true、deferred-by-default=false;临时 CHECK 不应出现 deferrable=true。

本节验收

  • 每条不变量根据行内、唯一、引用或跨行冲突选对约束;
  • error handling 使用 SQLSTATE + constraint identity,不匹配英文全文;
  • 知道 unique/PK/EXCLUDE 会建索引,FK child 不自动建;
  • 排他实验真实拒绝 overlap,且没有无谓安装 extension;
  • 能列出四类可延迟约束与两类不可延迟约束;
  • 能解释延迟检查对 ON CONFLICT、失败时点和性能的影响;
  • 核心 v1 没有为假想需求滥用 DEFERRABLE。

参考资料


上一节:NULL、默认值与生成值 · 返回本章目录 · 下一节:类型与约束的物理代价 · 查看全书目录 · 查看索引中心

最后更新于