跳到正文

MongoDB Schema 设计模式 ​

一句话:MongoDB 的 Schema 不是先定表结构,而是按查询模式反推文档边界:用内嵌换一次读取,用引用换独立增长,代价分别是写放大/文档上限和额外关联。

适用版本:MongoDB 7.0 / 8.0。文档大小限制 16MB 来自 BSON 规范;跨文档一致性的事务边界见 06-事务。

为什么需要 ​

关系库默认先做规范化,再靠 JOIN 把数据拼起来;MongoDB 没有成熟的连接优化器,$lookup 能关联但不适合高频、大数据量。如果照搬关系模型,就会得到一堆引用和 N+1 查询;如果什么都内嵌,又会遇到数组无限增长、文档撞 16MB、更新重写整个父文档。

所以 MongoDB 的 Schema 设计顺序是反的:先看查询怎么读、怎么写,再决定哪些字段放在同一个文档里。文档边界就是事务边界和读写边界,这个决定错了,后面加索引和加机器都很难补救。

核心机制 ​

决策只看三件事 ​

判据倾向内嵌倾向引用
查询模式总是一起读,一次查询拿全经常单独查子对象
数量与增长1:1 或 1:少量 N,有上限1:大量 N,会持续追加
生命周期父子同生共死子对象独立创建、更新、删除
更新频率父子通常一起更新子对象被频繁单独更新
共享范围只属于一个父文档被多个父文档引用,需要单一事实来源

内嵌的收益是读一次拿全、单文档原子更新;代价是父文档变大、更新要重写整条文档。引用的收益是子对象独立增长和更新;代价是额外查询或 $lookup,跨文档一致性要靠事务或应用补偿。

三种关联模式 ​

1:1   通常内嵌;两边访问频率或更新频率差异大时用引用
1:N   N 在几十以内:内嵌数组
      N 在几千:子引用(父存最近 N 个 ID),全量另存
      N 在几万以上:父引用(子文档存父 ID)
M:N   优先独立关联集合;两边各存 ID 数组只适合关联数量很小的场景

六种常用模式 ​

模式场景做法
Bucket(分桶)IoT 传感器每分钟一条每小时一个文档,内嵌该小时的记录
Computed(预计算)商品页显示销量/评分写入时更新统计字段,读取直接返回
Extended Reference(扩展引用)订单频繁展示用户名订单内嵌 user_name 快照,避免每次 $lookup
Subset(子集)商品有数万评论,首页只显示 3 条商品文档内嵌最近 3 条,全量评论另存
Polymorphic(多态)不同支付方式字段不同同一集合,用 type 区分形态
Schema Versioning(版本化)结构经常演进文档加 schema_version,应用兼容多版本
javascript
// 内嵌只放展示快照;用户改手机号不影响历史订单
{ _id: 1001, userId: 1, userName: "Bob", items: [{ sku: "A", qty: 2 }] }

灰区怎么判 ​

  • 1:N 但 N 会增长:不要内嵌全量,用 Subset 只嵌最近 N 条,全量另存并引用。
  • 需要 JOIN 但不想每次 $lookup:用 Extended Reference 冗余少量高频字段,接受同步成本。
  • 多对多:优先独立关联集合;两边都存 ID 数组只适合关联数量很小且读多写少。
  • 大字段/大文件:不要内嵌,放 GridFS 或对象存储,文档只存引用和元数据。
  • 会被频繁单独更新的子对象:引用比内嵌好,否则每次改子对象都要重写父文档,写放大明显。

最小上手 ​

javascript
// mongosh 7.0/8.0:内嵌地址、引用用户,并验证外键索引
db.users.insertOne({ _id: 1, name: "Bob",
  addresses: [{ city: "上海", zip: "200000" }] })
db.orders.insertOne({ _id: 1001, userId: 1, userName: "Bob",
  items: [{ sku: "A", qty: 2 }], createdAt: new Date() })
db.orders.createIndex({ userId: 1 })
db.orders.find({ userId: 1 }).explain("executionStats")

预期 explain 出现 IXSCAN;没有索引时会退化成 COLLSCAN。内嵌数组可以用 db.users.findOne({ _id: 1 }, { addresses: 1 }) 一次取回,验证「一起读」是否真的只查一次。

生产动作 ​

  • 加 schema 校验:validator + $jsonSchema(3.2+),配合 validationLevel/validationAction;它约束新写入,不替代应用层校验。
  • 版本化演进:文档带 schema_version,读时兼容新旧两版,后台批量迁移;不要一次性全量 update 大集合。
  • 监控文档大小:db.collection.stats().avgObjSize、最大文档大小;接近 16MB 前就拆分。
  • 外键建索引:所有被 $lookup 或按 ID 查询的字段都要有索引,否则每次关联都是集合扫描。
  • 控制数组上限:日志、轨迹、评论这类持续追加的数据用 Bucket/Subset,不要无限 $push。
  • 事务边界:需要跨文档原子性时用事务(4.0+ 副本集,4.2+ 分片),但事务有额外开销;能靠单文档原子更新解决就不要拆成多文档。

边界 ​

  • 不是「无 Schema」:灵活是每条文档可以不同,字段约束仍要由应用或 $jsonSchema 承担。
  • 内嵌不适合无界数据:数组会撞 16MB,嵌套深度也受 BSON 100 层限制。
  • 大文件不要进文档:放 GridFS 或对象存储,文档只保留元数据和引用。
  • $lookup 不是关系库 JOIN:没有成熟的连接优化器,高频、大数据量关联应改模型。
  • 不要为了规范化而全部引用:多文档事务的成本通常高于适度冗余;设计目标是匹配查询,不是消除冗余。

讲清楚 ​

MongoDB 的 Schema 设计不是先画表,而是先问查询:这些数据是不是总一起读,数量会不会无限增长,生命周期是不是一致。总一起读、有上限、同生共死,就内嵌,换一次读取和单文档原子更新;否则就引用,换独立增长和独立更新。代价要认:内嵌会写放大、会撞 16MB,引用会多一次查询或 $lookup,跨文档还要事务。常见模式像 Subset、Extended Reference、Bucket,都是在「想内嵌但数据会长大」之间找折中。判断标准始终是查询模式和边界,不是范式。

把一门技术讲透到既懂也会用