外键的本质竟然是触发器?深入解析 PostgreSQL 约束底层

什么是约束

在定义表或列时,可以为数据附加校验或强制规则的,这些规则称为约束。

数据类型本身只能提供较粗粒度的限制,例如 numeric 无法限定只能为正数。更具体的规则(如唯一性、取值范围等)需要通过约束来实现。

约束用于保障数据完整性。当插入或默认值违反约束时,PostgreSQL 会直接报错。

本质上,约束是数据库层面强制执行的数据规则。一旦缺失或使用不当,数据问题往往会悄然积累,并最终演变为难以排查的缺陷。

pg_constraint 系统目录

从内部实现来看,PostgreSQL 中的所有约束,都会以记录的形式存储在 pg_constraint 系统目录中。

🗄️ 什么是系统目录(Catalog)

系统目录是 PostgreSQL 用来保存元数据的系统表。用户表存储业务数据,而系统目录则记录“数据库自身的信息”,例如表、列、索引、约束等。

pg_constraint 之外,常见的系统目录还包括:

  • pg_class:所有关系对象(表、索引、视图等)
  • pg_attribute:表的列信息
  • pg_type:数据类型(含域和自定义类型)
  • pg_namespace:模式(schema)
  • pg_index:索引相关信息(其余信息主要在 pg_class 中)
  • pg_proc:函数、过程及聚合函数

这些表都位于 pg_catalog 模式中,该模式在 search_path 中默认优先,因此通常无需显式指定。

pg_constraint 用于存储表上的 CHECK、NOT NULL、主键、唯一、外键和排他约束。

需要注意的是,在 PostgreSQL 18 之前,表上的 NOT NULL 约束并不存储在 pg_constraint 中,而是记录在 pg_attribute;从 PostgreSQL 18 开始,NOT NULL 才在 pg_constraint 中拥有独立记录。

PostgreSQL 17:

pg_constraint 目录用于存储 CHECK、主键、唯一、外键、排他约束,以及定义在域上的 NOT NULL 约束。

表上的 NOT NULL 约束仍然记录在 pg_attribute 中,而非 pg_constraint

因此,每一个约束都会在 pg_constraint 中以一条记录存在,并通过 contype 字段标识约束类型。后文将对这些类型逐一说明,其中也包括一个较为特殊的类型:t
1.png

列约束与表约束

pg_constraint 文档中明确指出:

列约束不会被特殊处理,每个列约束在内部都等价于某种表约束。

SQL 层面上,约束可以写在列定义后,也可以写成表约束,例如:

CREATE TABLE products_oct (
  price numeric CHECK (price > 0)
);

CREATE TABLE products_nov (
  price numeric,
  CHECK (price > 0)
);

第一种写法只作用于单列,第二种写法可以作用于多列。但在 PostgreSQL 内部,这两种方式最终都会被统一记录为 pg_constraint 中的一行数据。

因此,无论约束以哪种形式定义,都可以通过 ALTER TABLE .. DROP CONSTRAINT .. 删除。系统目录中并不存在“列约束”的特殊标识,它只是作用于单列的表约束。

下面的查询用于查看两个示例表中的约束定义:

SELECT
  rel.relname AS table_name,
  c.conname,
  c.contype,
  c.conrelid::regclass AS table_ref,
  c.conkey,
  pg_get_constraintdef(c.oid, true) AS constraint_def
FROM pg_constraint c
JOIN pg_class rel ON rel.oid = c.conrelid
WHERE rel.relname IN ('products_oct', 'products_nov');

⚡ 查询要点说明

  • pg_class 用于存储所有关系对象的元数据。
  • relname 为表的名称,由于 pg_constraint 中仅保存表的 OID,需要通过 rel.oid = c.conrelid 进行关联。
  • conrelid 表示约束所属表的 OID。
  • conname 为约束名称,约束名称在单表内唯一,可由系统自动生成,也可在 DDL 中显式指定。
  • contype 表示约束类型(cfnpuxt)。
  • conkey 为属性编号数组,用于标识约束涉及的列(如 {1} 表示第一列,{1,3} 表示第一和第三列)。
  • pg_get_constraintdef() 为系统函数,用于获取约束定义文本。

查询结果如下所示。两种约束在内部表示上几乎完全一致,仅约束名称和所属表不同。

-[ RECORD 1 ]--+---------------------------
table_name     | products_nov
conname        | products_nov_price_check
contype        | c
table_ref      | products_nov
conkey         | {1}
constraint_def | CHECK (price > 0::numeric)
-[ RECORD 2 ]--+---------------------------
table_name     | products_oct
conname        | products_oct_price_check
contype        | c
table_ref      | products_oct
conkey         | {1}
constraint_def | CHECK (price > 0::numeric)

约束触发器(Constraint Trigger)

在 pg_constraint 中,使用 CREATE CONSTRAINT TRIGGER 创建的约束触发器同样会生成记录,其 contype 标记为 t。常见约束如 UNIQUEuCHECKc

约束触发器是一种将触发器机制与约束系统结合的特殊形式,主要用于数据一致性校验。

可延迟触发器(Deferrable Triggers)

约束触发器通过 CREATE CONSTRAINT TRIGGER 创建,语法与普通触发器类似,但指定 CONSTRAINT 后生成的是约束触发器。其核心区别在于,约束触发器可以通过 SET CONSTRAINTS 控制触发执行时机。

其执行时机可通过 SET CONSTRAINTS 控制:

  • IMMEDIATE:语句结束时检查
  • DEFERRED:事务提交时检查

与普通触发器不同,约束触发器允许在事务级别延迟执行,并在运行时动态调整。

⚠️ WHEN 条件始终立即评估

即使触发器本身是延迟执行的,WHEN 子句仍在语句执行时立即判断,用于决定是否进入执行队列。

AFTER 触发器

在创建触发器时,需要指定触发函数的执行时机:BEFOREAFTERINSTEAD OF。约束触发器只能定义为 AFTER 触发器。

约束触发器并不用于改变数据处理流程,而是在数据操作完成后进行条件校验。约束的核心目标是数据验证,而普通触发器通常用于数据修改。约束触发器属于校验机制的一部分,当其所实现的约束条件被违反时,应当抛出异常。

FOR EACH ROW 触发器

创建触发器时,还需要指定触发粒度:

  • FOR EACH ROW:对受影响的每一行执行一次
  • FOR EACH STATEMENT:每条 SQL 语句只执行一次

约束触发器只能定义为 FOR EACH ROW,这是因为约束校验依赖于单行数据的具体取值。

需要注意的是,约束触发器不支持 OR REPLACE 选项,因此只能通过删除后重新创建的方式进行修改。

为什么需要约束触发器

《Triggers to enforce constraints in PostgreSQL》一文中,Laurenz Albe 指出,某些需要在表级别强制执行的规则,无法通过常规约束直接表达,此时可借助触发器机制实现。文中结合示例说明了适用场景,并分析了约束与触发器在 MVCC 行为上的差异。

在实际系统中,约束触发器很少由用户显式创建。PostgreSQL 更多将其作为约束实现的内部基础机制使用,尤其是在外键约束中。外键依赖系统自动生成的约束触发器实现,这一设计也使外键能够支持 DEFERRABLEINITIALLY DEFERRED 等特性。

什么是域

域可以理解为“带约束的数据类型”。它基于已有类型(如 text、integer),但可以附加 NOT NULL、CHECK 约束或默认值,用于集中定义数据规则。

示例如下:

CREATE DOMAIN email_address AS text
  CHECK (VALUE ~* '^[^@]+@[^@]+\.[^@]+$');

CREATE TABLE users (
  id serial PRIMARY KEY,
  email email_address NOT NULL
);

-- This will fail
INSERT INTO users(email) VALUES ('not-an-email');

-- This will be successful
INSERT INTO users(email) VALUES ('ok@example.com');

上述示例中定义了一个名为 email_address 的新类型。所有使用该类型的列,在插入或更新数据时都会自动校验正则表达式。即使表本身未显式定义 CHECK 约束,非法值仍会被拒绝。

通常情况下,约束是附加在表上的,但 PostgreSQL 同样支持在域上定义约束。以下查询演示了如何从 pg_constraint 中查询定义在域上的约束:

SELECT c.conname,
       pg_get_constraintdef(c.oid, true) AS definition,
       t.typname AS domain_name
FROM pg_constraint c
JOIN pg_type t ON t.oid = c.contypid
WHERE c.contype = 'c'
  AND c.contypid <> 0;

查询要点说明

  • pg_constraint 存储所有类型的约束,包括表约束和域约束
  • pg_type 存储数据类型信息,包括域
  • contypid 表示约束所属域的 OID。当 contypid 非 0 时,约束附加在域上;当为 0 时,约束附加在表上,此时使用 conrelid
  • 通过 JOIN pg_type t ON t.oid = c.contypid 获取域名称
  • 域仅支持 CHECK 约束,因此筛选条件为 c.contype = 'c'
  • pg_get_constraintdef() 用于获取约束定义文本,与 CREATE DOMAIN 中的定义一致

查询结果如下,展示了约束名称、定义内容以及所属域:

conname            | definition                            | domain_name
-------------------+---------------------------------------+-------------
email_address_check|CHECK (VALUE ~* '^[^@]+@[^@]+\.[^@]+$')| email_address

总结

通过 pg_constraint 系统目录,可以系统理解 PostgreSQL 中各类约束的内部表示方式。无论是列约束、表约束、约束触发器,还是域上的约束,本质上都通过同一套机制进行管理,这是 PostgreSQL 约束体系设计上的关键特点。

原文链接:

https://xata.io/blog/constraints-in-postgres

作者:Gulcin Yildirim Jelinek

posted @ 2025-12-29 16:20  IvorySQL  阅读(3)  评论(0)    收藏  举报