前言
最近工作上經常遇到 MongoDB 關聯查詢的問題,沒處理好很容易變成 N+1 問題,舉例情境可以是:「一個用戶可以有多筆訂單,多筆訂單可以對應到多個產品」。
剛好最近在製作一款基於 Mongo Go Driver 之上的 Query Builder:good,就順著這個常見問題整理一次各種做法的取捨。
關聯查詢怎麼處理
N+1 Loop
查完主資料,再迴圈逐筆去查關聯資料:資料庫查詢效能問題:N+1 問題
var orders []Ordercursor, _ := orderColl.Find(ctx, bson.M{"userId": userID})cursor.All(ctx, &orders)
for i := range orders { var user User userColl.FindOne(ctx, bson.M{"_id": orders[i].UserID}).Decode(&user) orders[i].User = &user}一次查主資料,加上 N 筆關聯查詢,這就是經典的 N+1 問題。100 筆訂單就是 101 次 round trip。麻煩的是這段程式碼讀起來很自然,本機測試放個三五筆資料也完全正常,通常是等到正式環境資料長起來才會發現。
內嵌 Embedding
MongoDB 是文件資料庫,官方最推薦的做法是「資料怎麼一起讀,就怎麼一起存」,也就是把關聯資料直接內嵌在同一份文件裡:
{ "_id": "order_1", "items": [ { "productId": "p1", "name": "Keyboard", "price": 100, "qty": 2 } ], "createdAt": "2026-08-17T00:00:00Z"}是文件資料庫常有的特色,鼓勵「基於存取模式建構資料模型」,但內嵌也有對應的代價:
- 文件大小上限:單一文件 16MB,無邊界成長的關聯內嵌進去遲早會爆。
- 專為特定查詢設計:內嵌的形狀是為特定查詢設計的,需求一變結構就幫不上忙。
- 資料重複:
user.name同時存在 users collection 與每一筆 order 裡,使用者改名就要回頭更新所有訂單,否則資料不一致。 - N:M 不適合:products 被多筆 orders 引用,內嵌等同於無限複製。
所以 Embedding 雖然最直白,但適合的其實是那種有邊界、會一起讀、而且之後不會再改動的資料,像是訂單成立當下的商品快照,其他情況還是保持引用比較安全。
$lookup(Server-side Join)
把 join 交給資料庫,用 aggregation pipeline 一次組合好結果:
pipeline := mongo.Pipeline{ bson.D{{Key: "$match", Value: bson.M{"userId": userID}}}, bson.D{{Key: "$lookup", Value: bson.M{ "from": "users", "localField": "userId", "foreignField": "_id", "as": "user", }}}, bson.D{{Key: "$unwind", Value: bson.M{ "path": "$user", "preserveNullAndEmptyArrays": true, }}},}cursor, _ := orderColl.Aggregate(ctx, pipeline)一次就拿到組合好的結果,看起來很理想,但也有對應的取捨要留意:
- stage 有 100MB 上限,join 兩三層後 pipeline 也會膨脹成難以維護的 BSON。
- index 沒中就是全表掃描:
foreignField沒 index,每筆主文件都掃一次 foreign collection。 - 裸接
$unwind是 inner join:preserveNullAndEmptyArrays預設false,關聯查不到的主文件會整筆被丟掉,不報錯。 as永遠是陣列:N:1 也一樣,要對上 Go struct 就得接$unwind攤平。
需要在 DB 端拿關聯欄位做聚合、篩選、排序的時候,$lookup 通常是最直接的解法。但如果只是日常讀取,每次 request 都跑一遍 pipeline 就有點浪費,這種關聯結果通常高度重複,與其每次重算,不如回頭調整資料模型或在應用層快取。
手動二次查詢(Application-side Join)
把關聯操作搬回應用層,先查主資料收集所有外鍵,用一次 $in 批次查完關聯資料,最後在記憶體裡組裝。
// 1. 查主資料var orders []Ordercursor, _ := orderColl.Find(ctx, bson.M{"userId": userID})cursor.All(ctx, &orders)
// 2. 收集外鍵並去重userIDs := make([]bson.ObjectID, 0, len(orders))seen := map[bson.ObjectID]struct{}{}for _, o := range orders { if _, ok := seen[o.UserID]; !ok { seen[o.UserID] = struct{}{} userIDs = append(userIDs, o.UserID) }}
// 3. 一次 $in 撈完var users []Useruc, _ := userColl.Find(ctx, bson.M{"_id": bson.M{"$in": userIDs}})uc.All(ctx, &users)
// 4. 建 map 後回填userByID := make(map[bson.ObjectID]*User, len(users))for i := range users { userByID[users[i].ID] = &users[i]}for i := range orders { orders[i].User = userByID[orders[i].UserID]}不管幾筆訂單都是 2 次查詢,而且關聯查詢走的是 _id 這種必定有 index 的欄位,成本很好預測,概念也單純。缺點是樣板程式碼太多,上面那 30 行,換一個關聯要抄一次,換一個型別又要再抄一次,每次都是同樣的收集外鍵、去重、$in、建 map、回填。
Eager Loading
上面邏輯其實可以被抽象成一個通用的功能,這就是各家 ORM 提供的 Eager Loading:
// Laravel EloquentOrder::with('user')->get();// GORMdb.Preload("User").Find(&orders)// Prismaprisma.order.findMany({ include: { user: true } })一行宣告要載入哪些關聯,底下自動展開成上面那套批次查詢,寫的人只要描述關聯關係,不用每次都手刻一遍收集、批次、組裝。
而 Mongo Go Driver 是刻意保持 low-level 的,官方沒有提供這層抽象,所以接下來要做的就是在原生 driver 之上實踐更便捷的關聯查詢。
小結
| 做法 | 查詢次數 | 適用情境 | 主要代價 |
|---|---|---|---|
| N+1 Loop | 1 + N | 不該用 | 延遲線性成長 |
| 內嵌 Embedding | 1 | 一起讀、少變動、有邊界 | 資料重複、16MB 上限、綁定查詢方向 |
$lookup | 1 | 需在 DB 端聚合/排序關聯欄位 | index 敏感、記憶體上限、pipeline 難維護 |
| 手動二次查詢 | 1 + M(M = 關聯數) | 一般讀取路徑 | 樣板程式碼多、容易寫錯 |
| Eager Loading | 1 + M | 同上,但可重用 | 需要先建立抽象 |
簡化關聯查詢
中間那步本來就是 query builder 在做的事,所以真正缺的只有頭尾:把外鍵收出來,還有把查回來的東西建成索引。這兩件事都不用碰資料庫,也不用知道任何關聯宣告,所以我就把它們寫成三個泛型函式。
收集去重後的外鍵
func Keys[T any, K comparable](rows []T, key func(T) K) []K { var zero K seen := make(map[K]struct{}, len(rows)) out := make([]K, 0, len(rows)) for _, row := range rows { k := key(row) if k == zero { continue } if _, dup := seen[k]; dup { continue } seen[k] = struct{}{} out = append(out, k) } return out}ids := good.Keys(orders, func(o Order) bson.ObjectID { return o.UserID })if len(ids) == 0 { return orders, nil}實作很單純,不過有兩個地方細節:
- 零值直接跳過:空字串、零值
bson.ObjectID、0代表「沒有這個引用」,不是「要去查零值」。真的送進$in,反而會匹配剛好存了零值的文件。 - 回傳不會是
nil,長度 0 代表沒東西可查:要不要因此提早結束交給呼叫端決定。$in空陣列是合法查詢,結果也是空的,所以跳過它省的是一次 round trip 而不是修正正確性。
用 in 查一次另一邊
中間這步不需要任何新東西,它就是一個普通查詢:
users, err := userColl.Where("_id", "in", ids).Select("_id", "name").All(ctx)這步刻意不包起來。因為它只是普通查詢,所以能繼續加條件、加過濾,也能在送出前把 filter 印出來檢查:
q := good.NewQuery[User]().Where("_id", "in", ids)fmt.Println(q.Filter()) // map[_id:map[$in:[...]]]把回傳結果建索引,回填
查完兩次之後,手上是兩堆互不相識的資料:
orders := []Order{ {ID: "o1", UserID: 1}, // 只有 userId,沒有名字 {ID: "o2", UserID: 2},}users := []User{ {ID: 2, Name: "小明"}, // 只有名字,不知道自己被誰引用 {ID: 1, Name: "小美"},}但要輸出的是合體後的東西,也就是 orders[0].User.Name。SQL 的 JOIN、$lookup、ORM 的 with() 都是別人幫你併好再給你,自己查兩次就得自己併,這步就是回填。
直接兩層迴圈也能併,只是每筆主資料都要翻一次整疊關聯資料,100 × 100 就是一萬次比對:
for i := range orders { for _, u := range users { if u.ID == orders[i].UserID { orders[i].User = u; break } }}先把關聯資料排成 map 再查表,就從 O(N×M) 變成 O(N+M)。所以需要的東西很單純,就是一個能用外鍵直接取值的 map,差別只在關聯的方向:
func By[T any, K comparable](rows []T, key func(T) K) map[K]Tfunc ByAll[T any, K comparable](rows []T, key func(T) K) map[K][]Tfunc By[T any, K comparable](rows []T, key func(T) K) map[K]T { out := make(map[K]T, len(rows)) for _, row := range rows { out[key(row)] = row } return out}func ByAll[T any, K comparable](rows []T, key func(T) K) map[K][]T { out := make(map[K][]T, len(rows)) for _, row := range rows { k := key(row) out[k] = append(out[k], row) } return out}兩者的差別只在同一個 key 撞在一起時怎麼辦。拿同一份輸入跑跑看:
items := []Item{ {OrderID: 1, Name: "鍵盤"}, {OrderID: 2, Name: "滑鼠"}, {OrderID: 1, Name: "螢幕"}, // ← key 1 出現第二次}good.By(items, ...) // map[1:{螢幕} 2:{滑鼠}] ← 鍵盤被蓋掉了good.ByAll(items, ...) // map[1:[{鍵盤} {螢幕}] 2:[{滑鼠}]]By:一個 key 對一份文件,用在 belongs-to、one-to-one。同一個 key 出現兩次就是後者蓋掉前者,畢竟 map 只放得下一個,這種情況該換ByAll。ByAll:一個 key 對多份文件,用在 has-many,也就是外鍵長在子文件上的情況。每一組內部保持文件回來的順序,所以查詢時下的Sort就是每一組裡的順序。
組合起來
belongs-to,訂單帶上下訂的使用者:
orders, err := orderColl.Where("userId", "=", userID).Sort("-createdAt").All(ctx)
ids := good.Keys(orders, func(o Order) bson.ObjectID { return o.UserID })users, err := userColl.Where("_id", "in", ids).Select("_id", "name").All(ctx)
byID := good.By(users, func(u User) bson.ObjectID { return u.ID })for i := range orders { orders[i].User = byID[orders[i].UserID]}has-many 則只是換成 ByAll,外鍵從主文件換到子文件上:
items, err := itemColl.Where("orderId", "in", good.Keys(orders, func(o Order) bson.ObjectID { return o.ID }),).Sort("-createdAt").All(ctx)
byOrder := good.ByAll(items, func(i Item) bson.ObjectID { return i.OrderID })for i := range orders { orders[i].Items = byOrder[orders[i].ID]}總結
寫完之後回頭看,SQL 的習慣通常是「先正規化,再想怎麼 JOIN」,MongoDB 比較像「先按照存取模式設計資料模型,剩下真的需要引用的地方再想辦法」。
而 good 這邊的取捨是盡量不要有抽象。整個套件沒有任何地方「知道」order 有一個 user,所以也就沒有關係要註冊,沒有 lazy load 會偷偷幫你發查詢。代價是每個關聯要多寫三行,但至少沒有一行查詢是我看不到的,使用上更接近 Native Go Driver。