说实话,我第一次在 MongoDB 文档里看到_id旁边那串ObjectId("5f8d...")的时候,也完全摸不着头脑。后来才意识到,这其实就是 MongoDB 里的"引用"——一个不存在的类型,却到处都在用。这个系列写到第 44 篇,今天就把这层窗户纸彻底捅破:引用是什么、什么时候必须用、怎么用才不翻车,以及我这些年踩过的坑,一次说清楚。
1. 引用的本质:一个不存在的"类型",一条手动建立的关联
1.1 引用到底是什么
MongoDB 官方文档里并没有像"字符串""数组"那样定义一个独立的"引用类型"。所谓引用,本质上就是在一个文档的某个字段里,保存另一个文档的_id(通常是 ObjectId),用它来代表两个文档之间的关联关系。
举个例子,最简单的用户和订单模型:
// users 集合 { _id: ObjectId("5f8d0a2e9d6f0a1b2c3d4e5f"), username: "老王", email: "laowang@example.com" } // orders 集合 { _id: ObjectId("6a1b2c3d4e5f6a7b8c9d0e1f"), user_id: ObjectId("5f8d0a2e9d6f0a1b2c3d4e5f"), // 这就是引用 total_amount: 199, created_at: ISODate("2025-01-15T10:00:00Z") }orders.user_id这个字段里存的就是users._id,它不像关系型数据库的外键那样由数据库强制约束,而是由我们开发者自己维护的一种"指向关系"。
1.2 引用和关系型外键的核心差异
我从 MySQL 转到 MongoDB 时,最大的心理障碍就在这里:MongoDB 的引用,和 MySQL 的外键(Foreign Key)在概念上有点像,但底层逻辑完全不同。
| 对比项 | MySQL 外键 | MongoDB 引用 |
|---|---|---|
| 约束级别 | 数据库强制 | 应用层自觉约定 |
| 删除父记录 | 可级联删除/限制删除 | 数据库不管,可能产生孤儿文档 |
| 查询方式 | JOIN 一条 SQL 搞定 | 要么查两次,要么用$lookup聚合 |
| 类型限制 | 必须是主键列 | 可以是 ObjectId,也可以是任意业务唯一字段 |
说直白一点:MySQL 的外键是"法律",MongoDB 的引用是"君子协定"。数据库不会帮你验证user_id指向的用户是否真的存在,删除用户时也不会顺手把订单删了。这是 MongoDB 关系建模里最重要的一个认知转变。
1.3 ObjectId 凭什么是引用的事实标准
引用最常存的就是 ObjectId,这玩意儿本身就是为分布式环境设计的。它长度 12 字节,结构是:
- 前 4 字节:Unix 时间戳
- 中间 5 字节:机器随机值(每进程唯一)
- 最后 3 字节:自增计数器
因为这 12 个字节的组合方式,MongoDB 能在不依赖中心自增变量(就像 MySQL 的 AUTO_INCREMENT)的情况下,让每个客户端本地生成几乎全局唯一的 ID。所以你可以放心地先创建用户文档、拿到_id,再创建订单文档时把_id塞进user_id字段,完全不需要二次查询后端确认。
2. 什么时候应该用引用而不是嵌入文档:一份取舍清单
2.1 先搞懂嵌入和引用的本质区别
MongoDB 支持两种文档关系建模方式:
- 嵌入文档(Embedded Documents):直接把子数据整个塞进父文档的数组或子对象里
- 引用(References):只存一个 ID,数据分开存放
很多初学者会纠结:到底用哪个?其实判断标准非常朴素——这两个数据是不是"同生共死"的?
比如说订单和订单项:
// 嵌入方式 { _id: ObjectId("订单id"), user_id: ObjectId("用户id"), items: [ { sku: "A001", name: "机械键盘", price: 399, qty: 1 }, { sku: "A002", name: "鼠标垫", price: 49, qty: 2 } ], total: 497 }订单项绝对不会脱离订单独立存在,用户查看订单时一定是连头带尾把订单项一起读出来的,而且下单时订单和订单项必须在一个事务里一起写入。这种情况用嵌入文档,不光读写快,还天然保证一致性。
反过来,如果是一篇文章和它的浏览量:
- 文章详情页读文章正文,不一定每次都读浏览量
- 后台有个定时任务每 5 分钟批量更新浏览量
- 文章是低频更新,浏览量是高频更新
如果浏览量嵌在文章文档里,每次刷浏览量都要把整篇正文一起读进内存、改完再写回去。文章一长,这个操作的代价就非常难看。这时候把浏览量单独建一个集合,用引用关联,是更合理的做法。
2.2 适合用引用的四类典型场景
结合我实际做过的项目,下面这几种情况基本属于"不用引用就难受"的:
场景一:一对多,且"多"的那一侧数据量可能很大。
用户和订单、用户和日志、用户和收藏记录。一个用户可能有几万条操作日志,如果全嵌到用户文档里,很快就把 16MB 的单文档大小上限撞穿了。MongoDB 是文档型数据库,单文档大小有硬限制(默认 16MB),数据无上限增长时,只能拆成引用。
场景二:两个文档虽然有关联,但有完全独立的生命周期。
比如用户和订单:用户被删了(注销),订单记录仍然需要长期保留用于财务审计。如果订单嵌入用户文档,删用户就得先想好订单怎么办,非常尴尬。拆成引用之后,用户注销归注销,订单归订单,互相不绑架。
场景三:多对多关系。
文章和标签就是一个典型。一篇文章有多个标签,一个标签下有多篇文章。用嵌入怎么做都很别扭,双重嵌入会导致标签信息重复存储,改标签名的时候满数据库找。用引用,每篇文章存tag_ids: [ObjectId, ObjectId],标签单独维护一个集合,两边干净利落。
场景四:树形结构、分类层级。
比如商品分类的父子关系,每个分类存一个parent_id指向父分类,一条引用就能表达任意深度的树,不需要递归地嵌一堆子文档,逻辑清晰,维护也方便。
2.3 一个可以抄的建模决策表
我自己的建模习惯是照着下面这几个问题过一遍:
| 问题 | 倾向选嵌入 | 倾向选引用 |
|---|---|---|
| 子数据会单独查询吗? | 几乎不会 | 会,且频率不低 |
| 子数据量是否可能超过几十甚至上百条? | 不会 | 会 |
| 是否总跟父文档一起写、一起更新? | 是 | 否,两个写路径不同 |
| 删除父文档时子数据要跟着删除吗? | 要,一起删省心 | 不要,子数据有独立保存价值 |
| 数据是否会被多个父文档共享? | 否 | 是,必须引用 |
这套判断的最大好处是:把抽象概念变成 5 个具体问题,建模时过一遍,基本不会出大错。
3. 手动引用与 DBRef:两种写法的来龙去脉和选型建议
3.1 手动引用:99% 场景下的正解
所谓手动引用,就是我在第 1 节展示的写法——在文档里放一个字段,手动填上目标文档的_id。MongoDB 本身不提供任何"引用类型"的字段约束,所以你爱叫什么字段名都行:user_id、author_id、category_id,完全取决于你的语义。
手动引用的优点非常直接:
- 查询逻辑完全受你控制,想查就查,不想查就只存 ID
- 对
$lookup、$in这些聚合操作天然友好 - 可以给引用字段建立索引,比如
db.orders.createIndex({ user_id: 1 })
用 mongo shell 操作也很简单:
// 1. 创建用户 const user = db.users.insertOne({ username: "小张", email: "zhang@example.com" }); const userId = user.insertedId; // 2. 创建订单时引用用户 ID db.orders.insertOne({ user_id: userId, total_amount: 299, status: "paid" }); // 3. 查某个用户的所有订单 db.orders.find({ user_id: userId }); // 4. 查订单总数 db.orders.countDocuments({ user_id: userId });这里有个小细节:insertOne()返回的insertedId是真正的 ObjectId 类型,不是字符串。实际项目里经常有人把 ObjectId 转成字符串存进数据库,等到查询匹配的时候又发现ObjectId("...")和"..."对不上,非常坑。引用字段保持 ObjectId 类型,不要人为转字符串。
3.2 DBRef 到底是什么,为什么大家不用它
有些同学可能在老文档里见过 DBRef 这种格式。它长得是这样:
{ "_id": ObjectId("6a1b2c3d4e5f6a7b8c9d0e1f"), "user_ref": { "$ref": "users", "$id": ObjectId("5f8d0a2e9d6f0a1b2c3d4e5f"), "$db": "shop" } }DBRef 的设计初衷是:一条引用不只知道指向哪一行,还知道指向哪个集合、哪个数据库。理论上讲,它可以跨集合、跨数据库地构建通用引用关系。
既然设计思想听起来不错,为什么大家几乎不用?我总结三个现实原因:
**第一,DBRef 不会被自动解析。**数据库不会因为你存了 DBRef 就在查询时自动帮你加载被引用的文档。它本质上还是一组普通嵌套对象,该手动查还是得手动查。既然如此,我多包一层{$ref, $id, $db}图什么呢?
**第二,不同驱动对 DBRef 的支持参差不齐。**有的驱动有专门的DBRef类,有的交接时只把它当普通对象处理。一旦你换了语言栈,这些引用字段的读取、序列化逻辑可能要整个重写。
**第三,$lookup不喜欢它。**聚合作$lookup关联时,最顺手的方式是localField直接指向一个 ObjectId 字段。DBRef 把$id包在子对象里,$lookup的关联表达式会变得非常啰嗦:
db.orders.aggregate([ { $lookup: { from: "users", let: { uid: "$user_ref.$id" }, pipeline: [ { $match: { $expr: { $eq: ["$_id", "$$uid"] } } } ], as: "user" } } ])能跑,但比起手动引用的写法复杂了一大截。业界惯例早就收敛成一种:存 ObjectId,用的时候自己去查。DBRef 只在少数跨库引用、通用引用工具场景下还有价值,日常业务里别碰。
4. 从引用到数据落地:两次查询、聚合关联与安全删除的完整套路
4.1 最简单也最常见的"两次查询"模式
引用是手动维护的,所以查询时数据库不会自动把关联文档带出来。最简单的做法是:先查 A,再用 A 里拿到的 ID 去查 B。
假设我们要查一个订单详情,同时显示下单用户的名字:
// 第一步:查订单 const order = db.orders.findOne({ _id: orderId }); // 第二步:用订单里的 user_id 去查用户 const user = db.users.findOne({ _id: order.user_id }); // 组装 const result = { ...order, username: user.username };这段代码逻辑正确,但它是两次数据库往返。对于大多数业务系统,一次请求增加一次额外查询是可以接受的,尤其是命中索引后单次查询都在毫秒级。很多人一看到两次查询就皱眉头,其实没必要,在 MongoDB 里多一次有索引的精确查找,大多数时候代价完全可承受。
4.2 批量却不想踩 N+1?用$in合并查询
两次查询的"两次"是指固定的两次。但如果有人写出这样的代码,那问题就大了:
// 错误示范:查出 100 个订单,循环 100 次查用户 const orders = db.orders.find({ status: "paid" }).toArray(); for (const order of orders) { const user = db.users.findOne({ _id: order.user_id }); }这是经典的 N+1 查询陷阱:1 次查订单 + N 次查用户。正确做法是把用户 ID 先去重、再批量查一次:
const orders = db.orders.find({ status: "paid" }).toArray(); // 1. 提取所有 user_id,去重 const userIds = [...new Set(orders.map(o => o.user_id.toString()))]; // 注意:Map 出来的是 ObjectId 的话,Set 去重会失效,因为每个 ObjectId 是独立对象 // 所以要用 toString() 做去重 key,查的时候再转换回 ObjectId const userOids = userIds.map(id => ObjectId(id)); // 2. 一次查出所有相关用户 const users = db.users.find({ _id: { $in: userOids } }).toArray(); const userMap = new Map(users.map(u => [u._id.toString(), u])); // 3. 内存中组装 const result = orders.map(order => ({ ...order, username: userMap.get(order.user_id.toString())?.username }));这样无论有多少个订单,查询次数永远是 1(查订单)+ 1(批量查用户),配合索引,性能非常稳定。这里Set去重那一步极其容易被忽略,同一文档里_id作为 ObjectId 本身没问题,但如果你直接new Set(orders.map(o => o.user_id)),会发现 N 个订单就得到 N 个不同的对象引用,去重完全无效。先转字符串去重,再用 ObjectId 包装查库,这是经验。
4.3 用$lookup做一次性的关联查询
如果你不想在应用层手动拼数据,MongoDB 也提供了聚合管道的$lookup阶段,相当于关系型数据库里的 LEFT OUTER JOIN。还是刚才的用户-订单场景:
db.orders.aggregate([ { $lookup: { from: "users", localField: "user_id", foreignField: "_id", as: "user" } }, { $unwind: "$user" // 因为 $lookup 的结果是数组,这里拆开成对象 }, { $project: { "order_id": "$_id", "total_amount": 1, "status": 1, "username": "$user.username" } } ])几点说明:
from指定要关联的集合名localField是当前集合里的引用字段foreignField是目标集合里的_id字段- 结果会变成一个
user数组,所以通常接一个$unwind
$lookup对于"一次就想要关联数据"的场景非常省事,尤其适合报表、后台管理页这种要多表拼接数据的页面。不过它也有它的问题:如果两张表都很大,又没有合适的索引做支撑,关联查询可能消耗大量内存。MongoDB 的$lookup不是银弹,生产环境里我会给foreignField对应的_id(天然有索引)以及localField(手动建索引)都加上索引,否则小心扫描时间膨胀。
4.4 引用字段别忘了建索引
手动引用是"君子协定",意味着数据库不会替你优化。按user_id查订单的场景非常高频,如果amount、status那些字段都建了索引user_id却没有,那每次按用户查订单都是全集合扫描,数据量一上来就卡得很难看。
db.orders.createIndex({ user_id: 1 })联合查询经常连状态一起过滤的,可以考虑复合索引:
db.orders.createIndex({ user_id: 1, status: 1 })正确率高的索引能让引用查询从"能跑"变成"跑得好",这是引用建模里不可省略的一步。
4.5 删除引用链:顺序、孤儿与软删除
引用没有外键约束,删主文档时,子文档里的引用不会自动删。所以你在应用层必须想好删除策略。
最稳妥的 delete 顺序是:
- 先删(或更新)所有引用方
- 再删被引用的主文档
比如要注销一个用户,就先处理他名下的订单:要么删除,要么把user_id置空或标记为anonymous_user_id,再删 users 里的用户文档。如果顺序反了,先删用户,订单里的user_id就变成指向不存在的文档——这类文档叫孤儿引用(dangling reference),查询时 match 不上,统计时容易漏数据,问题非常隐蔽。
更省心的做法是软删除。给用户文档加一个deleted: true或者status: "disabled"的字段,注销的时候只改状态,不物理删除。订单查询仍然能关联到用户,只是要过滤掉已注销用户时多写一个条件。我在真实业务里更倾向于软删除,因为数据一旦物理删除,后面要找回来、做审计都难。
5. 引用设计最常见的坑:类型、孤儿与一致性边界
5.1 坑一:ObjectId 变字符串,怎么查都查不着
这是我在团队里见过最多的低级但隐蔽的错误。很多人从接口层拿到数据,顺手就把_id转成了字符串传给前端。前端某一天要把这个字符串原样存进另一个集合,于是数据库里出现了这种数据:
// orders 集合 { _id: ObjectId("6a1b2c3d4e5f6a7b8c9d0e1f"), user_id: "5f8d0a2e9d6f0a1b2c3d4e5f" // 注意:字符串,不是 ObjectId }然后查询的时候:
db.orders.find({ user_id: ObjectId("5f8d0a2e9d6f0a1b2c3d4e5f") }) // 一条都查不出来,因为类型不匹配ObjectId和string在 MongoDB 是完全不同的两种 BSON 类型,等值匹配永远不会相等。这个问题排查起来非常痛苦,因为数据看起来"差不多"。我的经验是:
- 插入引用字段前,先用代码强制校验类型
- 写一套数据校验脚本,定期扫描引用字段,把类型不对的数据暴露出来
- 查询时永远统一用
ObjectId包装
如果使用了驱动(比如 Node.js 的 Mongoose),可以给字段声明type: mongoose.Schema.Types.ObjectId,让框架帮你做转换,能省掉很多类型坑。
5.2 坑二:孤儿引用怎么发现
前面说过删主文档不删引用会产生孤儿引用。但真正的难点在于:当数据规模大起来,你根本不知道它产生了没有。你不能对每个引用字段都做一次反向查询来验证。实际工作中,我常用这么几招:
- 定期抽样检查:写一个脚本,随机抽取 N 条订单,用
$lookup关联用户,把关联不上的订单统计出来 - 重删重命名策略:对用户删除操作统一走软删除,从源头避免物理删除导致的孤儿
- 用聚合快速筛:
db.orders.aggregate([ { $lookup: { from: "users", localField: "user_id", foreignField: "_id", as: "u" } }, { $match: { u: { $size: 0 } } // 关联数组为空 = 没有找到用户 }, { $project: { _id: 1, user_id: 1 } } ])这种检查脚本建议作为生产环境数据巡检的一部分,每月跑一次,把孤儿数据兜出来清洗。别指望它永远不会发生,睡得着的唯一方式是定期检查。
5.3 坑三:引用字段放冗余快照,少一次后悔
引用的一个天然短板是:如果被引用的文档内容变了,引用方读到的还是当初那个 ID,内容却已经变了。这在电商场景里非常致命。
举个具体例子:商品价格。
- 用户下单时,订单只存
product_id,不存价格 - 一个月后,商品价格调整了
- 而订单详情页需要显示"下单时的价格"
如果只管引用、不做冗余,你会发现所有历史订单显示的都是最新价格,财务对账直接乱套。正确的做法是:下单时,把商品名称、单价、图片等核心信息作为"快照"冗余到订单文档里,同时保留product_id引用便于跳转商品页:
// 订单项 { product_id: ObjectId("..."), snapshot: { name: "机械键盘", price_at_order: 399, // 下单时价格,永久不变 image: "http://..." }, qty: 1 }这样既保住了历史事实(价格是当时的),又保留了关联能力(点商品名能跳转)。**引用负责关联,快照负责还原历史上下文。**这个设计几乎所有涉及"历史单据"的业务都应该抄走。
5.4 引用设计的一致性边界
最后聊一个设计层面的问题:引用什么时候该用ObjectId,什么时候可以用业务唯一键?
比如用户表里有个customer_no是业务编号,订单里存customer_no而不是user_id可不可以?可以,但代价是索引、关联都要跟上,而且业务编号可能因业务规则变化而没有全局唯一保证。多数情况下,直接用_id引用是最短路径,因为_id唯一的强制性是数据库层面的,而业务键的唯一性是应用层保证的,信用级别不一样。
再延伸一步:引用关系里,到底谁应该拥有数据?以用户和订单为例,用户是独立实体,订单也是独立实体,两边是对等关联,没有谁嵌在谁里面的说法。但如果某个子文档离开父文档没有任何存在意义,比如订单项、评论的回复,那说明它本来就该被嵌入。判断边界的时候,先回答"这条数据没了父文档还能活吗",再回答"我要不要独立查它",两个问题都指向独立,才用引用。
6. 引用设计的最后一道自查题
写到这里,把最重要的经验浓缩成一张自查表。以后做 MongoDB 关系建模时,照着过一遍就行:
- 这个字段指向的是真正独立的实体吗?(是才引用)
- 引用字段是不是统一存 ObjectId,不转字符串?(是才不容易出类型坑)
- 高频按引用查询的字段建索引了吗?(建了才不慢)
- 删除主文档时,引用方怎么办?(想好了才不会孤儿遍地)
- 需要历史快照的引用场景,冗余关键信息了吗?(冗余了才经得起审计)
坦白说,引用这个概念本身并不复杂,难的是它把原本数据库该承担的关系约束,交到了开发者手里。我在实际项目中见过太多"以为自己在用 MongoDB,其实还在按 MySQL 的 JOIN 思维硬写"的代码,也见过因为引用滥用导致数据像蜘蛛网一样散落各集合的场景。不求一步到位,但建模时多停几秒想清楚"谁拥有数据、谁引用数据、删了怎么办",后面能少熬很多夜。
这个系列接下来如果时间允许,我会拿一个完整的电商订单系统,把嵌入、引用、索引、聚合关联从头到尾演示一遍,到时候你们会发现,最顺手的那套设计,往往是先把概念想透的那套。