🎯 什麼情境該想到我
當你「需要讓記憶體中的物件知道自己對應資料庫的哪一列」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:在物件中保存資料庫該列的主鍵,藉此維持記憶體物件與資料庫列之間的對應。
做法要點:
- 物件持有一個 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 型別只是降低被掃的效率,不是授權
★ 兄弟辨析
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
鍵的產生與唯一性策略要想清楚(自增、UUID、表分配等)。
-
內嵌值、序列化大物件這類「依附在別人列裡」的物件不需要自己的識別欄位。
-
複合鍵會讓對應與比較變複雜。
-
★★
uuidv4 當主鍵在寫入密集的大表上是真實的效能問題:隨機值讓每次插入落在不同 B-Tree 頁,造成頁分裂與 WAL 放大。用 v7/ULID。 -
★★ 「誰產生 ID」決定了「這個操作能不能安全重試」。 資料庫產生 → 重試會產生第二筆;應用程式產生 → 重試撞唯一鍵。
-
★★ 對外暴露自增 ID 會洩漏數量且可被列舉。 對外用另一個不可列舉的 ID,但授權檢查仍不可省略(換 ID 只降低掃描效率)。
-
★★ 業務鍵絕不可當主鍵(email 會改、訂單號規則會變)。業務唯一性用唯一索引。
-
★ 複合主鍵讓每個外鍵都要帶兩個欄位,ORM 支援差、API/快取鍵/日誌全部變複雜。多租戶用「代理主鍵 + 複合唯一索引」。
-
★ 具名 ID 型別(
type OrderID string)是零成本的型別安全,但要自己實作Scanner/Valuer。 -
資料庫產生 ID 時
INSERT一定要用RETURNING id,否則你拿不到它。 -
關聯表用複合鍵是對的,那是唯一的合理例外。
🔗 相關工具
- 外鍵對應 Foreign Key Mapping(用識別欄位對應物件間參照)
- 識別對應 Identity Map(以主鍵作為快取 key)
- 企業應用架構模式
- 外鍵對應 —— 一對多關聯
- 關聯表對應 —— 多對多,複合鍵的正當用途
- 值物件 —— 沒有識別欄位的對照
- 內嵌值 —— 沒有自己 ID 的子物件
- 以物件取代基本型別 —— 具名 ID 型別
- 冪等接收者 —— 應用程式產生 ID 讓重試安全
- 工具-儲存引擎B-Tree與LSM-Tree —— 隨機 ID 的插入成本