博客 / 詳情

返回

外鍵的本質竟然是觸發器?深入解析 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

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.