跳到主要内容

文档模型与 Schema 设计

问题

MongoDB 的文档模型是什么?BSON 格式有哪些特点?如何进行 Schema 设计(嵌入 vs 引用)?

答案

BSON 文档格式

MongoDB 使用 BSON(Binary JSON)格式存储文档。BSON 是 JSON 的二进制编码扩展,提供了更多数据类型和高效的编解码能力。

JSON vs BSON

特性JSONBSON
格式文本二进制
数据类型6 种(string, number, boolean, null, array, object)20+ 种(新增 ObjectId, Date, Decimal128, Binary 等)
遍历需要解析整个文本支持前缀长度,快速跳过字段
大小通常更小略大(存储类型信息和长度前缀)
日期字符串表示原生 Date 类型(毫秒时间戳)
精确数值无(Number 是浮点)Decimal128(财务精确计算)

BSON 特有数据类型

{
_id: ObjectId("507f1f77bcf86cd799439011"), // 12字节唯一ID
name: "Alice", // String
age: 30, // 32-bit Integer
balance: NumberDecimal("199.99"), // Decimal128(精确小数)
createdAt: ISODate("2024-01-15T08:00:00Z"), // Date
avatar: BinData(0, "base64..."), // Binary Data
location: { // 嵌入文档
type: "Point",
coordinates: [116.4, 39.9]
},
tags: ["vip", "active"], // 数组
metadata: null // Null
}

ObjectId 结构

ObjectId 是 MongoDB 文档默认的主键类型,12 字节,保证全局唯一:

|  4 字节   |  5 字节   |  3 字节  |
| 时间戳 | 随机值 | 递增计数器 |
  • 时间戳:秒级 Unix 时间戳,自带时间排序能力
  • 随机值:包含机器+进程标识的随机值
  • 计数器:同一秒内递增,避免冲突
ObjectId 的隐藏能力

ObjectId 内含时间戳,可以直接提取创建时间:

ObjectId("507f1f77bcf86cd799439011").getTimestamp()
// → ISODate("2012-10-17T20:46:22Z")

因此很多场景下不需要额外的 createdAt 字段。

文档大小限制

限制项限制值
单文档最大16 MB
嵌套深度100 层
索引键最大1024 字节
集合名长度120 字节
命名空间<db>.<collection> < 120 字节
16 MB 限制

如果需要存储大文件(图片、视频等),使用 GridFS 将大文件自动分块(默认 255 KB/块)存储到两个集合中:fs.files(元数据)和 fs.chunks(数据块)。

Schema 设计:嵌入 vs 引用

这是 MongoDB 面试中 最核心的设计话题。关系型数据库追求范式化(消除冗余),MongoDB 则需要根据 数据访问模式 来决定数据结构。

嵌入(Embedding)— 反范式

将关联数据直接嵌入到父文档中:

// 订单文档 — 嵌入订单项
{
_id: ObjectId("..."),
orderId: "ORD-2024-001",
customer: { // 嵌入客户信息(冗余快照)
name: "Alice",
email: "alice@example.com"
},
items: [ // 嵌入订单项
{ productId: "P001", name: "键盘", price: 299, qty: 1 },
{ productId: "P002", name: "鼠标", price: 99, qty: 2 }
],
totalAmount: 497,
status: "paid",
createdAt: ISODate("2024-01-15T10:00:00Z")
}

引用(Referencing)— 范式化

通过 ID 引用其他集合的文档:

// 订单集合
{
_id: ObjectId("..."),
orderId: "ORD-2024-001",
customerId: ObjectId("6789..."), // 引用客户集合
itemIds: [ // 引用订单项集合
ObjectId("item1..."),
ObjectId("item2...")
],
totalAmount: 497,
status: "paid"
}

// 客户集合
{
_id: ObjectId("6789..."),
name: "Alice",
email: "alice@example.com"
}

嵌入 vs 引用选择原则

考量维度选择嵌入选择引用
关系基数一对一、一对少一对多(无界)、多对多
数据访问总是一起读取经常独立访问
更新频率嵌入数据不常变关联数据频繁更新
数据量嵌入后 < 16MB嵌入会导致文档过大
一致性可以接受冗余需要单一数据源
查询复杂度一次查询获取需要 $lookup 或多次查询
经典口诀
  • 「一起读的数据,放在一起存」
  • 数据冗余不是罪,性能提升是目的
  • 但无界增长的数组一定要引用

常见 Schema 设计模式

1. 子集模式(Subset Pattern)

热数据嵌入,冷数据引用。适合商品评论场景 —— 只嵌入最新 N 条评论:

// 商品文档 — 嵌入最新 10 条评论
{
_id: ObjectId("..."),
name: "无线键盘",
price: 299,
// 只嵌入最近 10 条(热数据)
recentReviews: [
{ userId: "u1", text: "手感很好", rating: 5, date: ISODate("...") },
// ... 最多 10 条
],
reviewCount: 1523
}

// 全量评论集合(冷数据)
{
_id: ObjectId("..."),
productId: ObjectId("..."),
userId: "u1",
text: "手感很好",
rating: 5,
date: ISODate("...")
}

2. 扩展引用模式(Extended Reference)

引用时冗余常用字段,避免 $lookup

// 订单文档 — 冗余客户的常用字段
{
orderId: "ORD-001",
customer: {
_id: ObjectId("6789..."), // 保留引用
name: "Alice", // 冗余常用字段
phone: "13800138000" // 冗余常用字段
}
// 不冗余 email、address 等详细信息
}

3. 桶模式(Bucket Pattern)

适合时间序列数据,将一段时间的数据聚合到一个文档中:

// 每小时一个文档,存该小时内的所有传感器读数
{
sensorId: "sensor-01",
date: ISODate("2024-01-15T10:00:00Z"),
// 一小时内的读数都放在一个数组里
readings: [
{ ts: ISODate("...T10:01:00Z"), temp: 22.3, humidity: 65 },
{ ts: ISODate("...T10:02:00Z"), temp: 22.4, humidity: 64 },
// ... 一小时内的数据点
],
count: 60,
avg_temp: 22.35
}

优势:减少文档数量,减少索引大小,提升范围查询效率。

4. 多态模式(Polymorphic Pattern)

同一集合存储不同类型但有共同字段的文档:

// 产品集合 — 不同类型的产品共用一个集合
{ type: "book", title: "MongoDB 实战", author: "Kyle", isbn: "978-..." }
{ type: "movie", title: "星际穿越", director: "Nolan", duration: 169 }
{ type: "music", title: "夜曲", artist: "周杰伦", album: "十一月的萧邦" }

适用场景:内容管理系统、日志平台(不同类型的事件)。

反模式:必须避免的设计

常见反模式
  1. 无界数组增长:不断 $push 到数组中,最终撑破 16MB
  2. 过度嵌套:超过 3-4 层嵌套,查询和更新都很困难
  3. 大量分散的小文档:完全按关系型范式设计,导致大量 $lookup
  4. 不考虑查询模式的 Schema:先设计 Schema 再写查询(应该反过来)

常见面试问题

Q1: MongoDB 和 MySQL 怎么选?各自适合什么场景?

答案

维度MongoDBMySQL
数据模型文档(灵活 Schema)关系(固定 Schema)
事务4.0+ 支持多文档事务,性能开销较大原生强事务,ACID 完整
扩展原生分片,水平扩展简单水平扩展需要中间件
JOIN$lookup(性能一般)原生 JOIN(优化器成熟)
Schema 变更无需 DDL,直接写入新字段需要 ALTER TABLE,大表很慢

选 MongoDB

  • 快速迭代的早期项目,Schema 频繁变化
  • 半结构化/非结构化数据(日志、IoT、内容)
  • 需要水平扩展处理大量写入
  • 地理空间数据查询

选 MySQL

  • 强事务需求(金融、支付、订单)
  • 复杂多表关联查询
  • 成熟的运维生态和 DBA 支持
  • 结构化数据,Schema 稳定

Q2: 什么情况下嵌入,什么情况下引用?

答案

核心原则是 「数据总是一起读取就嵌入,独立访问就引用」

  • 嵌入:一对一(用户-地址)、一对少且有界(订单-订单项,通常 < 100)、数据不常独立更新、查询时总是一起返回
  • 引用:一对多且无界增长(用户-评论)、多对多(学生-课程)、关联数据独立更新频繁、嵌入会导致文档过大

Q3: 什么是 ObjectId?它是如何保证唯一性的?

答案

ObjectId 是 12 字节的唯一标识符:

  • 前 4 字节:Unix 时间戳(秒级),使 ObjectId 天然按时间排序
  • 中间 5 字节:随机值(每个进程启动时生成),区分不同机器和进程
  • 后 3 字节:递增计数器(同一进程同一秒内递增),同秒内也不会冲突

这种设计使得 ObjectId 在 无需中心节点协调 的情况下保证全局唯一,非常适合分布式系统。

Q4: MongoDB 的 16MB 文档限制怎么处理?

答案

  1. Schema 重设计:如果接近限制,说明设计可能有问题(通常是无界数组)。应改用引用模式
  2. GridFS:存储大文件(图片、视频等),自动分块为 255KB 的 chunk
  3. 桶模式:时序数据按时间段分桶,控制单文档大小
  4. 子集模式:只嵌入部分热数据(如最新 N 条评论)

Q5: MongoDB 支持事务吗?有什么限制?

答案

MongoDB 4.0+ 支持 多文档 ACID 事务,4.2+ 支持 分片集群的分布式事务

const session = client.startSession();
try {
session.startTransaction();
await orders.insertOne({ ... }, { session });
await inventory.updateOne(
{ productId: "P001" },
{ $inc: { stock: -1 } },
{ session }
);
await session.commitTransaction();
} catch (e) {
await session.abortTransaction();
} finally {
session.endSession();
}

限制

  • 事务有 60 秒的默认超时时间
  • 事务中操作的数据量有限制(16MB oplog 条目)
  • 分布式事务性能开销比单文档操作高得多
  • MongoDB 官方建议:如果你的 Schema 设计合理,大多数操作应该是单文档原子操作,很少需要多文档事务

Q6: MongoDB 的 Schema 设计和关系型数据库有什么本质区别?

答案

关系型数据库是 数据驱动设计(先设计 Schema,再写查询),MongoDB 是 查询驱动设计(先确定查询模式,再设计 Schema):

  • 关系型:消除冗余 → 范式化 → JOIN 查询 → 读时组装
  • MongoDB:根据查询模式 → 适度冗余 → 嵌入/引用 → 读时直取

关系型追求「数据不重复」,MongoDB 追求「查询最高效」。MongoDB 的设计核心是:把一起访问的数据放在一起存储,以空间换时间。

相关链接