🎯 什麼情境該想到我

當你「想確保同一筆資料在一次會話內只被載入一次,避免出現多份不一致的物件」的時候。

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

意圖:用一份對照表記錄「已載入的物件」,每筆資料只載入並實例化一次,之後都回傳同一個物件。

做法要點:

  • 以主鍵為 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 傳下去就好。

  • 不要和 登錄器 搞混:登錄器是全域的,識別對應是請求作用域的。

🔗 相關工具