数据建模
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 无界数组是头号反模式 把评论、日志、消息嵌入父文档里,是新手最常犯的错误。后果:
- 撞 16MB 上限,之后所有写入全部失败
- 文档不断增长导致频繁的存储重定位,写放大严重
- 每次读父文档都要把整个大数组读出来,即使只需要其中几条
- 无法对数组做高效分页 ——
$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 |
怎么答建模题
面试给一个场景(「设计一个电商订单系统」「设计一个评论系统」),答题框架:
- 先问访问模式 —— 读多还是写多?最高频的查询是什么?有哪些查询维度?
- 确定文档边界 —— 哪些数据总是一起被读写?(这些应该在一个文档里)哪些需要原子更新?
- 检查增长性 —— 有没有无界数组?文档会不会接近 16MB?
- 设计索引 —— 按 ESR 规则为高频查询建复合索引
- (分片场景)选分片键 —— 高基数 + 分布均匀 + 非单调 + 在高频查询条件里
- 说出取舍 —— 「我这里冗余了用户名,代价是用户改名时要同步,但订单需要的是下单时的快照,所以这个冗余是必要的」
主动说出取舍是最大的加分项。 建模题没有唯一正确答案,面试官看的是你是否理解每个选择的代价。