🎯 什麼情境該想到我

當你「需要讓記憶體中的物件知道自己對應資料庫的哪一列」的時候。

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

意圖:在物件中保存資料庫該列的主鍵,藉此維持記憶體物件與資料庫列之間的對應。

做法要點:

  • 物件持有一個 ID 欄位,值等於對應資料列的主鍵。
  • 存回資料庫時靠它定位要更新 / 刪除的列。
  • 可用有意義鍵或無意義的代理鍵(surrogate key,通常較佳);單欄或複合鍵。
  • 是 Identity Map、Foreign Key Mapping 等機制的基礎。

★★ ID 型別的選擇:這是一個很難改的早期決定

型別優點★ 缺點
bigserial / identity★ 小(8 bytes)、索引局部性好、B-Tree 插入在末端★★ 會洩漏數量與順序(/orders/1024 可被列舉);★ 分庫時會撞號
uuid v4★ 可在客戶端產生、不洩漏資訊、分庫不撞★★ 隨機 ⇒ B-Tree 插入分散 ⇒ 頁分裂與 WAL 放大
★ uuid v7 / ULID★ 時間有序 ⇒ 插入局部性好,同時保留 v4 的優點★ 洩漏建立時間(通常可接受)
業務鍵(email、訂單號)有意義★★ 會變(email 會改)⇒ 絕不可當主鍵

★★ 推薦:內部主鍵用 bigint(或 uuid v7),對外暴露另一個不可列舉的 ID。
兩者分開讓你同時拿到「索引效率」與「不洩漏資訊」。
★ 而 uuid v4 當主鍵在寫入密集的大表上是真實的效能問題——
隨機值讓每次插入都落在不同的 B-Tree 頁(見 工具-儲存引擎B-Tree與LSM-Tree)。

★★ Go 的實作:用具名型別防止 ID 搞混

// ★ 零成本、編譯期擋住
type OrderID string
type CustomerID string
 
func LoadOrder(ctx context.Context, id OrderID) (*Order, error)
LoadOrder(ctx, customerID)    // ★★ 編譯錯誤 —— 這正是重點

★★ 為什麼值得做:func Transfer(from, to string, amount int) 傳反了編譯完全通過。
一旦 ID 是具名型別,這類錯誤消失(見 以物件取代基本型別)。
⚠️ 代價:要為每個 ID 型別實作 sql.Scanner/driver.Valuer(或用 sqlc 的 override 設定)。

★★ 誰產生 ID:三種策略的取捨

// ① ★ 資料庫產生(bigserial)
INSERT INTO orders (…) VALUES (…) RETURNING id;   -- ★★ 一定要用 RETURNING
// ★ 問題:★ 在 INSERT 之前你沒有 ID
//   → ★★ 無法在同一交易裡先建立子物件的外鍵參照(要先插父再插子)
//   → ★ 也無法做冪等(見下)
 
// ② ★★ 應用程式產生(uuid v7 / ULID)—— ★ 推薦
id := ulid.Make()
// ★ 好處:① 插入前就有 ID ⇒ 整個聚合可以一次組好再寫
//         ② ★★ 可以做冪等:重試時用同一個 ID,唯一鍵擋掉重複
//         ③ 不需要 RETURNING,可以批次插入
 
// ③ ★ 客戶端產生(讓 API 呼叫端帶 idempotency key)
//    → ★★ 這是「重試安全」的唯一乾淨解(見 [[冪等接收者]])

★★ ② 的第二點常被低估:

★ 資料庫產生 ID 時,「重試一個 INSERT」會產生第二筆資料
★ 應用程式產生 ID 時,重試會撞唯一鍵 → 你知道它已經成功了
→ ★★ 所以「誰產生 ID」直接決定了「這個操作能不能安全重試」

★★ 複合鍵:Fowler 說能避就避,而理由很實際

-- ★ 複合主鍵
PRIMARY KEY (tenant_id, order_no)
-- ★★ 代價:
--   ① 每個外鍵都要帶兩個欄位 → 子表欄位變多、JOIN 條件變長
--   ② ORM/codegen 對複合鍵的支援通常較差
--   ③ ★ 「用 ID 指向一列」變成「用兩個值指向一列」→ API、快取鍵、日誌全部變複雜

★ 例外:關聯表(join table)用複合鍵是對的——見 關聯表對應。
★★ 多租戶的正確做法通常是「單一代理主鍵 + tenant_id 欄位 + 複合唯一索引」:

id        bigserial PRIMARY KEY,
tenant_id text NOT NULL,
order_no  text NOT NULL,
UNIQUE (tenant_id, order_no)      -- ★ 業務唯一性用唯一索引,不用主鍵

★★ 一個高頻的資料洩漏:對外暴露自增 ID

GET /orders/1024
→ ★ 攻擊者知道:你有至少 1024 張訂單
→ ★★ 而且可以列舉:/orders/1023、/orders/1022 …
   → ★ 若授權檢查有任何漏洞(IDOR),整個資料庫可被爬走

★ 兩個對策:

① ★★ 對外用另一個不可列舉的 ID(uuid v4 / 隨機字串),內部仍用 bigint
   → ★ 需要一個 public_id 欄位 + 唯一索引
② ★ 授權檢查必須以「這個使用者能不能看這一筆」為單位,不是「能不能呼叫這個端點」
   → ★★ 這一條不可省略 —— 換 ID 型別只是降低被掃的效率,不是授權

★ 兄弟辨析

差別
外鍵對應★ 用識別欄位表達「一對多」關聯
關聯表對應多對多
值物件★ 值物件沒有識別欄位(這是它與實體的定義差別)
以物件取代基本型別用具名型別包 ID
內嵌值沒有自己的 ID,存在宿主的列裡

🧪 我實際套用的紀錄

  • (待填)

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

  • 鍵的產生與唯一性策略要想清楚(自增、UUID、表分配等)。

  • 內嵌值、序列化大物件這類「依附在別人列裡」的物件不需要自己的識別欄位。

  • 複合鍵會讓對應與比較變複雜。

  • ★★ uuid v4 當主鍵在寫入密集的大表上是真實的效能問題:隨機值讓每次插入落在不同 B-Tree 頁,造成頁分裂與 WAL 放大。用 v7/ULID。

  • ★★ 「誰產生 ID」決定了「這個操作能不能安全重試」。 資料庫產生 → 重試會產生第二筆;應用程式產生 → 重試撞唯一鍵。

  • ★★ 對外暴露自增 ID 會洩漏數量且可被列舉。 對外用另一個不可列舉的 ID,但授權檢查仍不可省略(換 ID 只降低掃描效率)。

  • ★★ 業務鍵絕不可當主鍵(email 會改、訂單號規則會變)。業務唯一性用唯一索引。

  • ★ 複合主鍵讓每個外鍵都要帶兩個欄位,ORM 支援差、API/快取鍵/日誌全部變複雜。多租戶用「代理主鍵 + 複合唯一索引」。

  • ★ 具名 ID 型別(type OrderID string)是零成本的型別安全,但要自己實作 Scanner/Valuer。

  • 資料庫產生 ID 時 INSERT 一定要用 RETURNING id,否則你拿不到它。

  • 關聯表用複合鍵是對的,那是唯一的合理例外。

🔗 相關工具