适合人群:掌握了 CRUD(第 03 篇),开始要为真实业务建集合的开发者。
本文是全系列的灵魂篇:聚合、索引、事务的很多结论,都建立在”建模做对了”的前提上。案例主线用「文章 + 评论」,电商场景会同步给出对应映射。
一、两种极端都不可取
从 MySQL 过来的人,最常见的第一个错误是把”关系范式”整个搬进 MongoDB:评论单独一个集合、标签单独一个集合、点赞单独一个集合……写个文章详情页要 $lookup 三四轮,查询代价比 JOIN 还高——文档数据库的优势荡然无存。
而另一个极端是万物皆嵌入:把一篇文章的所有评论、所有点赞用户全塞进 posts 文档的数组里。用起来确实爽,一个 find 全部拿到,但很快你会撞上一串墙:
- 16 MB 上限:热帖十万条评论根本放不下;
- 更新风暴:每条新评论都在改这个巨型文档,WiredTiger 的写放大让磁盘和缓存一起遭殃;
- 并发互踩:第 03 篇说过单文档写入天然原子——粒度太大时,这份原子性反而成了吞吐瓶颈,所有人排队等同一份文档。
正确的心法只有一句话:**总是被一起读写的数据,就住在一起;各过各日子的,就分开住。**下面把它变成可执行的判断。
二、决策三问
面对任何一条关联关系(父文档 A 关联 B),依次问三个问题:
- B 是否总随 A 一起读写? 详情页必带、离开 A 就没人查 → 倾向嵌入。
- B 的数量是否有界? 几十上百封顶(地址、规格项)→ 嵌入安全;持续增长无上限 → 引用或只嵌子集。
- B 更新的频率如何? 高频被独立修改(点赞数每次点击都在变)→ 独立集合,避免牵一发动全身。
拿「文章 + 评论」走一遍:评论会被独立分页浏览(第三页以后基本不回到文章里读)→ 问题一答否;数量随热度无界增长 → 问题二答否;结论自然落到引用。反过来,「文章自身的富文本配置」永远只在文章上下文出现、体量固定 → 直接嵌入。
三、一对 N 的建模谱系
“一对多”不是一个问题,而是三种情况,各自的标准答案不同:
| 形态 | 典型例子 | 推荐方案 |
|---|---|---|
| 一对几 / 一对几十 | 用户 ↔ 收货地址 | 嵌入:数组直接住进父文档 |
| 一对多(百到十万级) | 商品 ↔ 订单明细 | 父引用:子文档存父 _id,读取用 $lookup |
| 一对巨(无界) | 文章 ↔ 评论 | 引用 + 子集模式:最近 N 条嵌进文章,余下归集合 |
三种形态的关键差异在读写路径上。嵌入形态一次 find 拿到全部;父引用形态写入轻松、但”带子查询”要付联表成本;子集模式是折中艺术——高频读的部分付嵌入成本,长尾部分留在引用侧:
1 | // 子集模式的 posts 文档 |
评论区翻页、搜索历史评论这些操作才去访问独立的 comments 集合(postId 字段回指文章)。电商对照:订单主文档通常整体嵌入商品快照行(名称/单价/数量),因为订单必须自证历史、且明细有界——这也是为什么它是文档模型的教科书案例。
3.1 多对多:双向引用
学生 ↔ 课程这类关系,两侧都能发起查询,就各持一份对方的 _id 数组。牢记两件事:① 以哪一侧为主查询,就把维护成本放在另一侧增量同步;② 数组同样遵守”有界才嵌入”,规模失控的多对多该认真考虑中间表集合或干脆回关系库。
四、反范式冗余:读快的代价要算清
latestComments 和 commentCount 都是对关系范式的背叛——同样的数据存了两份。冗余不是罪,但要明确交易条款:
- 买到的:读路径缩短。列表页展示 50 篇文章的总评论数,没有冗余就是 50 次
$lookup或聚合,有了commentCount就是原字段直出; - 付出的:写路径变长。新增一条评论必须同时
$inc文章计数,两处不在同一个文档里——不保证原子(跨文档事务是第 08 篇的话题)。
于是出现经典的短暂不一致窗口:计数落后于实际评论几毫秒甚至几秒。多数业务完全能容忍这种”最终一致”;不能容忍的(比如库存),要么放进同一份文档用 $inc 的原子性保护,要么老老实实走强事务存储。
冗余字段的准入学测试:它显示错了几分钟,有人会提工单吗?
五、四种官方模式速览
《MongoDB Design Patterns》沉淀了一批反复出现的套路,先认识最常用的四个:
| 模式 | 解决什么 | 一句话结构 |
|---|---|---|
| 子集 Subset | 大集合的高频头部 | 主文档嵌最新 N 条 + 总量计数 |
| 桶 Bucket | 时序/流水数据的无限追加 | 按”天+设备”聚成一桶:{ device, day, readings: [...] } |
| 扩展引用 Extended Reference | 联表只为显示几个字段 | 子文档里顺手冗余对方的两三个展示字段 |
| 极值 Outlier | 少数脏数据破坏均值假设 | 大字段超阈值就降级成引用 |
桶模式值得多说一句:IoT 设备每秒上报一次温度,逐秒一文档一年就是三千万条;按天打包成一桶后只剩三千条,索引小、范围扫描快,正是嵌入式设备和监控系统在生产的常见做法。扩展引用则回答了”订单展示买家昵称要不要 join users 集合”——下单时把昵称抄进订单即可,代价是改昵称不会追溯生效,而这恰恰往往是需求方想要的语义(历史凭证不可变)。
六、Schema 校验:给自由套上缰绳
第 01 篇留了个伏笔:动态 Schema 需要”显式契约”。它就是建集合时声明的校验器:
1 | db.createCollection("posts", { |
两个参数决定严格程度:validationLevel(off / strict / moderate)约束新旧文档是否都受检,validationAction(error / warn)决定违规是拒绝还是仅记警告。生产建议 moderate + error 起步——既有体系平滑接入,又挡住未来的脏数据。注意校验器只在写入时把关,查询照旧灵活,所以灰度期新旧结构并存的策略依然可行。
七、常见坑清单
- 数组当万能口袋:
$push无限追加日志/积分记录,撑破 16 MB 只是时间问题——流水类数据该进桶或独立集合; - 引用类型不一致:一半文档存
ObjectId、另一半存它的字符串形式,$lookup前必须统一(新旧版本代码迁移的重灾区); - 为了少一次 lookup 过度引用化:把本该嵌入的一对几拆成独立集合,每个接口背四个
$lookup——反过来也是一种失衡; - 冗余字段无人负责:
commentCount出现后没规定”哪条代码路径允许改它”,三个月后不一致成为常态。冗余必须有唯一维护入口; - 缺 schema 版本号:加了
_schemaVersion字段,未来滚动升级、批量迁数据才有抓手; - validator 写完就忘:Structural 变更后忘了更新
$jsonSchema,新字段被 old validator 拦截时报错信息令人抓狂——校验器要当契约文档一样 review。
八、总结与下一步
- 建模心法一句话:一起读写的数据住在一起;决策三问(一起?有界?更新频率)落地成一张决策树;
- 一对 N 分三种:嵌入、父引用、子集模式;多对多用双向引用并警惕数组膨胀;
- 冗余 = 读性能与写一致性的交换合同,准入测试是”错了几分钟能忍吗”;同一文档内靠原子性,跨文档只能事后修或上事务;
- 子集、桶、扩展引用、极值四个官方模式覆盖了大半真实场景;
validationLevel/action让 Schema 校验可以温和地铺开,别再裸奔。
下一篇:《MongoDB 从零到一(05):聚合管道(上)》——把这个系列真正的重头戏请上场:像搭流水线一样完成 GROUP BY 干不了的统计工作。详见 MongoDB 从零到一(05):聚合管道(上)。
思考与练习
- 给你的项目挑三条外键关联,分别走一遍决策三问,写下各自的归宿(嵌入 / 引用 / 子集)和理由。
- 为
shop.products补一个$jsonSchema:price必须是 decimal 且非负,tags至多 8 个元素——想清楚validationLevel该选哪个。- 订单嵌入了商品快照,若商家事后修改商品价格,订单里的金额该如何表现?这个问题在你们现在的 MySQL 方案里是怎么解的?