🎯 什麼情境該想到我
當你「物件之間互相參照,要把這種關聯存進關聯式資料庫」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:用資料表的外鍵欄位對應物件之間的參照關聯(一對一、一對多)。
做法要點:
- 被參照物件的主鍵,存成參照方資料列裡的外鍵欄位。
- 載入時依外鍵去查出關聯物件並接上參照;儲存時把物件參照寫回外鍵值。
- 集合(一對多)以「多方」持有指向「一方」的外鍵表示。
- 常搭配 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)。
★ 兄弟辨析
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
多對多關聯無法只靠一個外鍵,需改用關聯表對應。
-
要處理載入順序、循環參照與更新集合的差異比對。
-
小型依附物件可考慮內嵌值,不必獨立表與外鍵。
-
★★ 不要用
ON DELETE CASCADE:刪除範圍看不見、沒有稽核、可能鎖住並重寫幾萬列、業務規則被繞過。用RESTRICT+ 應用層明確刪除。 -
★ 多數業務資料不該被刪除,而該標記狀態。刪除不可逆,而業務幾乎總是需要歷史。
-
★★ 跨服務沒有外鍵約束 ⇒ 必然有孤兒資料。唯一真正有效的手段是定期的一致性檢查(對帳 job)。
-
★★ 跨服務參照必須假設「對方可能不存在」(見 特例)。前端拿到
null就崩潰是常見事故。 -
★ 一對多讀取的預設解法是「兩次查詢 +
ANY($1)+ 記憶體 group by」,不是 JOIN 也不是延遲載入。 -
★
ids為空時要早退。WHERE id = ANY('{}')的行為依實作而異。 -
★ JOIN 會讓父欄位重複 n 倍,大欄位(description、json)時流量成本可觀。
-
外鍵一律放「多」的那一邊。 放錯邊會逼你用陣列欄位或關聯表。
-
外鍵欄位一定要有索引。 Postgres 不會自動為外鍵建索引——而缺了它,刪除父列會全表掃描子表。
🔗 相關工具
- 關聯表對應 Association Table Mapping(多對多關聯的作法)
- 識別欄位 Identity Field(外鍵對應的基礎)
- 延遲載入 Lazy Load(決定關聯的載入時機)
- 企業應用架構模式
- 識別欄位 —— 外鍵對應的基礎
- 關聯表對應 —— 多對多
- 從屬對應 —— 子物件沒有獨立身分
- 內嵌值 —— 存在同一列裡
- 延遲載入 —— N+1 的來源
- 特例 —— 參照不存在時的處理