🎯 什麼情境該想到我
當你「有繼承階層,想讓每個類別各自對應一張表、資料表保持正規化」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:讓繼承階層中每個類別(含父類別)各自對應一張表,用 join 把一個物件的父子欄位組合起來。
做法要點:
- 父類別一張表放共通欄位,每個子類別一張表放其專屬欄位。
- 各表以相同主鍵相連;載入某物件時 join 父表與對應子表取得完整欄位。
- 資料表對應類別結構、少有空欄位,關係清楚、正規化程度高。
- 三種繼承對應中最貼近物件模型的一種。
★★ 先講 Go:這個模式的前提在 Go 不成立
// ★ Go 沒有繼承,而嵌入沒有虛擬分派(見 [[工具-Go嵌入機制]])
type Base struct{}
func (b Base) Name() string { return "base" }
func (b Base) Greet() string { return "hi, " + b.Name() } // ★ b 永遠是 Base
type Child struct{ Base }
func (c Child) Name() string { return "child" }
Child{}.Greet() // ★★ "hi, base"★★ 所以「把繼承階層存進資料庫」這個問題,在 Go 裡換成
「把一組介面實作/判別聯集存進資料庫」。
★ 三種對應方式仍然適用,只是「子類」變成「實作型別」(見 以子類取代型別碼)。
★ Go 的落地:父表 + 子表,用同一個 ID
CREATE TABLE payments ( -- ★ 父表:共同欄位
id text PRIMARY KEY,
kind text NOT NULL, -- ★★ 判別欄位仍然需要
amount bigint NOT NULL
);
CREATE TABLE card_payments ( -- ★ 子表:專屬欄位
payment_id text PRIMARY KEY REFERENCES payments(id) ON DELETE RESTRICT,
last4 text NOT NULL, -- ★★ 可以 NOT NULL —— 這是本模式的核心優勢
brand text NOT NULL
);
CREATE TABLE transfer_payments (
payment_id text PRIMARY KEY REFERENCES payments(id) ON DELETE RESTRICT,
bank_code text NOT NULL,
account_no text NOT NULL
);★★
NOT NULL是這個模式存在的全部理由。
單表繼承做不到;而「每個 card_payment 都必須有 last4」這件事
只有資料庫能真正強制。
★★ 讀取:三種寫法,成本差很多
-- ★ ① 已知型別 → 一次 JOIN
SELECT p.*, c.last4, c.brand
FROM payments p JOIN card_payments c ON c.payment_id = p.id
WHERE p.id = $1;
-- ★★ ② 未知型別 → 這是本模式最痛的地方
-- a. 先查 payments 拿 kind,再依 kind 查對應的子表 → ★ 2 次往返
-- b. ★ LEFT JOIN 所有子表 → 欄位變成 N 倍,而且大多是 NULL(★ 又回到單表的問題)
-- c. ★★ 在 SQL 裡用 CASE 組成 jsonb(見下)
-- ★ ③ 列出所有付款(跨子型別)
SELECT p.*,
to_jsonb(c) - 'payment_id' AS card,
to_jsonb(t) - 'payment_id' AS transfer
FROM payments p
LEFT JOIN card_payments c ON c.payment_id = p.id
LEFT JOIN transfer_payments t ON t.payment_id = p.id;
-- ★ 一次往返、欄位不爆;★★ 代價:子型別增加時這個查詢要改★★ ②a(兩次往返)在「列一頁付款」時會變成 N+1:
100 筆付款、3 種型別 → 要嘛 1 + 3 次查詢(按型別分組批次查),要嘛 101 次。
★ 正確做法:按 kind 分組,每組一次 WHERE payment_id = ANY($1) ——
★★ 這比 LEFT JOIN 全部子表更省,也比 N+1 快得多。
★★ 寫入:必須是原子的,而且順序固定
func (r *Repo) Insert(ctx context.Context, q db.Querier, p Payment) error {
// ★★ 父先、子後(外鍵方向決定)
if err := q.InsertPayment(ctx, db.InsertPaymentParams{
ID: p.ID(), Kind: p.Kind(), Amount: p.Amount(),
}); err != nil { return fmt.Errorf("insert payment: %w", err) }
switch v := p.(type) {
case CardPayment:
return q.InsertCardPayment(ctx, …)
case TransferPayment:
return q.InsertTransferPayment(ctx, …)
}
return fmt.Errorf("unknown payment type %T", p) // ★★ 這一行會在交易內觸發 rollback
}★★ 兩個必須處理的完整性問題:
① ★★ 父列存在但子列不存在(「孤兒父」)
→ 交易保證了原子性,但★ 若有人直接 INSERT payments 就會出現
→ ★ 資料庫層無法強制「每個 payment 必須有一個對應的子列」
(★★ 這是類別表繼承的根本缺陷 —— 反向的外鍵約束不存在)
→ ★ 只能靠:① 寫入權只給應用程式 ② 定期一致性檢查
② ★ 刪除順序:子先、父後(或用 ON DELETE CASCADE,★ 但見 [[外鍵對應]] 的警告)
★★ 三種繼承對應的選擇(判準)
★★ 選類別表繼承的唯一充分理由:★ 你需要子型別專屬欄位的 NOT NULL 與外鍵約束。
★ 代價清單:
① ★★ 未知型別的讀取需要 N 次查詢或 LEFT JOIN 全部子表
② ★★ 「父必須有子」無法用外鍵強制
③ ★ 加一個子型別要新增一張表(★ 但不必 ALTER 既有表 —— 這是優點)
④ ★ 寫入要兩次 INSERT,刪除要兩次 DELETE
★ 若你發現自己一直在 LEFT JOIN 所有子表,那你付了類別表繼承的成本、
卻得到單表繼承的欄位形狀——★ 這時改用 單表繼承 + CHECK 更划算。
★ Go 的一個實用替代:父表 + jsonb 存專屬欄位
CREATE TABLE payments (
id text PRIMARY KEY,
kind text NOT NULL,
amount bigint NOT NULL,
details jsonb NOT NULL, -- ★ 專屬欄位放這裡
CHECK (jsonb_typeof(details) = 'object')
);★ 優點:一次查詢、加子型別不用 migration、Go 端用 json.RawMessage 延遲解析。
★★ 代價:失去 NOT NULL 與外鍵(見 序列化大物件)——
而那正是你原本選類別表繼承的理由。所以這是一個明確的取捨,不是升級。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
讀取單一物件需要多次 join,深階層時效能較差。
-
表數量多,schema 較繁瑣。
-
追求查詢速度、可容忍空欄位改用單表繼承;不想 join 改用具象表繼承。
-
★★ 子型別欄位可以
NOT NULL是這個模式存在的全部理由。 不需要它就不要付這個成本。 -
★★ 「每個父列必須有一個子列」無法用外鍵強制(反向外鍵不存在)。只能靠寫入權限 + 定期一致性檢查。
-
★★ 未知型別的讀取是最痛的地方:兩次往返,在列表頁會變成 N+1。正確做法是按 kind 分組、每組一次
ANY($1)。 -
★ 若你一直在 LEFT JOIN 所有子表,就改用 單表繼承 + CHECK——你付了成本卻沒得到好處。
-
★ 寫入順序父先子後、刪除順序子先父後。 用
ON DELETE CASCADE要先讀 外鍵對應 的警告。 -
★ 判別欄位(kind)仍然需要,否則你不知道該查哪張子表。
-
加子型別要新增一張表,但不必 ALTER 既有表——這是它相對單表繼承的優點。
-
「父表 + jsonb」是明確的取捨而非升級:換回一次查詢,但失去 NOT NULL 與外鍵。
🔗 相關工具
- 單表繼承 Single Table Inheritance(一張表全裝、免 join 的對照)
- 具象表繼承 Concrete Table Inheritance(每個具象類別獨立完整表的對照)
- 繼承對應器 Inheritance Mappers(組織繼承載入 / 儲存)
- 企業應用架構模式
- 單表繼承、具象表繼承 —— 另兩種對應方式
- 繼承對應器 —— 三者共用的對應邏輯
- 序列化大物件 —— jsonb 替代方案的代價
- 外鍵對應 —— 刪除行為的警告
- 以子類取代型別碼 —— Go 用介面取代繼承
- 工具-Go嵌入機制 —— 為什麼 Go 沒有虛擬分派