🎯 什麼情境該想到我

當你「有繼承階層,想讓每個具象類別各自對應一張含全部欄位的獨立表、查詢完全不 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 沒有繼承,這裡的「具體類」是介面的各個實作(見 以子類取代型別碼)。

🔗 相關工具