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 解决;若读远多于写,双向冗余更划算。当关系本身带有属性(如选课时间、成绩)时,应把关系建模为独立集合,即“关联文档”模式。

嵌入 vs 引用:按关系基数与访问模式选择 一对一 user profile: { nickname, locale } 默认嵌入 独立生命周期 / 大体积 → 引用 判断:访问模式是否一致 一对多 order items: [ {sku, qty, price} ] 有界 + 一起读 → 嵌入 无界 / 独立分页 → 引用 折中:嵌入最近 N 条摘要 多对多 student ⇄ course courseIds / studentIds 双向引用(读多写少) 关系带属性 → 独立关联集合 代价:更新两处,非原子 建模决策路径 子数据是否总随父读? 是否会被独立查询/更新? 数量是否有上界? 是否属于同一聚合根? 嵌入:同聚合根内,强一致、一起读写 订单行、地址、配置项、最近 N 条摘要 引用:跨聚合根,独立生命周期、无界增长 评论、日志、用户、商品、跨服务实体

五、面向微服务聚合根的设计方法

在微服务架构中,文档建模的边界首先由聚合根(Aggregate Root)决定,而不是由表关系决定。聚合根是外部访问的唯一入口,聚合内部保证强一致,聚合之间只能通过 ID 引用并最终一致。这与 MongoDB 的嵌入/引用选择天然吻合:聚合内部用嵌入,聚合之间用引用。

具体落地可以按四步走。第一步,从业务不变量出发划分聚合。例如“订单总额必须等于各行小计之和”是订单聚合的不变量,因此订单行必须与订单同属一个聚合,嵌入。第二步,确定聚合根 ID 作为跨聚合引用的唯一键,如 orderId、productId、userId,禁止用业务字段(如商品名)做引用。第三步,为每个聚合设计一个文档或一组文档,聚合内嵌入,聚合间引用。第四步,明确跨聚合一致性策略:下单时订单服务只保存 productId 与下单时的价格快照,商品改价不影响历史订单,这正是文档模型“快照式”建模的价值。

一个实用判据:如果两个数据必须“同时成功或同时失败”,它们应在同一个文档(同一聚合)内;如果可以“先写一个、稍后同步另一个”,就用引用并接受最终一致。

六、一个可对照的建模示例

以电商下单场景为例,假设商品服务与订单服务是两个微服务:

关系基数选择理由
订单 → 订单行一对少嵌入同聚合,一起读写,需保证总额一致
订单 → 用户一对一引用 userId跨服务,用户独立更新
订单 → 商品多对一引用 productId + 价格快照跨服务,且需保留历史价格
商品 → 评论一对海量引用 productId无界增长,需独立分页
用户 → 收藏商品多对多双向引用两端都需独立查询

可以看到,同一套业务里嵌入与引用是并存的,选择依据始终是“聚合边界 + 访问模式 + 增长性”,而不是关系类型本身。把这三条判据固定下来,文档建模就从凭感觉变成了可复用的工程决策。