主题
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,都是在「想内嵌但数据会长大」之间找折中。判断标准始终是查询模式和边界,不是范式。