Building Relational Queries in Go MongoDB

Go MongoDB 构建关联查询

前言

最近工作中经常遇到 MongoDB 关联查询的问题,没处理好很容易变成 N+1 问题,举例场景可以是:「一个用户可以有多笔订单,多笔订单可以对应到多个产品」。

USERSObjectId_idPKstringnamestringemailORDERSObjectId_idPKObjectIduserIdFKarrayitemsdatecreatedAtPRODUCTSObjectId_idPKstringnamenumberprice下单items 引用

刚好最近在制作一款基于 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 []Order
cursor, _ := 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",
"user": { "_id": "user_1", "name": "Alice", "email": "[email protected]" },
"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 []Order
cursor, _ := 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 []User
uc, _ := 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 Eloquent
Order::with('user')->get();
// GORM
db.Preload("User").Find(&orders)
// Prisma
prisma.order.findMany({ include: { user: true } })

一行声明要加载哪些关联,底下自动展开成上面那套批量查询,写的人只要描述关联关系,不用每次都手刻一遍收集、批量、组装。

而 Mongo Go Driver 是刻意保持底层的,官方没有提供这层抽象,所以接下来要做的就是在原生 driver 之上实践更便捷的关联查询。

小结

做法查询次数适用场景主要代价
N+1 Loop1 + N不该用O(N) 的延迟
内嵌 Embedding1一起读、少变动、有边界数据重复、16MB 上限、绑定查询方向
$lookup1需在 DB 端聚合/排序关联字段index 敏感、内存上限、pipeline 难维护
手动二次查询1 + M(M = 关联数)一般读取路径样板代码多、容易写错
Eager Loading1 + M同上,但可复用需要先建立抽象

简化关联查询

查询主数据
orders

收集去重外键
Keys

一次 in 查另一边
Where(_id, in, ids)

建索引
By / ByAll

回填字段

中间就是单纯的 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]T
func ByAll[T any, K comparable](rows []T, key func(T) K) map[K][]T
func 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。