🎯 什麼情境該想到我

當你「載入一個物件時不想連帶把所有關聯資料都拉進來,等真正用到才去查」的時候。

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

意圖:物件先不載入其關聯的資料,但保留「稍後如何取得」的資訊,等到真正被存取時才去資料庫載入。

做法要點:

  • 四種常見做法:Lazy Initialization(存取時檢查並載入)、Virtual Proxy(假物件占位,用到才實體化)、Value Holder(包一層持有者)、Ghost(先建空殼,觸碰時補齊)。
  • 避免一次把整張物件關聯網全部拉出,減少不必要的查詢與記憶體。
  • 常見於 ORM 的關聯載入策略。

★★ 這個模式在 Go 裡幾乎不用,而那是好事

// ★★ Go 的做法:載入範圍寫在方法名/簽章上
func (r *Repo) Get(ctx context.Context, q Querier, id ID) (*Order, error)
func (r *Repo) GetWithLines(ctx context.Context, q Querier, id ID) (*Order, error)
// ★ 呼叫端一看簽章就知道會不會多一次查詢 —— ★★ 沒有「你沒寫的查詢」

★★ 延遲載入的本質是「把 I/O 藏在一個看起來像欄位存取的地方」。
order.Lines 看起來是讀一個欄位,實際上發了一個 SQL——
★ 而這正是 N+1 問題的全部來源。

★★ N+1:把它算清楚

// ★ ORM 裡的典型 N+1
orders := db.Find(&orders)              // ★ 查詢 1:SELECT * FROM orders  → 100 列
for _, o := range orders {
    fmt.Println(o.Customer.Name)        // ★★ 每一輪一次查詢 → 100 次
}
// → ★ 總共 101 次查詢

代價的量級(假設單次查詢往返 1 ms):

101 次 × 1 ms = 101 ms          ← ★ 延遲載入
1 次 JOIN 或 2 次查詢 = 2 ms     ← ★★ 快 50 倍
★ 而且 101 次會佔用 101 次連線池的取用 —— 高併發下這是真正的殺手

★★ 最惡劣的是它「在開發環境看不出來」:本機 DB 往返 0.1 ms,
100 次只多 10 ms;上線後跨 AZ 往返 2 ms,同一段碼變成 200 ms。

★★ 四種實作方式與各自的陷阱(在別的語言看到時要認出來)

做法機制★ 陷阱
① 延遲初始化欄位為 nil 時才查★ 非併發安全;★ 無法分辨「還沒載入」與「真的是空的」
② 虛擬代理一個假物件,第一次用到時替換★ 代理與真物件的 == 比較不相等
③ 值持有者LazyValue[T] 明確包一層★ 呼叫端看得到「這是延遲的」——★ 最誠實的做法
④ 幽靈物件只載 ID,用到其他欄位才補載★★ 最難除錯:任何欄位存取都可能發 SQL

★★ ①「無法分辨還沒載入與真的是空的」是最常見的 bug:

if len(order.Lines) == 0 { … }   // ★★ 是「沒有品項」還是「還沒載入」?

★ Go 裡若真要做,用 ③:

type Lazy[T any] struct {
    once sync.Once                 // ★ 併發安全
    load func() (T, error)
    v    T
    err  error
}
func (l *Lazy[T]) Get() (T, error) {
    l.once.Do(func() { l.v, l.err = l.load() })    // ★ 只載一次,錯誤也快取
    return l.v, l.err
}

⚠️ 但 sync.Once 會把錯誤永久快取——暫時性的網路錯誤會讓這個值永遠拿不到。
★ 需要重試就不能用 Once,而那就要自己處理併發(見 工具-靠通訊共享記憶體)。

★★ 反過來的問題:過度載入(eager loading)

★ 為了避免 N+1 而一律 Preload → ★★ 撈出一棵巨大的物件樹
  ① 記憶體暴增(一次撈十萬列)
  ② ★ JOIN 造成列數乘積(一張訂單 × 10 個品項 × 5 個標籤 = 50 列)
  ③ ★ 大多數欄位根本沒用到

★★ 判準:呼叫端需要什麼,就提供剛好那個。
這意味著針對使用場景設計查詢,而不是追求一個「通用的 Get」——
★ 這也是 CQRS 讀側的核心思想(見 查詢物件)。

★ 偵測 N+1 的三個實用手段

① ★★ 在測試裡數查詢次數
   ★ 包一層 driver 或用 sqlmock 計數,斷言「這個 handler 最多 3 次查詢」
   → ★ 這是唯一能防止 N+1 回歸的方法
② ★ 用 tracing:一個請求的 span 裡有 100 個 db span 一眼就看得出來
③ ★ 慢查詢日誌看不到 N+1 —— 每一次都很快,是「次數」的問題

★★ ③ 很重要:N+1 的每一次查詢都是 0.5 ms,不會進慢查詢日誌。
它只會表現為「這個端點就是有點慢」——而那通常被歸因為「資料多」。

★ 兄弟辨析

差別
識別對應★ 避免重複載入;延遲載入是延後載入
工作單元追蹤變更
序列化大物件★ 把整包存成一欄,根本不需要延遲載入
代理模式虛擬代理式實作的來源
查詢物件★ 針對場景設計查詢,避免過度載入

🧪 我實際套用的紀錄

  • (待填)

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

  • 用不好會造成 N+1 查詢(迴圈中反覆觸發載入),反而更慢。

  • 若明知會用到整批資料,改用及早載入(eager)更划算。

  • 代理 / 空殼會讓除錯與相等判斷較不直覺。

  • ★★ 在 Go 裡把載入範圍寫在方法名上(Get / GetWithLines),不要做延遲載入。「沒有你沒寫的查詢」是最大的收益。

  • ★★ N+1 在開發環境看不出來:本機往返 0.1 ms,上線跨 AZ 2 ms,同一段碼慢 20 倍。

  • ★★ 慢查詢日誌抓不到 N+1。每一次都很快,問題在次數。要在測試裡數查詢次數。

  • ★★ 「欄位是空的」無法分辨「還沒載入」與「真的是空的」。這是延遲初始化最常見的 bug。

  • ★ sync.Once 會把錯誤永久快取。暫時性網路錯誤會讓那個值永遠拿不到。

  • ★ 過度 eager loading 是另一個極端:JOIN 造成列數乘積、記憶體暴增、多數欄位沒用到。

  • ★ 判準是「呼叫端需要什麼就提供剛好那個」,針對場景設計查詢,而非追求通用的 Get。

  • 幽靈物件最難除錯:任何欄位存取都可能發 SQL。

🔗 相關工具