跳到主要内容

数据建模

MongoDB 建模的第一原则:按访问模式设计,而不是按范式设计。关系库先设计规范化的表结构、再想怎么查;MongoDB 反过来 —— 先确定「应用会怎么读写」,再决定文档长什么样。

🔥 嵌入 vs 引用​

最高频的建模题。

嵌入(Embedding) —— 把关联数据直接放进父文档:

{
_id: ObjectId("..."),
orderNo: "202601010001",
user: {id: 123, name: "张三", phone: "138****"}, // 冗余快照
items: [ // 嵌入数组
{sku: "A1", name: "戒指", price: 1090, qty: 1},
{sku: "B2", name: "项链", price: 590, qty: 2}
],
total: 2270
}

引用(Referencing) —— 只存关联文档的 _id,查询时再关联:

{_id: ..., orderNo: "...", userId: 123, itemIds: [ObjectId(), ObjectId()]}

决策依据:

维度选嵌入选引用
访问模式总是一起读取经常单独访问子对象
关系基数1:1、1:少(几十以内)1:多(成百上千)、多:多
子数据是否无界增长有上限无上限(评论、日志、消息)
更新频率子数据很少单独变子数据频繁更新
数据重复可接受冗余 / 需要历史快照必须单一来源
文档大小远小于 16MB可能撑爆 16MB

一句话总结:「一起读就嵌入,单独用就引用,会无限长就一定引用」。

三种基数的典型处理:

  • 1:1 —— 嵌入(用户和他的详细资料)
  • 1:少(one-to-few) —— 嵌入(用户的几个地址、订单的几件商品)
  • 1:多(one-to-many) —— 子文档单独一个集合,存父 ID(文章和它的评论)
  • 1:极多(one-to-squillions) —— 必须引用,且在子文档里存父 ID而不是在父文档里存子 ID 数组(否则父文档的数组会无界增长)
  • 多:多 —— 两边都存对方 ID 数组(数量可控时),或者建中间集合

:::warning 无界数组是头号反模式 把评论、日志、消息嵌入父文档里,是新手最常犯的错误。后果:

  1. 撞 16MB 上限,之后所有写入全部失败
  2. 文档不断增长导致频繁的存储重定位,写放大严重
  3. 每次读父文档都要把整个大数组读出来,即使只需要其中几条
  4. 无法对数组做高效分页 —— $slice 能取片段但仍要加载整个文档

判断标准:如果这个数组的长度由用户行为决定、没有自然上限,就不要嵌入。 :::

常用设计模式​

冗余快照(Snapshot / Extended Reference)

订单里冗余商品名和价格。这不只是性能优化,更是业务正确性 —— 商品改名改价后,历史订单必须显示下单时的信息。和 MySQL 反范式化的理由完全一致。

要点是只冗余不变的或需要快照的字段,且明确「这是快照,不跟随源数据更新」。

分桶模式(Bucket Pattern)

时序数据的标准解法。不要「一个数据点一个文档」(元数据开销巨大),而是按时间窗口聚合成桶:

{
sensorId: "s001",
date: ISODate("2026-01-01T10:00:00Z"), // 整小时
count: 60,
sum: 1380.5, // 预聚合
readings: [{t: 0, v: 23.1}, {t: 1, v: 23.2}, ...] // 60 个点
}

收益:文档数量降低 60 倍、索引小 60 倍、一次读取拿到整小时数据。MongoDB 5.0+ 有原生的时序集合(Time Series Collections),底层就是自动分桶,新项目直接用它。

预计算模式(Computed Pattern)

把聚合结果直接存在文档里(商品的 avgRating、reviewCount),写入时用 $inc 增量更新,而不是每次读都跑聚合。读多写少时收益巨大。

子集模式(Subset Pattern)

文档里只存「最常用的一小部分」—— 商品文档存最新 5 条评论,完整评论在另一个集合。解决「16MB 限制」和「每次读都加载大量不需要的数据」。

树形结构的四种建模(高频):

方式存什么擅长
父引用parentId查直接父节点、插入简单
子引用childIds: []查直接子节点
物化路径path: ",1,5,12,"查整个子树(前缀正则 + 索引)、查所有祖先
嵌套集合left/right 编号查子树快,但插入要重排大量节点

实践中物化路径最常用,常和父引用组合(parentId + path 都存)。配合 $graphLookup 也能做递归查询。

多态模式(Polymorphic Pattern) —— 同一集合里存不同形态的文档,用一个 type 字段区分。这是 MongoDB 相对关系库的真实优势:商品的属性因品类而异(戒指有尺寸和材质,相机有传感器和焦距),关系库要么建大宽表(大量 NULL)要么建多张表 + 一堆 JOIN。

反模式清单​

反模式问题正解
无界数组16MB、写放大拆成独立集合或分桶
大量小集合每个集合有固定开销,元数据膨胀用多态模式合并
过度规范化到处 $lookup,性能差该嵌入就嵌入
过度嵌入文档巨大,读一个字段要加载全部子集模式
不必要的索引拖慢写入、占 cache用 $indexStats 清理
区分度低的索引优化器用不上删掉或并入复合索引
把 MongoDB 当关系库用失去所有优势只剩缺点要么重建模,要么换 MySQL
不设 wtimeout节点故障时无限阻塞总是设超时
用 $where / JS 表达式无法用索引、有注入风险用原生运算符或 $expr

怎么答建模题​

面试给一个场景(「设计一个电商订单系统」「设计一个评论系统」),答题框架:

  1. 先问访问模式 —— 读多还是写多?最高频的查询是什么?有哪些查询维度?
  2. 确定文档边界 —— 哪些数据总是一起被读写?(这些应该在一个文档里)哪些需要原子更新?
  3. 检查增长性 —— 有没有无界数组?文档会不会接近 16MB?
  4. 设计索引 —— 按 ESR 规则为高频查询建复合索引
  5. (分片场景)选分片键 —— 高基数 + 分布均匀 + 非单调 + 在高频查询条件里
  6. 说出取舍 —— 「我这里冗余了用户名,代价是用户改名时要同步,但订单需要的是下单时的快照,所以这个冗余是必要的」

主动说出取舍是最大的加分项。 建模题没有唯一正确答案,面试官看的是你是否理解每个选择的代价。