← 所有文章2026.05.21 · CS 411 · 資料庫系統

Constraints 與 Triggers:讓所有寫入者都遵守不變量

用可測試的失敗行為,安排 NOT NULL、CHECK、UNIQUE、foreign key 與 trigger 的責任。

Invariant 是無論 application、migration、script 或管理者怎麼寫入,都必須成立的事實。只要資料庫能直接表達,它通常就是最有力的執行位置。

使用最簡單、足夠的機制

CREATE TABLE account (
  account_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email      text   NOT NULL UNIQUE,
  balance    numeric(12,2) NOT NULL CHECK (balance >= 0),
  status     text   NOT NULL CHECK (status IN ('active', 'frozen'))
);

CREATE TABLE transfer (
  transfer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  from_id     bigint NOT NULL REFERENCES account(account_id),
  to_id       bigint NOT NULL REFERENCES account(account_id),
  amount      numeric(12,2) NOT NULL CHECK (amount > 0),
  CHECK (from_id <> to_id)
);

這個順序是有意義的:

  1. Type 與 NOT NULL 定義基本形狀。
  2. CHECK 驗證單一 row。
  3. UNIQUE 與 primary key 阻止重複 identity。
  4. Foreign key 保護 table 之間的 reference。
  5. 前面都無法清楚表達時,才考慮 trigger。

白話來說: 能裝鎖就先不要請警衛。Declarative constraint 看得見、資料庫懂得最佳化,而且每個寫入者都會自動受到約束。

Constraint 拒絕不合法狀態

Application validation 能提供較好的錯誤訊息,卻不能取代資料庫 enforcement。兩個 request 可以同時通過「email 尚未使用」的檢查,再一起 insert。Unique constraint 會讓其中一個成功,另一個得到明確失敗。

Foreign-key action 是商業決策:

  • RESTRICT 或 NO ACTION:還有 child 時拒絕刪除。
  • CASCADE:child 也一起刪除或更新。
  • SET NULL:保留 child,但移除 relationship。

不要只因為刪除方便就選 action;它實際定義哪些歷史資料會留下。

規則跨越資料事件時才用 trigger

如果每次狀態修改都必須留下 audit,trigger 可能合理:

CREATE FUNCTION audit_account_change() RETURNS trigger AS $$
BEGIN
  INSERT INTO account_audit(account_id, old_status, new_status, changed_at)
  VALUES (OLD.account_id, OLD.status, NEW.status, current_timestamp);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER account_status_audit
AFTER UPDATE OF status ON account
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION audit_account_change();

Trigger 和原本 update 位於同一個 transaction;audit insert 失敗,update 也會一起失敗。

Trigger 危險在哪裡

Trigger 是 implicit behavior。一個看似簡單的 update,背後可能多做寫入、取得更多 lock,或因呼叫者看不到的原因失敗。Trigger chain 還會讓順序與 recursion 變得難懂。

可由 query 推導的值、外部 service call 和長 workflow 都不適合 trigger。跨出資料庫的工作,應考慮明確 transaction 或 outbox event。

不只測成功,也測拒絕

每條規則都保留一個必須失敗的 statement:

INSERT INTO transfer(from_id, to_id, amount)
VALUES (10, 10, -5);

錯誤訊息指出哪個 constraint 拒絕資料,也能驗證 enforcement 是否放在預期位置。

複習卡

  • 把 invariant 放在最低且清楚的 declarative 層級。
  • Application validation 與 database constraint 解決不同問題。
  • Foreign-key action 定義資料生命週期。
  • Trigger 具有 transaction 保證,但行為隱含。
  • Trigger 工作保持小、deterministic,而且不跨出資料庫。
  • 測試預期的 rejection path。