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)
);
這個順序是有意義的:
- Type 與
NOT NULL定義基本形狀。 CHECK驗證單一 row。UNIQUE與 primary key 阻止重複 identity。- Foreign key 保護 table 之間的 reference。
- 前面都無法清楚表達時,才考慮 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。