4. 文档数据建模核心
MongoDB 的文档模型允许把关联数据直接放进同一个文档,也允许像关系数据库那样用 ID 引用另一个集合。这两种手段——嵌入(Embedding)与引用(Referencing)——构成了文档建模的全部基础。真正困难的不是记住这两个词,而是判断在具体业务里该用哪一个,以及如何让这个判断与微服务的聚合边界保持一致。
一、嵌入与引用的本质差异
嵌入是把子数据作为字段写进父文档,读写一次往返即可拿到完整对象,天然具备原子性;代价是文档体积增长、单文档 16MB 上限、以及子数据被多处复用时产生冗余。引用是把子数据存到另一个集合,父文档只保留其 _id,消除了冗余、便于独立更新,但读取需要二次查询或 $lookup 聚合,且跨文档写入不再原子。
判断的核心不是“哪个更好”,而是三个问题:子数据是否总与父数据一起被读取?子数据是否会被独立查询或更新?子数据是否会无限增长?三个问题都指向“一起读、不独立、有界”,就嵌入;只要有一个指向“独立、共享、无界”,就引用。
二、一对一:优先嵌入,除非有独立生命周期
一对一关系在文档模型里几乎没有理由拆开。例如用户与其账户设置,读取用户时几乎总要带上设置,直接嵌入即可:
{
_id: ObjectId("..."),
username: "lin",
profile: {
nickname: "小林",
avatar: "https://cdn.example.com/a.png",
locale: "zh-CN"
}
}
只有当被关联对象体积很大(如一份几十 KB 的简历正文)、访问频率极低,或拥有独立权限与生命周期(如实名认证材料需要单独审计、单独过期)时,才拆成独立集合并用 userId 引用。判断依据是“访问模式是否一致”,而不是“逻辑上是不是两个实体”。
三、一对多:区分“少数”与“海量”
一对多是最需要分情况处理的形态,关键变量是“多”的那一端有没有上界。
- 一对少数(有界):如订单与其订单行。订单行数量有限、总与订单一起读取、不会单独查询,应嵌入为数组。此时订单文档本身就是一次交易的完整快照,读取一个文档即可渲染整个订单页。
- 一对海量(无界):如商品与其评论、用户与其行为日志。评论会持续增长,且经常需要单独分页查询、按时间排序、单独删除,必须放到独立集合,用
productId引用。 - 一对多但需要独立查询:如文章与其评论,即使单篇文章评论不多,只要存在“查某用户的所有评论”这类反向查询,也应引用。
嵌入数组时还要注意:数组元素不宜过多,通常控制在几百以内;若确实需要保留少量摘要,可采用“混合模式”——把最近 3 条评论嵌入文章文档用于首屏展示,全量评论仍存独立集合。这是文档建模中非常实用的折中手法。
四、多对多:引用为主,必要时双向冗余
多对多意味着两端都可能被独立查询和更新,嵌入任何一端都会造成另一端的更新困难。典型做法是两端各存对方的 _id 数组:
// students 集合
{ _id: 1, name: "王敏", courseIds: [101, 102] }
// courses 集合
{ _id: 101, title: "MongoDB 建模", studentIds: [1, 2, 3] }
双向引用让“查某学生选了哪些课”和“查某课有哪些学生”都只需一次查询,代价是选课/退课时要更新两个文档,且不再原子。若一致性要求高,可只保留单向引用,反向查询用 $lookup 解决;若读远多于写,双向冗余更划算。当关系本身带有属性(如选课时间、成绩)时,应把关系建模为独立集合,即“关联文档”模式。
五、面向微服务聚合根的设计方法
在微服务架构中,文档建模的边界首先由聚合根(Aggregate Root)决定,而不是由表关系决定。聚合根是外部访问的唯一入口,聚合内部保证强一致,聚合之间只能通过 ID 引用并最终一致。这与 MongoDB 的嵌入/引用选择天然吻合:聚合内部用嵌入,聚合之间用引用。
具体落地可以按四步走。第一步,从业务不变量出发划分聚合。例如“订单总额必须等于各行小计之和”是订单聚合的不变量,因此订单行必须与订单同属一个聚合,嵌入。第二步,确定聚合根 ID 作为跨聚合引用的唯一键,如 orderId、productId、userId,禁止用业务字段(如商品名)做引用。第三步,为每个聚合设计一个文档或一组文档,聚合内嵌入,聚合间引用。第四步,明确跨聚合一致性策略:下单时订单服务只保存 productId 与下单时的价格快照,商品改价不影响历史订单,这正是文档模型“快照式”建模的价值。
六、一个可对照的建模示例
以电商下单场景为例,假设商品服务与订单服务是两个微服务:
| 关系 | 基数 | 选择 | 理由 |
|---|---|---|---|
| 订单 → 订单行 | 一对少 | 嵌入 | 同聚合,一起读写,需保证总额一致 |
| 订单 → 用户 | 一对一 | 引用 userId | 跨服务,用户独立更新 |
| 订单 → 商品 | 多对一 | 引用 productId + 价格快照 | 跨服务,且需保留历史价格 |
| 商品 → 评论 | 一对海量 | 引用 productId | 无界增长,需独立分页 |
| 用户 → 收藏商品 | 多对多 | 双向引用 | 两端都需独立查询 |
可以看到,同一套业务里嵌入与引用是并存的,选择依据始终是“聚合边界 + 访问模式 + 增长性”,而不是关系类型本身。把这三条判据固定下来,文档建模就从凭感觉变成了可复用的工程决策。