文档模型与 Schema 设计
问题
MongoDB 的文档模型是什么?BSON 格式有哪些特点?如何进行 Schema 设计(嵌入 vs 引用)?
答案
BSON 文档格式
MongoDB 使用 BSON(Binary JSON)格式存储文档。BSON 是 JSON 的二进制编码扩展,提供了更多数据类型和高效的编解码能力。
JSON vs BSON
| 特性 | JSON | BSON |
|---|---|---|
| 格式 | 文本 | 二进制 |
| 数据类型 | 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("507f1f77bcf86cd799439011").getTimestamp()
// → ISODate("2012-10-17T20:46:22Z")
因此很多场景下不需要额外的 createdAt 字段。
文档大小限制
| 限制项 | 限制值 |
|---|---|
| 单文档最大 | 16 MB |
| 嵌套深度 | 100 层 |
| 索引键最大 | 1024 字节 |
| 集合名长度 | 120 字节 |
| 命名空间 | <db>.<collection> < 120 字节 |
如果需要存储大文件(图片、视频等),使用 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: "十一月的萧邦" }
适用场景:内容管理系统、日志平台(不同类型的事件)。
反模式:必须避免的设计
- 无界数组增长:不断
$push到数组中,最终撑破 16MB - 过度嵌套:超过 3-4 层嵌套,查询和更新都很困难
- 大量分散的小文档:完全按关系型范式设计,导致大量
$lookup - 不考虑查询模式的 Schema:先设计 Schema 再写查询(应该反过来)
常见面试问题
Q1: MongoDB 和 MySQL 怎么选?各自适合什么场景?
答案:
| 维度 | MongoDB | MySQL |
|---|---|---|
| 数据模型 | 文档(灵活 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 文档限制怎么处理?
答案:
- Schema 重设计:如果接近限制,说明设计可能有问题(通常是无界数组)。应改用引用模式
- GridFS:存储大文件(图片、视频等),自动分块为 255KB 的 chunk
- 桶模式:时序数据按时间段分桶,控制单文档大小
- 子集模式:只嵌入部分热数据(如最新 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 的设计核心是:把一起访问的数据放在一起存储,以空间换时间。