前言
最近工作中经常遇到 MongoDB 关联查询的问题,没处理好很容易变成 N+1 问题,举例场景可以是:「一个用户可以有多笔订单,多笔订单可以对应到多个产品」。
刚好最近在制作一款基于 Mongo Go Driver 的 Query Builder:good,就顺着这个常见问题整理一次各种做法的取舍。
type User struct { ID bson.ObjectID `bson:"_id"` Name string `bson:"name"` Email string `bson:"email"`}
type Order struct { ID bson.ObjectID `bson:"_id"` UserID bson.ObjectID `bson:"userId"` Status string `bson:"status"` CreatedAt time.Time `bson:"createdAt"`
User *User `bson:"-"` Items []Item `bson:"-"`}
type Item struct { ID bson.ObjectID `bson:"_id"` OrderID bson.ObjectID `bson:"orderId"` Name string `bson:"name"` Price int `bson:"price"` CreatedAt time.Time `bson:"createdAt"`}关联查询怎么处理
N+1 Loop
查完主数据,再循环逐笔去查关联数据:数据库查询性能问题:N+1 问题
var orders []Ordercursor, _ := orderColl.Find(ctx, bson.M{"status": "paid"})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": "键盘", "price": 100, "qty": 2 } ], "createdAt": "2026-08-17T00:00:00Z"}这是文档数据库常见的特色,鼓励「基于访问模式构建数据模型」,但内嵌也有对应的代价:
- 文档大小上限:单一文档 16MB,无边界增长的关联内嵌进去迟早会爆。
- 绑定单一读取路径:内嵌的形状是为某一种读取方式优化的,需求一变就得重塑文档。
- 数据重复:
user.name同时存在 users collection 与每一笔 order 里,用户改名就要回头更新所有订单,否则数据不一致。 - N:M 不适合:products 被多笔 orders 引用,内嵌等同于在每笔订单里再复制一份同样的数据。
所以内嵌虽然最直白,但适合的其实是那种有边界、会一起读、而且之后不会再改动的数据,像是订单成立当下的商品快照,其他情况还是保持引用比较安全。
$lookup(Server-side Join)
把 join 交给数据库,用 aggregation pipeline 一次组合好结果:
pipeline := mongo.Pipeline{ bson.D{{Key: "$match", Value: bson.M{"status": "paid"}}}, 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 内存上限:数据量一大就得开
allowDiskUse,不然整条 pipeline 直接失败。 - 多层 join 难维护:join 两三层之后,pipeline 会膨胀成一大块难以阅读的 BSON。
- index 没命中就是全表扫描:
foreignField没 index,每笔主文档都扫一次 foreign collection。 - 裸接
$unwind是 inner join:preserveNullAndEmptyArrays默认false,关联查不到的主文档会整笔被丢掉,不报错。 as永远是数组:N:1 也一样,要对上 Go struct 就得接$unwind摊平。
需要在 DB 端拿关联字段做聚合、筛选、排序的时候,$lookup 通常是最直接的解法。但如果只是想把关联字段补齐,成本会比想象中难掌握:性能取决于 foreign collection 有没有命中 index、内存吃紧时整条 pipeline 会直接失败,而且是哪一个 stage 慢了,从应用层也不容易看出来。单纯补字段这种需求,放在应用层反而更可控。
手动二次查询(Application-side Join)
把关联操作搬回应用层,先查主数据收集所有外键,用一次 $in 批量查完关联数据,最后在内存里组装。
// 1. 查询主数据var orders []Ordercursor, _ := orderColl.Find(ctx, bson.M{"status": "paid"})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 的字段,成本很好预测,概念也单纯。缺点是样板代码太多,上面那一段,换一个关联要抄一次,换一个类型又要再抄一次,每次都是同样的收集外键、去重、$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 是刻意保持底层的,官方没有提供这层抽象,所以接下来要做的就是在原生 driver 之上实践更便捷的关联查询。
小结
| 做法 | 查询次数 | 适用场景 | 主要代价 |
|---|---|---|---|
| N+1 Loop | 1 + N | 不该用 | O(N) 的延迟 |
| 内嵌 Embedding | 1 | 一起读、少变动、有边界 | 数据重复、16MB 上限、绑定查询方向 |
$lookup | 1 | 需在 DB 端聚合/排序关联字段 | index 敏感、内存上限、pipeline 难维护 |
| 手动二次查询 | 1 + M(M = 关联数) | 一般读取路径 | 样板代码多、容易写错 |
| Eager Loading | 1 + M | 同上,但可复用 | 需要先建立抽象 |
简化关联查询
中间就是单纯的 query $in,真正缺的只有头尾:把外键收出来,还有把查回来的东西建成索引。这两件事都不用碰数据库,也不用知道任何关联声明,所以我就把它们写成三个泛型函数。
收集去重后的外键
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,反而会匹配到刚好存了零值的文档。 - 返回长度 0 不代表出错:返回值不会是
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: u1}, // 只有 userId,没有名字 {ID: o2, UserID: u2},}users := []User{ {ID: u2, Name: "小明"}, // 只有名字,不知道自己被谁引用 {ID: u1, 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: o1, Name: "键盘"}, {OrderID: o2, Name: "鼠标"}, {OrderID: o1, Name: "显示器"}, // ← key o1 出现第二次}good.By(items, ...) // map[o1:{显示器} o2:{鼠标}] ← 键盘被覆盖掉了good.ByAll(items, ...) // map[o1:[{键盘} {显示器}] o2:[{鼠标}]]By:一个 key 对一份文档,用在 belongs-to、one-to-one。同一个 key 出现两次就是后者覆盖前者,毕竟 map 只放得下一个,这种情况该换ByAll。要注意的是 map 取不到值时回的是零值而不是错误,所以回填前要用v, ok := m[k]判断,不然关联不存在时会静静塞进一个空的 struct。ByAll:一个 key 对多份文档,用在 has-many,也就是外键长在子文档上的情况。每一组内部保持文档回来的顺序,所以查询时下的Sort就是每一组里的顺序。
组合起来
belongs-to,订单带上下单的用户:
orders, err := orderColl.Where("status", "=", "paid").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 { if u, ok := byID[orders[i].UserID]; ok { orders[i].User = &u }}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。