🎯 什麼情境該想到我
當你「載入一個物件時不想連帶把所有關聯資料都拉進來,等真正用到才去查」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:物件先不載入其關聯的資料,但保留「稍後如何取得」的資訊,等到真正被存取時才去資料庫載入。
做法要點:
- 四種常見做法: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。
🔗 相關工具
- 資料對應器 Data Mapper(常搭配實作延遲載入)
- 外鍵對應 Foreign Key Mapping(關聯載入時機的取捨)
- 企業應用架構模式
- 識別對應、工作單元 —— 另兩個 ORM 內部機制
- 序列化大物件 —— 整包存一欄就不需要延遲載入
- 查詢物件 —— 針對場景設計查詢
- 代理模式 —— 虛擬代理的來源
- 資料對應器 —— 載入範圍該由誰決定
- 工具-靠通訊共享記憶體 —— 併發安全的延遲初始化