🎯 什麼情境該想到我
當你「想確保同一筆資料在一次會話內只被載入一次,避免出現多份不一致的物件」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:用一份對照表記錄「已載入的物件」,每筆資料只載入並實例化一次,之後都回傳同一個物件。
做法要點:
- 以主鍵為 key,把已載入的物件存進 map;再次請求同一筆時直接回傳快取。
- 避免同一列在記憶體中出現兩個物件,防止更新互相覆蓋、狀態不一致。
- 兼具效能好處:省掉重複的資料庫查詢。
- 通常一個會話 / 交易一份 Identity Map,常由 Data Mapper 管理。
★★ 它要解決的問題非常具體:同一列變成兩個物件
// ★ 沒有識別對應時
a, _ := repo.Get(ctx, tx, orderID) // 查詢 1 → 物件 A
b, _ := repo.Get(ctx, tx, orderID) // 查詢 2 → ★★ 物件 B(另一份記憶體)
a.Cancel()
b.SetNote("urgent")
repo.Save(ctx, tx, a) // status = cancelled
repo.Save(ctx, tx, b) // ★★ note 寫入,但 status 被寫回舊值
// → ★ lost update,而且完全沒有錯誤★★ 而且不只是寫入問題:a == b 為假、a.Status != b.Status,
於是「同一張訂單」在同一個請求裡有兩個互相矛盾的真相。
★★ Go 的做法:不實作識別對應,改用「一次載入、參數傳遞」
// ★★ Go 的答案 —— 在服務層載入一次,往下傳同一個指標
func (s *Service) Settle(ctx context.Context, id OrderID) error {
tx, err := s.db.BeginTx(ctx, nil); …
defer tx.Rollback()
o, err := s.orders.Get(ctx, tx, id) // ★ 只有這裡載入
if err != nil { return err }
if err := s.applyDiscount(o); err != nil { return err } // ★ 傳物件,不傳 ID
if err := s.applyTax(o); err != nil { return err } // ★ 同一個 *Order
return s.orders.Save(ctx, tx, o)
}★★ 判準:你的內部函式收
id還是收*Order?
收id→ 它會自己去載入 → 你需要識別對應(或者你有 bug)。
收*Order→ 問題根本不存在。
★ 這正是 表格模組 那一頁提到的「方法都收 entityID」反模式的另一面。
★★ 它是快取,所以它有快取的所有問題
① ★★ 作用域必須是「一個業務交易」,不能更長
★ 跨請求共用 → 兩個使用者看到同一個物件、互相改到
★ 跨交易共用 → 讀到未提交或已回滾的狀態
→ ★ ORM 的 session/EntityManager 生命週期 = 一個請求,就是這個原因
② ★★ 它只在「按 ID 查」時有效
repo.FindByCustomer(ctx, custID) 回傳的物件
→ ★ 必須在回傳前逐一查識別對應,把已存在的換回來
→ ★ 漏了這一步,identity map 就只是裝飾品(★ 這是自製時最常見的 bug)
③ ★ 它會讓「重新讀取資料庫」變成不可能
★ 你想要「重新讀最新狀態」時,identity map 會給你快取的舊物件
→ ORM 都提供 refresh/evict —— 而那正是快取失效問題
★★ 如果你真的要在 Go 裡做(作用域與型別安全)
// ★ 用 context 綁定一個請求作用域的 map
type identityMap struct {
mu sync.Mutex // ★ 同一請求內若有 goroutine 併發,必須鎖
m map[typeKey]map[string]any // ★★ Go 沒有異質集合,只能 any + 型別斷言
}
type typeKey struct{ pkg, name string }★★ Go 的兩個具體障礙:
① ★ 沒有異質集合 → 只能 map[any]any 或 map[type]map[id]any,★ 取出時要型別斷言
→ 泛型幫不上:Go 的泛型不能宣告在方法上,所以做不出
func (m *identityMap) Get[T any](id string) (*T, bool)
② ★★ 併發:一個請求內用 errgroup 並行載入時,map 必須加鎖
→ 而加了鎖,它就成為一個潛在的競爭點
★ 結論:成本高於收益。 Go 的「一次載入、參數傳遞」達到同樣效果且零機制。
★★ 什麼時候你其實需要它(三個真實場景)
① ★★ 圖狀資料的走訪
權限樹、組織架構、BOM(物料清單)—— 同一個節點會從多條路徑被到達
→ ★ 這時「已載入的節點」快取是必要的(否則指數級查詢)
→ ★ 但實作上通常是「一個明確的 map[ID]*Node 傳下去」,而不是一個通用框架
② ★ 一次請求內反覆查同一張小表(幣別、稅率、設定)
→ ★ 這是普通的記憶體快取,不需要叫識別對應
③ ★ 遺留碼裡到處 FindByID 且無法一次改完
→ ★ 加一層請求作用域快取是止血手段(見 [[工具-遺留程式碼修改流程]])
★ 兄弟辨析
| 差別 | |
|---|---|
| 工作單元 | ★ 追蹤「改了什麼」;識別對應保證「同一列一個物件」 |
| 延遲載入 | ★ 延後載入;識別對應是避免重複載入 |
| 登錄器 | ★ 全域查找;識別對應是請求作用域的(★ 這是關鍵差別) |
| 儲存庫 | 通常內建識別對應(若基於 ORM) |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
生命週期要抓對(一般綁一次會話),跨會話共用會有陳舊資料風險。
-
不可變值物件或不需要唯一身分的資料不必納入。
-
需注意記憶體佔用與適時清理。
-
★★ Go 不要實作它。 「在服務層載入一次、往下傳
*Order」達到同樣效果且零機制。 -
★★ 判準:內部函式收
id還是收*Order? 收id就代表你需要它(或你有 bug)。 -
★★ 作用域必須是「一個業務交易」。 跨請求共用 → 兩個使用者改到同一個物件;跨交易 → 讀到已回滾的狀態。
-
★★ 它只在「按 ID 查」時有效。
FindByCustomer回傳的物件必須逐一查表換回已存在的——漏了這一步它就是裝飾品。 -
★ 它讓「重新讀最新狀態」變成不可能,需要 refresh/evict,也就是快取失效問題。
-
★ Go 的具體障礙:沒有異質集合(只能
any+ 型別斷言)、泛型不能宣告在方法上、並行載入時 map 要加鎖。 -
★ 真正需要它的是圖狀資料走訪(權限樹、組織架構、BOM)——但那時做一個明確的
map[ID]*Node傳下去就好。 -
不要和 登錄器 搞混:登錄器是全域的,識別對應是請求作用域的。
🔗 相關工具
- 工作單元 Unit of Work(一起管理物件狀態與變更)
- 資料對應器 Data Mapper(常內建識別對應)
- 企業應用架構模式
- 工作單元 —— 追蹤變更
- 延遲載入 —— 另一個 ORM 內部機制
- 登錄器 —— ★ 全域 vs 請求作用域
- 儲存庫 —— 通常內建它
- 表格模組 —— 「方法都收 entityID」的反模式
- 樂觀離線鎖 —— 防 lost update 的另一條路
- 工具-遺留程式碼修改流程 —— 當成止血手段時