没有“最高效”的设计,只有“在当前查询模式下开销最小”的设计;关键取决于查询内容、方式及频率,需结合读写比、稳定性、分页缓存等需求选择 $lookup 或应用层查询,并确保索引合理、原子操作兜底、中间集合显式建模。

直接说结论:没有“最高效”的设计,只有“在当前查询模式下开销最小”的设计。关键不是关系怎么存,而是你查什么、怎么查、频率多高。
用 $lookup 还是应用层两次查询?
选哪个取决于你的读写比和查询稳定性:
- 如果查询固定(比如“查用户+他的所有角色+每个角色的权限名”),且权限数据极少变动,
$lookup配合$match前置过滤 +$project投影必要字段,能省一次 round-trip - 如果要支持分页、缓存控制、或按权限创建时间排序,应用层先查
user.roles数组,再Permission.find({ _id: { $in: [...] } })更可控 - 常见错误是没加
$match就直接$lookup——主集合没过滤,结果集爆炸,内存溢出或超时很常见 - 被 join 的集合(如
permissions)必须在foreignField(比如_id)上建索引,否则$lookup变全表扫描
该不该用中间集合(junction collection)?
只要关系带属性,或者你需要按关系本身做条件查询,就必须拆。
比如“用户收藏文章”,如果只存 user.favorites: [ObjectId],就无法回答:“查用户 A 本周收藏的所有技术类文章”。这时候中间集合 user_favorites 是唯一解:
- 结构示例:
{ user_id: ObjectId, article_id: ObjectId, created_at: ISODate, tag: "tech" } - 必须建复合索引:
db.user_favorites.createIndex({ user_id: 1, created_at: -1 }) - 别在中间集合里嵌入完整
article文档——更新文章标题时得批量改几百条中间记录 - 中间集合不等于“更重”,反而是把模糊的语义显式落地,避免后期用
$unwind+$lookup硬凑
双向引用数组怎么维护不崩?
MongoDB 不保证跨文档原子性,所以靠写法兜底:
- 增删一律用原子操作:
$addToSet替代$push,$pull替代手动 splice 数组 - 删主文档前,必须用
bulkWrite批量清理所有关联方,例如:一次更新 500 个permissions.roles字段,而不是循环 500 次save() - 并发写同一关系时(如两个请求同时给用户加同一个角色),
$addToSet能防重复,但不会报错;若需强校验,得加唯一索引{ user_id: 1, role_id: 1 }在中间集合上 - 定期跑一致性校验脚本,比如查出所有
roles.users里存在但对应users._id已删除的记录——这种脚本不能省,也不能只靠开发环境测
最容易被忽略的点是:索引不是建了就完事。比如你在 user.roles 上建了索引,但实际查的是 user.roles.name,那这个索引根本用不上;又比如用了 $elemMatch 却忘了对数组字段启用 multikey 索引。设计完立刻用 explain() 看执行计划,比凭经验猜靠谱得多。

















