🎯 什麼情境該想到我
當你「有繼承階層,想讓每個具象類別各自對應一張含全部欄位的獨立表、查詢完全不 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 的落地:每個具體型別一張完整的表
-- ★★ 沒有父表 —— 共同欄位在每張表裡重複
CREATE TABLE card_payments (
id text PRIMARY KEY,
amount bigint NOT NULL, -- ★ 共同欄位(重複 1)
last4 text NOT NULL,
brand text NOT NULL
);
CREATE TABLE transfer_payments (
id text PRIMARY KEY,
amount bigint NOT NULL, -- ★ 共同欄位(重複 2)
bank_code text NOT NULL,
account_no text NOT NULL
);★★ 它的優點非常單純:讀寫任何一種型別都是「一張表、零 JOIN、全欄位 NOT NULL」。
★ 而缺點同樣單純:跨型別的任何事都很痛。
★★ 三個具體的痛點
-- ① ★★ 跨型別查詢 → UNION ALL(而且欄位要對齊)
SELECT id, amount, 'card' AS kind FROM card_payments
UNION ALL
SELECT id, amount, 'transfer' FROM transfer_payments
ORDER BY amount DESC LIMIT 20;
-- ★★ 問題:① 加一個型別 → 這個查詢要改(★ 而它可能出現在十個地方)
-- ② ★ ORDER BY + LIMIT 要先 UNION 全部再排序 ⇒ 無法只掃一張表
-- ③ ★★ 分頁極痛:OFFSET 要跨 N 張表算
-- ② ★★ ID 唯一性跨表無法保證
-- card_payments 和 transfer_payments 可能有同一個 id
-- → ★ 若 ID 對外暴露(/payments/{id}),你不知道該查哪張表
-- → ✅ 對策:★ 用全域唯一的 ID(uuid/ULID),★★ 或加一張只有 (id, kind) 的索引表
-- → ★ 但那張索引表就是「父表」了 ⇒ 你其實走回 [[類別表繼承]]
-- ③ ★★ 外鍵無法指向「任一種付款」
CREATE TABLE refunds (payment_id text REFERENCES ???); -- ★ 指不到
-- → ✅ 對策:refunds 也分成 card_refunds / transfer_refunds(★ 繼續擴散)
-- 或 ★ 放棄外鍵約束(見 [[外鍵對應]] 的跨服務討論)★★ ②③ 是這個模式的殺手:一旦「付款」這個概念需要被別的表參照,
具象表繼承就開始逼你做出一連串妥協。
★★ 改共同欄位的成本:N 倍
-- ★ 新增一個共同欄位 currency
ALTER TABLE card_payments ADD COLUMN currency text NOT NULL DEFAULT 'TWD';
ALTER TABLE transfer_payments ADD COLUMN currency text NOT NULL DEFAULT 'TWD';
ALTER TABLE voucher_payments ADD COLUMN currency text NOT NULL DEFAULT 'TWD';
-- ★★ 而且:① 忘記一張表 → 那個型別沒有 currency,執行期才發現
-- ② migration 要在 N 張表上跑,大表時鎖定時間 × N★ 對策:把共同欄位的 migration 自動化(一個腳本遍歷型別清單),
並在測試裡斷言「所有型別的表都有相同的共同欄位」。
★★ 沒有這個測試,「忘記一張表」是必然會發生的。
★★ 它真正合適的場景(很窄,但真實存在)
① ★★ 子型別之間幾乎不共查
★ 「信用卡對帳」與「轉帳對帳」是兩個完全獨立的流程,
從來沒有「列出所有付款」的需求
→ ★ 這時具象表繼承是最乾淨的(零 JOIN、全 NOT NULL、各自可獨立加索引)
② ★★ 各型別的資料量差異極大
card_payments 一億列、voucher_payments 一千列
→ ★ 分表讓它們可以各自分區、各自調整索引與 vacuum 參數
→ ★★ 單表繼承會讓小型別被大型別的索引拖累
③ ★ 各型別由不同團隊擁有、獨立演進
→ ★ 分表讓 schema 變更互不影響
★★ 判準:「列出所有 X」是一個真實需求嗎?
是 → 不要用具象表繼承。
不是 → ★ 它可能是三者中最好的。
★★ 三種繼承對應的最終判準(一句話)
★★ 你多常跨子型別一起查?
很常 + 差異小 → [[單表繼承]]
很常 + 差異大 → [[類別表繼承]]
★ 幾乎不會 → 具象表繼承
★ 而「要不要 NOT NULL」是單表繼承唯一的硬傷。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
共通欄位在多張表重複,改動父類別欄位要動多張表,維護麻煩。
-
主鍵需跨所有表唯一,較難管理。
-
想跨整個階層查詢時得 union 多張表;此時單表 / 類別表繼承更好。
-
★★ 「列出所有 X」是真實需求 → 不要用這個模式。 UNION ALL 的查詢會出現在十個地方,而且分頁極痛。
-
★★ ID 唯一性跨表無法保證。對外暴露 ID 時你不知道該查哪張表——加一張
(id, kind)索引表就等於走回 類別表繼承。 -
★★ 外鍵無法指向「任一種型別」。一旦這個概念要被別的表參照,就開始一連串妥協。
-
★★ 改共同欄位要在 N 張表上 migration,而「忘記一張」是必然會發生的。 要有測試斷言所有型別的表有相同共同欄位。
-
★ 它真正合適的場景很窄:子型別幾乎不共查、資料量差異極大(可各自分區)、或由不同團隊獨立演進。
-
★ 資料量差異極大時它反而最好:單表繼承會讓小型別被大型別的索引與 vacuum 拖累。
-
UNION ALL 的
ORDER BY+LIMIT要先合併全部再排序,無法只掃一張表。 -
Go 沒有繼承,這裡的「具體類」是介面的各個實作(見 以子類取代型別碼)。
🔗 相關工具
- 單表繼承 Single Table Inheritance(一張表全裝的對照)
- 類別表繼承 Class Table Inheritance(正規化、以 join 組合的對照)
- 繼承對應器 Inheritance Mappers(組織繼承載入 / 儲存)
- 企業應用架構模式
- 單表繼承、類別表繼承 —— 另兩種對應方式
- 繼承對應器 —— 三者共用的對應邏輯
- 識別欄位 —— 跨表 ID 唯一性
- 外鍵對應 —— 外鍵指不到的問題
- 以子類取代型別碼 —— Go 用介面取代繼承
- 工具-資料分區 —— 各型別獨立分區的優勢