# 用约束表达不变量

LLMS 索引： [llms.txt](/llms.txt)

---

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

## 4.4.1 主键、唯一、外键与检查约束 {#item-4-4-1}

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

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

### 命名约束就是命名失败

v1 不依赖 PostgreSQL 自动生成名：

```sql
CONSTRAINT product_price_minor_bounds
  CHECK (current_unit_price_minor BETWEEN 0 AND 1000000000000)
```

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

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

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

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

```sql
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：

```text
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 并承诺持续一致。下面不是合法方向：

```sql
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` 的适用条件 {#item-4-4-2}

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

单资源预约：

```sql
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 分别不能重叠：

```sql
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 先观察：

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

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

```sql
CREATE EXTENSION btree_gist;
```

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

### 并发正确性

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

选择前确认：

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

## 4.4.3 仅对支持类型使用 DEFERRABLE，并说明事务末校验代价 {#item-4-4-3}

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

- UNIQUE；
- PRIMARY KEY；
- EXCLUDE；
- REFERENCES / FOREIGN KEY。

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

### 两个维度

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

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

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

实验中有两个唯一 slot：

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

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

```sql
CONSTRAINT display_slot_slot_key
UNIQUE (slot_no)
DEFERRABLE INITIALLY IMMEDIATE
```

事务内：

```sql
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`](/labs/ch04/constraint-lab.sql)只演示正确适用点，不改变核心模式。

目录验收：

```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。

## 参考资料

- [PostgreSQL 18：约束](https://www.postgresql.org/docs/18/ddl-constraints.html)
- [PostgreSQL 18：CREATE TABLE 与 DEFERRABLE](https://www.postgresql.org/docs/18/sql-createtable.html)
- [PostgreSQL 18：SET CONSTRAINTS](https://www.postgresql.org/docs/18/sql-set-constraints.html)
- [PostgreSQL 18：range 与 exclusion constraint](https://www.postgresql.org/docs/18/rangetypes.html)
- [PostgreSQL 18：`btree_gist`](https://www.postgresql.org/docs/18/btree-gist.html)
- [PostgreSQL 18：`pg_constraint`](https://www.postgresql.org/docs/18/catalog-pg-constraint.html)

---

[上一节：NULL、默认值与生成值](../03/) · [返回本章目录](../) · [下一节：类型与约束的物理代价](../05/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
