🎯 什麼情境該想到我

當你「物件之間互相參照,要把這種關聯存進關聯式資料庫」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:用資料表的外鍵欄位對應物件之間的參照關聯(一對一、一對多)。

做法要點:

  • 被參照物件的主鍵,存成參照方資料列裡的外鍵欄位。
  • 載入時依外鍵去查出關聯物件並接上參照;儲存時把物件參照寫回外鍵值。
  • 集合(一對多)以「多方」持有指向「一方」的外鍵表示。
  • 常搭配 Identity Map(避免重載)與 Lazy Load(決定載入時機)。

★★ 三個方向的決定,各有代價

① ★ 外鍵放哪一邊
   一對多 → ★ 永遠放「多」的那一邊(order_lines.order_id)
   一對一 → ★ 放在「生命週期依附」的那一邊,或誰更常被查
   → ★★ 放錯邊會逼你用陣列欄位或關聯表,兩者都更糟

② ★★ 要不要建資料庫的 FOREIGN KEY 約束
   建 → ★ 資料完整性由 DB 保證;★★ 但跨服務/分庫時做不到
   不建 → ★ 彈性高(可分庫);★★ 但一定會有孤兒資料

③ ★ 刪除行為
   ON DELETE CASCADE / RESTRICT / SET NULL / NO ACTION

★★ ON DELETE CASCADE 是最危險的一個設定

order_lines.order_id REFERENCES orders(id) ON DELETE CASCADE
-- ★ 看起來很方便:刪訂單自動刪品項
-- ★★ 但:
--   ① 刪除的範圍看不見 —— 一個 DELETE 可能連鎖刪掉六張表
--   ② ★ 沒有稽核:誰刪了什麼,日誌裡只有一筆 DELETE orders
--   ③ ★★ 效能:刪一列可能鎖住並重寫幾萬列
--   ④ ★ 業務規則被繞過:「已出貨的訂單不可刪」這條規則在 DB 層不存在

★★ 實務建議:不要用 CASCADE,用 RESTRICT(或 NO ACTION)+ 應用層明確刪除。

// ★ 明確、可稽核、可套業務規則
if o.Status == StatusShipped { return ErrCannotDeleteShipped }
if err := q.DeleteOrderLines(ctx, tx, id); err != nil { … }   // ★ 先子
if err := q.DeleteOrder(ctx, tx, id); err != nil { … }        // ★ 後父

★ 更好的做法:多數業務資料不該被刪除,而該被標記狀態(cancelled/archived)
——刪除是不可逆的,而業務幾乎總是需要歷史。

★★ 沒有 FOREIGN KEY 約束時會發生什麼(微服務的現實)

★ 跨服務時外鍵約束做不到(order 在 A 庫、customer 在 B 庫)
→ ★★ 必然會有:
  ① 孤兒資料:customer 被刪了,orders 還指著它
  ② ★ 參照不存在的 ID:因為 bug 或競態,寫進了一個無效的 customer_id
→ ★ 三個必做的補償:
  ① ★★ 寫入時驗證(呼叫對方確認存在)—— ★ 但這是 TOCTOU,中間仍可能被刪
  ② ★★ 定期的一致性檢查(對帳 job)—— ★ 這是唯一真正有效的手段
  ③ ★ 讀取時容忍缺失(見 [[特例]]):查不到 customer 就顯示「已刪除的使用者」

★★ ③ 最常被忽略:前端拿到 customer: null 就崩潰——
跨服務參照必須假設「對方可能不存在」。

★★ 讀取:一對多的 N+1 與三種解法

// ❌ N+1
orders, _ := q.ListOrders(ctx)
for _, o := range orders {
    lines, _ := q.ListOrderLines(ctx, o.ID)     // ★★ N 次查詢
}
 
// ✅ ① 兩次查詢 + 記憶體 group by(★ 最常用、最好懂)
orders, _ := q.ListOrders(ctx)
ids := make([]string, len(orders))
for i, o := range orders { ids[i] = o.ID }
lines, _ := q.ListOrderLinesByOrderIDs(ctx, ids)     // ★ WHERE order_id = ANY($1)
byOrder := make(map[string][]Line, len(orders))
for _, l := range lines { byOrder[l.OrderID] = append(byOrder[l.OrderID], l) }
 
// ✅ ② JOIN + 自己拆列(★ 一次往返,但★★ 父欄位會重複 n 倍)
// ✅ ③ ★ 資料庫端聚合成 JSON(Postgres 的 json_agg)
SELECT o.*, coalesce(json_agg(l.*) FILTER (WHERE l.id IS NOT NULL), '[]') AS lines
  FROM orders o LEFT JOIN order_lines l ON l.order_id = o.id
 GROUP BY o.id;
// ★ 一次往返、無重複;★★ 代價:JSON 解析成本、型別安全較弱

★★ ① 是預設答案:兩次查詢、ANY($1) 吃一個陣列、記憶體 group by。
它好懂、可測、而且 ids 為空時要記得早退(WHERE id = ANY('{}') 會掃全表或回空,
依實作而異——★ 一律先檢查 len(ids) == 0)。

★ 兄弟辨析

差別
識別欄位★ 外鍵對應建立在它之上
關聯表對應★ 多對多(需要中間表)
從屬對應★ 子物件沒有自己的身分,由父負責全部讀寫
內嵌值子物件存在父的同一列裡
延遲載入★ 一對多讀取的 N+1 來源

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 多對多關聯無法只靠一個外鍵,需改用關聯表對應。

  • 要處理載入順序、循環參照與更新集合的差異比對。

  • 小型依附物件可考慮內嵌值,不必獨立表與外鍵。

  • ★★ 不要用 ON DELETE CASCADE:刪除範圍看不見、沒有稽核、可能鎖住並重寫幾萬列、業務規則被繞過。用 RESTRICT + 應用層明確刪除。

  • ★ 多數業務資料不該被刪除,而該標記狀態。刪除不可逆,而業務幾乎總是需要歷史。

  • ★★ 跨服務沒有外鍵約束 ⇒ 必然有孤兒資料。唯一真正有效的手段是定期的一致性檢查(對帳 job)。

  • ★★ 跨服務參照必須假設「對方可能不存在」(見 特例)。前端拿到 null 就崩潰是常見事故。

  • ★ 一對多讀取的預設解法是「兩次查詢 + ANY($1) + 記憶體 group by」,不是 JOIN 也不是延遲載入。

  • ★ ids 為空時要早退。WHERE id = ANY('{}') 的行為依實作而異。

  • ★ JOIN 會讓父欄位重複 n 倍,大欄位(description、json)時流量成本可觀。

  • 外鍵一律放「多」的那一邊。 放錯邊會逼你用陣列欄位或關聯表。

  • 外鍵欄位一定要有索引。 Postgres 不會自動為外鍵建索引——而缺了它,刪除父列會全表掃描子表。

🔗 相關工具