🎯 什麼情境該想到我
當你「有一個繼承階層要存進資料庫,想用一張表全裝、查詢時不用 join」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:把整個繼承階層的所有類別都存進同一張表,用一個型別欄位區分每列屬於哪個子類別。
做法要點:
- 一張表包含所有子類別可能用到的欄位;某列用不到的欄位就留空(NULL)。
- 用一個 type / discriminator 欄位標明該列對應的具體類別。
- 讀寫任何子類別都不需要 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 的落地:一張表 + kind 欄位 + 分派
CREATE TABLE payments (
id text PRIMARY KEY,
kind text NOT NULL, -- ★ 'card' | 'transfer' | 'voucher'
amount bigint NOT NULL,
-- ★ card 專屬
card_last4 text,
card_brand text,
-- ★ transfer 專屬
bank_code text,
account_no text,
-- ★★ 每個子型別用不到的欄位都是 NULL
CHECK (
(kind = 'card' AND card_last4 IS NOT NULL AND bank_code IS NULL) OR
(kind = 'transfer' AND bank_code IS NOT NULL AND card_last4 IS NULL) OR
(kind = 'voucher')
) -- ★★ 這個 CHECK 是單表繼承唯一能挽回資料完整性的手段
);★★ 那個 CHECK 約束是這個模式的關鍵:
沒有它,你的表可以存在「kind=‘card’ 但有 bank_code」的列——
而這種資料一旦出現,就沒有任何程式碼能正確處理它。
⚠️ 但 CHECK 會隨子型別增加而膨脹,而且改子型別要 migration。
// ★ Go 端的讀取:一次查詢,依 kind 分派
func fromRow(r db.Payment) (Payment, error) {
switch r.Kind {
case "card":
return CardPayment{Amount: r.Amount, Last4: r.CardLast4.V, Brand: r.CardBrand.V}, nil
case "transfer":
return TransferPayment{Amount: r.Amount, BankCode: r.BankCode.V}, nil
}
return nil, fmt.Errorf("unknown payment kind %q", r.Kind) // ★★ 一定要處理
}★★ default 分支不可省略:Go 沒有窮盡檢查,
而資料庫裡可能有一個新版程式碼寫入的 kind,而舊版 pod 還在跑(滾動部署)。
★★ 三種繼承對應一次比較(這是本頁最重要的表)
| 單表繼承 | 類別表繼承 | 具象表繼承 | |
|---|---|---|---|
| 表數 | ★★ 1 | 1 + N(父 + 每個子) | N(每個具體類一張) |
| 讀取一筆 | ★★ 0 次 JOIN | ★ N 層 JOIN | ★ 0 次 JOIN(但要先知道型別) |
| 查「所有子型別」 | ★★ 一次查詢 | 一次 JOIN | ★★ UNION N 張表 |
| 欄位 NULL | ★★ 很多 | ★ 無 | 無 |
| 非空約束 | ★★ 做不到(只能靠 CHECK) | ★ 可以 | ★ 可以 |
| 加一個子型別 | ★ ALTER TABLE 加欄位 | ★ 新增一張表 | ★ 新增一張表 |
| 改共同欄位 | ★★ 改一處 | ★ 改一處 | ★★ 改 N 張表 |
| 空間 | ★ 浪費(NULL 也佔位,但 Postgres 有 null bitmap 較省) | 省 | 省 |
| ★ 適合 | ★★ 子型別少、差異小、查詢多 | 差異大、要約束 | 子型別間幾乎不共查 |
★★ 判準(按重要度):
① ★★ 你多常「跨子型別一起查」?
很常(列出所有付款)→ ★ 單表繼承(一次查詢)或類別表繼承
幾乎不會 → 具象表繼承
② ★★ 子型別的差異有多大?
小(2–3 個專屬欄位)→ ★★ 單表繼承
大(十幾個)→ ★ 類別表繼承(否則表會有一半是 NULL)
③ ★ 需要非空約束嗎?
需要 → ★★ 單表繼承做不到(只能 CHECK)
★ 什麼時候單表繼承是明確的錯誤選擇
- ★★ 子型別超過 5–6 個,且各有 5+ 專屬欄位 → 表會有 40 個欄位、90% 是 NULL。
- ★ 子型別的數量會持續成長(外部決定的支付方式)→ 每次都要 ALTER TABLE。
- ★ 需要每個子型別各自的唯一索引/外鍵 → 部分索引(partial index)能救一部分:
CREATE UNIQUE INDEX ON payments (bank_code) WHERE kind = 'transfer';
★ 但這個技巧本身就是在補單表繼承的洞。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
表會有很多空欄位,浪費空間、語意較弱、不易加非空約束。
-
所有子類別欄位擠一張表,欄位多時難維護、容易變寬表。
-
想正規化、少空欄位改用類別表繼承;想各自獨立表改用具象表繼承。
-
★★ 沒有
CHECK約束時,表可以存在「kind 與欄位不一致」的列——而那種資料沒有任何程式碼能正確處理。 -
★★ 無法對子型別專屬欄位加 NOT NULL。這是這個模式最根本的損失。
-
★★
default分支不可省略:滾動部署期間,新版可能寫入舊版不認識的 kind。 -
★ 子型別超過 5–6 個且各有多個專屬欄位 → 用 類別表繼承,否則表 90% 是 NULL。
-
★ CHECK 約束會隨子型別增加而膨脹,改子型別要 migration。
-
★ 部分索引(
WHERE kind = 'x')能補一部分洞,但那本身就是在補這個模式的缺陷。 -
子型別數量由外部決定(第三方支付方式)→ 不要用單表繼承,每次都要 ALTER TABLE。
-
Go 沒有繼承,這裡的「子類」是介面實作或判別聯集(見 以子類取代型別碼)。
🔗 相關工具
- 類別表繼承 Class Table Inheritance(正規化、以 join 組合的對照)
- 具象表繼承 Concrete Table Inheritance(每個具象類別一張完整表的對照)
- 繼承對應器 Inheritance Mappers(組織繼承載入 / 儲存)
- 企業應用架構模式
- 類別表繼承、具象表繼承 —— 另兩種對應方式
- 繼承對應器 —— 三者共用的對應邏輯
- 以子類取代型別碼 —— Go 用介面取代繼承
- 工具-Go嵌入機制 —— 為什麼 Go 沒有虛擬分派
- 序列化大物件 —— 子型別專屬欄位放 jsonb 的替代方案
- 工具-判別聯集 —— TS 的窮盡檢查