MongoDB: 文档与BSON:MongoDB的数据基石
最后更新:2026-08-26
BSON 是 MongoDB 的数据格式——它扩展了 JSON 的能力,支持日期、二进制、Decimal128 等原生类型。
本课程深入理解 BSON 数据格式、ObjectId 内部结构、字段类型体系,掌握文档设计的最佳实践。
1. 你将学到
- BSON 数据格式与 JSON 的本质区别
- MongoDB 文档的内部结构(_id、字段、值)
- ObjectId 的组成、时间戳提取、唯一性保证
- 12 种 BSON 数据类型(String/Number/Date/Array/Object/ObjectId 等)
- 字段命名规范(驼峰 vs 蛇形 vs kebab-case)
- 文档大小限制(16 MB)的设计哲学
- 嵌入式文档 vs 引用的选择策略
2. 一个全栈工程师的真实故事
(1) 痛点:JSON 存进 MongoDB 后日期变成字符串
Charlie 是一名 Node.js 全栈工程师,正在迁移 MySQL 数据到 MongoDB:
"我把 MySQL 的订单数据转成 JSON 存进 MongoDB,发现所有日期都变成了字符串
new Date()解析不了,金额精度丢失(0.1+0.2 ≠ 0.3),二进制的头像图片完全存不了。"
他用 JSON.stringify() 序列化订单数据,丢失了类型信息:
// ❌ 错误:JSON.stringify 丢失类型
const order = {
createdAt: new Date(), // Date 对象
total: new Number('0.30'), // Decimal128 应该用更精确类型
avatar: Buffer.from('...'), // 二进制头像
_id: new ObjectId() // MongoDB 期望的 ObjectId
};
const json = JSON.stringify(order);
// {"createdAt":"2026-07-01T...","total":0.3,"avatar":"...","_id":"..."}
// ^^^^^^^^^^^^^^^^ 字符串 ^ 浮点数(精度丢失) ^ 字符串(无法还原)
(2) BSON 的解法
MongoDB 直接以 BSON(Binary JSON)格式存储数据,保留所有类型信息。
// ✅ 正确:mongoose 直接操作 BSON 类型
const OrderSchema = new mongoose.Schema({
createdAt: { type: Date, default: Date.now }, // BSON Date
total: { type: mongoose.Schema.Types.Decimal128 }, // BSON Decimal128(精确)
avatar: { type: Buffer }, // BSON Binary
_id: { type: mongoose.Schema.Types.ObjectId, auto: true } // BSON ObjectId
});
const order = await Order.create({
total: mongoose.Types.Decimal128.fromString('0.30'),
// TODO: 替换为实际头像文件路径
avatar: fs.readFileSync('avatar.jpg')
});
(3) 收益
| 维度 | JSON | BSON |
|---|---|---|
| 日期类型 | 字符串(需手动解析) | 原生 Date(毫秒精度) |
| 数值精度 | 浮点(精度丢失) | Decimal128(精确 34 位) |
| 二进制数据 | 不支持 | 原生 Binary |
| 字段顺序 | 无序 | 有序(关键!) |
| 大小开销 | 较紧凑 | 略大(多 5-15%) |
3. BSON 数据格式
概念说明:BSON(Binary JSON)是 MongoDB 专用的二进制序列化格式,是 JSON 的超集。JSON 只有 6 种数据类型(string/number/boolean/null/array/object),而 BSON 支持 12+ 种类型,包括 Date、Binary、ObjectId、Decimal128 等数据库必需的类型。BSON 的核心优势是:类型丰富、字段有序、解析极快。
工作原理:BSON 文档以二进制格式存储,每个文档以 4 字节长度头开始,后跟键值对序列,最后以 0x00 结束。与 JSON 的文本解析不同,BSON 的长度头允许快速跳过不需要的字段(类似二进制协议的"定长头"设计),解析性能比 JSON 快 3-5 倍。代价是空间开销增加 5-15%(存储类型信息和长度信息)。
graph TB
subgraph "BSON 文档内部结构"
A[4 字节<br/>文档总长度] --> B[类型码 1B<br/>+ 字段名<br/>+ 值]
B --> C[类型码 1B<br/>+ 字段名<br/>+ 值]
C --> D[...更多键值对...]
D --> E[0x00<br/>结束标记]
end
style A fill:#cce5ff
| 维度 | JSON | BSON |
|---|---|---|
| 类型 | 文本格式 | 二进制格式 |
| 可读性 | ✅ 人类可读 | ❌ 二进制 |
| 性能 | 解析较慢 | 解析极快(3-5x) |
| 类型丰富 | 6 种 | 12+ 种 |
| 字段顺序 | 无序 | 有序 |
| 空间 | 较紧凑 | 多 5-15% |
(1) 什么是 BSON?
BSON(Binary JSON)是 MongoDB 使用的二进制序列化格式,特点:
graph LR
A[JavaScript 对象] -->|JSON.stringify| B[JSON 文本]
A -->|BSON 序列化| C[BSON 二进制]
B --> D[传输 / 存储]
C --> D
style C fill:#d4edda
| 维度 | JSON | BSON |
|---|---|---|
| 类型 | 文本格式 | 二进制格式 |
| 可读性 | ✅ 人类可读 | ❌ 二进制 |
| 性能 | 解析较慢 | 解析极快 |
| 类型丰富 | 6 种 | 12+ 种 |
| 字段顺序 | 无序 | 有序 |
| 空间 | 较紧凑 | 多 5-15% |
(2) BSON 文档结构
要点解析:
- BSON 保留字段插入顺序——这对 MongoDB 的索引和查询优化至关重要
- 每个字段前有 1 字节类型码,使 BSON 能区分 Date 和 String(JSON 不能)
- 嵌套文档和数组在 BSON 中递归存储,最大嵌套深度 100 层
_id字段始终在文档第一个位置,优化查询性能
// 一个 BSON 文档的内部表示(简化)
{
_id: ObjectId("507f1f77bcf86cd799439011"), // 12 字节 ObjectId
name: "Alice", // String(UTF-8)
age: 28, // Int32
balance: Decimal128("12345.6789"), // Decimal128(高精度)
joinedAt: ISODate("2026-07-01T10:00:00Z"), // Date(64-bit 整数)
isActive: true, // Boolean
hobbies: ["reading", "coding", "hiking"], // Array
address: { // Embedded Document
city: "Tokyo",
country: "Japan"
},
profile: null, // Null
avatar: BinData(0, "..."), // Binary
// 字段顺序:BSON 保留字段插入顺序(JSON 不保证)
}
▶ 示例 1:mongosh 中查看 BSON 详情
// 插入一个文档
db.users.insertOne({
name: "Alice",
age: 28,
joinedAt: new Date(),
balance: NumberDecimal("12345.6789"),
address: { city: "Tokyo", country: "Japan" }
});
// 查看 BSON 详细信息(使用 bsonSon 函数)
db.users.findOne({ name: "Alice" });
// {
// _id: ObjectId('507f1f77bcf86cd799439011'),
// name: 'Alice',
// age: 28,
// joinedAt: ISODate('2026-07-01T10:00:00.000Z'),
// balance: NumberDecimal('12345.6789'),
// address: { city: 'Tokyo', country: 'Japan' }
// }
// 查看字段类型
const doc = db.users.findOne({ name: "Alice" });
print(typeof doc.age); // number
print(doc.joinedAt instanceof Date); // true
输出:
// 执行成功
4. ObjectId 主键机制
概念说明:ObjectId 是 MongoDB 默认的主键类型,是一个 12 字节(96 位)的二进制值。与传统数据库的自增整数主键不同,ObjectId 采用分布式设计——由时间戳 + 随机值 + 计数器组成,无需中心化协调即可保证全局唯一性。ObjectId 的另一大优势是天然包含创建时间,可直接提取而不需额外字段。
工作原理:ObjectId 的 12 字节分为三段:前 4 字节是 Unix 时间戳(秒级精度),中间 5 字节是随机值(首次生成时由机器 ID + 进程 ID 决定,之后保持不变),后 3 字节是递增计数器(同一秒内从随机起始值递增)。这种设计使得同一秒内同一进程可生成约 1677 万个不重复 ObjectId。
graph LR
A[ObjectId 12 字节] --> B[4 字节时间戳<br/>秒级精度]
A --> C[5 字节随机值<br/>机器/进程唯一]
A --> D[3 字节递增计数器<br/>同一秒内唯一]
style A fill:#cce5ff
| 段 | 长度 | 内容 | 用途 |
|---|---|---|---|
| Timestamp | 4 字节 | Unix 时间戳(秒) | 可提取创建时间 |
| Random | 5 字节 | 机器 ID + 进程 ID | 跨进程唯一 |
| Counter | 3 字节 | 递增计数器 | 同一秒内唯一 |
| _id 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 自动 ObjectId | 分布式唯一、含时间戳、天然有序 | 12 字节较大 | 通用(默认) |
| 字符串业务键 | 语义明确、可读性好 | 需手动保证唯一 | 订单号、SKU |
| 自增整数 | 紧凑、可读 | 需计数器集合、不适合分片 | 遗留系统 |
| UUID | 全球唯一 | 16 字节、无序 | 跨系统唯一 |
(1) 什么是 ObjectId?
ObjectId 是 MongoDB 默认的主键类型,12 字节(96 位)的二进制值:
graph LR
A[ObjectId 12 字节] --> B[4 字节时间戳<br/>秒级精度]
A --> C[5 字节随机值<br/>机器/进程唯一]
A --> D[3 字节递增计数器<br/>同一秒内唯一]
style A fill:#cce5ff
| 段 | 长度 | 内容 | 用途 |
|---|---|---|---|
| Timestamp | 4 字节 | Unix 时间戳(秒) | 可提取创建时间 |
| Random | 5 字节 | 机器 ID + 进程 ID | 跨进程唯一 |
| Counter | 3 字节 | 递增计数器 | 同一秒内唯一 |
(2) ObjectId 优势
要点解析:
- ObjectId 的时间戳部分使其天然按插入时间排序——无需额外
createdAt索引即可按时间范围查询 - 5 字节随机值在进程启动时生成并缓存,保证跨进程唯一性(2^40 ≈ 1 万亿种可能)
- 3 字节计数器在同一秒内递增,每秒可生成 2^24 ≈ 1677 万个不重复 ID
getTimestamp()方法可直接从 ObjectId 提取创建时间,无需额外查询
// 在 mongosh 中创建 ObjectId
const id1 = ObjectId(); // 自动生成
const id2 = ObjectId("507f1f77bcf86cd799439011"); // 从字符串生成
// 提取创建时间(关键优势!)
id2.getTimestamp();
// ISODate("2012-10-17T20:46:11.000Z")
// 在 Node.js 中使用 mongoose
const mongoose = require('mongoose');
const id = new mongoose.Types.ObjectId();
console.log(id.getTimestamp()); // 2026-07-01T10:00:00.000Z
(3) ObjectId 唯一性保证
graph TB
A[客户端 A<br/>同一秒生成 ID] --> A1[time=1000<br/>random=ABC<br/>counter=1]
A --> A2[time=1000<br/>random=ABC<br/>counter=2]
A --> A3[time=1000<br/>random=ABC<br/>counter=3]
B[客户端 B<br/>同一秒生成 ID] --> B1[time=1000<br/>random=DEF<br/>counter=1]
B --> B2[time=1000<br/>random=DEF<br/>counter=2]
style A1 fill:#d4edda
style B1 fill:#d4edda
▶ 示例 2:ObjectId 时间戳提取
// === 在 mongosh 中 ===
const products = db.products.find().toArray();
products.forEach(p => {
print(`Product ${p._id} created at ${p._id.getTimestamp()}`);
});
// === 按 ObjectId 时间范围查询 ===
const startOfDay = ObjectId.createFromTime(
Math.floor(new Date('2026-07-01').getTime() / 1000)
);
const endOfDay = ObjectId.createFromTime(
Math.floor(new Date('2026-07-02').getTime() / 1000)
);
db.products.find({
_id: { $gte: startOfDay, $lt: endOfDay }
});
// === Node.js / mongoose ===
const Product = mongoose.model('Product', productSchema);
const products = await Product.find({
_id: {
$gte: mongoose.Types.ObjectId.createFromTime(
Math.floor(Date.parse('2026-07-01') / 1000)
),
$lt: mongoose.Types.ObjectId.createFromTime(
Math.floor(Date.parse('2026-07-02') / 1000)
)
}
});
输出:
// mongoose 操作成功执行
// 数据库查询/更新结果
5. BSON 数据类型
概念说明:BSON 支持 12+ 种数据类型,远超 JSON 的 6 种。其中最关键的区别是数值类型——JSON 只有一种 Number(IEEE 754 双精度浮点),而 BSON 提供 Double、Int32、Int64(Long)、Decimal128 四种数值类型。选错数值类型会导致精度丢失(如金额计算中 0.1 + 0.2 ≠ 0.3)。
使用场景:金融金额必须使用 Decimal128(精确 34 位十进制),计数器使用 Int32,大整数 ID 使用 Long,科学计算和统计使用 Double。Date 类型在 BSON 中存储为 64 位毫秒时间戳,与 JSON 的字符串日期有本质区别。
| 类型 | 类型码 | 示例 | 用途 |
|---|---|---|---|
| Double | 1 | 3.14, 0.1+0.2 |
浮点数(默认 Number) |
| String | 2 | "Alice" |
UTF-8 字符串 |
| Object | 3 | { key: "value" } |
嵌套文档 |
| Array | 4 | [1, 2, 3] |
数组 |
| Binary data | 5 | BinData(0, "...") |
二进制数据(图片、文件) |
| Undefined | 6 | undefined |
不推荐使用 |
| ObjectId | 7 | ObjectId("...") |
默认主键 |
| Boolean | 8 | true, false |
布尔值 |
| Date | 9 | ISODate("...") |
日期时间 |
| Null | 10 | null |
空值 |
| Regular Expression | 11 | /pattern/i |
正则表达式 |
| 32-bit Integer | 16 | NumberInt(123) |
32 位整数 |
| 64-bit Integer | 18 | NumberLong(123) |
64 位整数(BigInt) |
| Decimal128 | 19 | NumberDecimal("0.30") |
高精度小数(金融) |
| MinKey/MaxKey | -1 / 127 | MinKey(), MaxKey() |
比较边界 |
(1) 12 种 BSON 数据类型
(2) 数值类型选择
概念说明:数值类型选择是 BSON 数据类型中最关键的决策。JSON 只有一种 Number 类型(双精度浮点),在金融计算中会出现经典的精度丢失问题:0.1 + 0.2 = 0.30000000000000004。BSON 的 Decimal128 类型解决了这个问题,提供 34 位十进制精度,适合金额、税率等精确计算场景。
| 数值类型 | 精度 | 范围 | 存储大小 | 适用场景 |
|---|---|---|---|---|
| Double | 15-17 位有效数字 | ±1.7×10^308 | 8 字节 | 科学计算、统计、图形 |
| Int32 | 精确 | -2^31 ~ 2^31-1 | 4 字节 | 常规计数、库存 |
| Int64/Long | 精确 | -2^63 ~ 2^63-1 | 8 字节 | 大整数ID、时间戳 |
| Decimal128 | 34 位十进制 | ±10^6145 | 16 字节 | 金融金额(推荐) |
graph TB
A[MongoDB 数值类型] --> B[Double<br/>默认]
A --> C[Int32<br/>32 位整数]
A --> D[Long<br/>64 位整数]
A --> E[Decimal128<br/>34 位十进制]
B --> B1[适用:科学计算、统计]
C --> C1[适用:常规计数]
D --> D1[适用:大整数 ID]
E --> E1[适用:金融、金额]
style E fill:#d4edda
▶ 示例 3:数值类型实战
// === Double(默认)===
db.products.insertOne({
sku: "PHONE-001",
price: 599.99 // 存储为 Double
});
// === Decimal128(金融推荐)===
db.accounts.insertOne({
balance: NumberDecimal("1234567890.12345678901234567890")
// 精确存储,无精度丢失
});
// === Int32(计数)===
db.products.insertOne({
sku: "BOOK-001",
stock: NumberInt(150)
});
// === Long(大整数 ID)===
db.orders.insertOne({
_id: NumberLong("1700000000000") // 时间戳作为 ID
});
// === JavaScript 中处理 Decimal128 ===
const account = await Account.findOne({});
console.log(account.balance.toString()); // "1234567890.12345678901234567890"
// === Number 精度陷阱 ===
0.1 + 0.2; // 0.30000000000000004 ❌
NumberDecimal("0.1") + NumberDecimal("0.2"); // NumberDecimal("0.3") ✅
输出:
// 执行成功
6. 字段命名规范
概念说明:字段命名看似是小事,但在团队协作和长期维护中影响巨大。MongoDB 的字段命名有三个硬性限制(不能以 $ 开头、不能含 .、不能为空字符串),以及多个软性建议(推荐 camelCase、避免保留字、限制长度)。统一的命名风格是数据库可维护性的基础。
使用场景:JavaScript/TypeScript 生态推荐 camelCase(与代码变量名一致),Python/SQL 生态推荐 snake_case(与数据库列名一致)。在 MongoDB + mongoose 技术栈中,推荐 camelCase 作为数据库字段命名,通过 mongoose 的 toJSON 转换在 API 层输出 snake_case。
| 命名风格 | 示例 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|---|
| camelCase | firstName |
JS/TS 原生支持 | SQL 数据库不友好 | ⭐⭐⭐(推荐) |
| snake_case | first_name |
SQL/Python 友好 | JS 中需引号 | ⭐⭐ |
| kebab-case | first-name |
URL 友好 | MongoDB 中需引号 | ⭐ |
(1) MongoDB 字段命名规则
✅ 合法的命名:
- 字段名不能以
$开头(保留字) - 字段名不能包含
.(点表示法保留) - 字段名不能为空字符串
""
// ✅ 合法的字段名
db.users.insertOne({
firstName: "Alice", // 驼峰式
first_name: "Alice", // 蛇形式
"first-name": "Alice", // kebab-case(需引号)
"user 1": "Alice", // 含空格(需引号)
age28: 28 // 数字结尾
});
// ❌ 非法的字段名
db.users.insertOne({
$name: "Alice", // 以 $ 开头 ❌
"user.name": "Alice", // 含 . ❌
"": "Alice" // 空字符串 ❌
});
(2) 三种命名风格对比
| 风格 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| camelCase | firstName |
JS/TS 原生支持 | SQL 数据库不友好 |
| snake_case | first_name |
SQL/Python 友好 | JS 中需引号 |
| kebab-case | first-name |
URL 友好 | MongoDB 中需引号 |
(3) 推荐:camelCase + MongoDB 官方风格
// ✅ 推荐风格:camelCase
db.users.insertOne({
firstName: "Alice",
lastName: "Smith",
emailAddress: "alice@example.com",
dateOfBirth: new Date("1998-01-01"),
isActive: true,
totalSpent: NumberDecimal("1234.56")
});
▶ 示例 4:mongoose Schema 命名规范
// mongoose 自动将驼峰转为数据库字段
const UserSchema = new mongoose.Schema({
firstName: { type: String, required: true }, // 数据库字段:firstName
emailAddress: { type: String, required: true }, // 数据库字段:emailAddress
createdAt: { type: Date, default: Date.now }, // 数据库字段:createdAt
isActive: { type: Boolean, default: true } // 数据库字段:isActive
});
// 通过 toJSON 转换下划线命名(API 返回时)
UserSchema.set('toJSON', {
virtuals: true,
versionKey: false,
transform: (doc, ret) => {
ret.first_name = ret.firstName;
delete ret.firstName;
return ret;
}
});
输出:
// mongoose 操作成功执行
// 数据库查询/更新结果
7. 文档大小限制
概念说明:MongoDB 单文档最大 16 MB,最大嵌套深度 100 层。这个限制是 MongoDB 的核心设计哲学——鼓励将相关数据嵌入同一文档(避免 JOIN),但不鼓励存储巨型文档。16 MB 的限制使 MongoDB 可以在内存中高效处理单个文档,保证查询和更新的响应时间。
工作原理:16 MB 限制的底层原因是 MongoDB 的 WiredTiger 存储引擎在修改文档时采用"原地更新"策略——如果更新后文档变大且原位置空间不足,需要将文档移动到新位置,这会触发索引更新(所有指向该文档的索引条目都需要更新)。文档越大,移动代价越高。因此 MongoDB 选择 16 MB 作为平衡点。
| 维度 | 限制 | 原因 |
|---|---|---|
| 单文档大小 | 16 MB | BSON 文档最大尺寸 |
| 嵌套深度 | 100 层(默认) | 防止栈溢出 |
| 字段名长度 | 255 字节 | UTF-8 编码 |
| 索引数量 | 64 个/集合 | 索引元数据大小 |
| 单集合索引键总长 | 1024 字节 | 索引效率 |
| 超限场景 | 解决方案 | 说明 |
|---|---|---|
| 大文件(图片/视频) | GridFS | 分块存储,每块 255KB |
| 超长文本 | Elasticsearch + 引用 | 文档存 ID,ES 存全文 |
| 数组过大(评论列表) | 拆分为独立集合 | comments 集合 + 引用 |
| 嵌套层级过深 | 扁平化设计 | 减少嵌套层级 |
(1) 16 MB 限制
(2) 为什么是 16 MB?
MongoDB 设计哲学:避免存储巨型文档:
- ✅ 一次查询返回完整文档(无 JOIN)
- ✅ 文档传输效率高(适合网络传输)
- ❌ 不适合存储大型二进制(用 GridFS)
- ❌ 不适合存储超长文本(用 Elasticsearch)
(3) 大文档场景解决方案
graph TB
A[大文档场景] --> B[二进制文件<br/>图片/视频]
A --> C[长文本<br/>文章/日志]
A --> D[数组过大<br/>评论列表]
B --> E[GridFS<br/>分块存储]
C --> F[文本搜索<br/>Elasticsearch]
D --> G[拆分集合<br/>comments 集合]
style E fill:#d4edda
style F fill:#d4edda
style G fill:#d4edda
▶ 示例 5:GridFS 存储大文件
// === 存储大文件(>16MB)===
const mongoose = require('mongoose');
const Grid = require('gridfs-stream');
const fs = require('fs');
const conn = mongoose.connection;
let gfs;
conn.once('open', () => {
gfs = Grid(conn.db, mongoose.mongo);
gfs.collection('uploads');
});
// 上传文件
const writestream = gfs.createWriteStream({
filename: 'large-video.mp4',
content_type: 'video/mp4'
});
fs.createReadStream('./local-video.mp4').pipe(writestream);
writestream.on('close', (file) => {
console.log(`File stored: ${file._id}`);
});
// 下载文件
const readstream = gfs.createReadStream({
_id: ObjectId('507f1f77bcf86cd799439011')
});
readstream.pipe(fs.createWriteStream('./downloaded-video.mp4'));
输出:
// mongoose 操作成功执行
// 数据库查询/更新结果
8. 嵌入式文档 vs 引用
概念说明:MongoDB 文档关系建模有两大策略——嵌入式(Embed)和引用式(Reference)。嵌入式将关联数据直接嵌在父文档中,一次查询即可获取全部数据;引用式将关联数据存储在独立集合中,通过 ObjectId 引用,需要 $lookup 或应用层多次查询。两种策略的选择是 MongoDB 数据建模最核心的决策。
工作原理:嵌入式文档与父文档存储在同一个 BSON 文档中,共享同一生命周期——更新父文档时嵌入文档也被重写,查询父文档时嵌入文档一并返回。引用式文档是独立的 BSON 文档,有自己的 _id 和生命周期,更新互不影响,但查询需要额外的关联操作。
graph TB
A[文档关系建模] --> B{数据特征}
B -->|1:1 关系<br/>数据量小<br/>经常一起读| C[嵌入式 ✅<br/>一次查询获取]
B -->|1:N 关系<br/>N 较小<br/>很少单独查| D[嵌入式 ✅<br/>嵌入数组]
B -->|1:N 关系<br/>N 较大<br/>需单独查| E[引用式 ✅<br/>独立集合]
B -->|N:N 关系| F[引用式 ✅<br/>双向 ID 数组]
B -->|频繁更新子文档| G[引用式 ✅<br/>避免整个文档重写]
style C fill:#d4edda
style D fill:#d4edda
style E fill:#d4edda
| 场景 | 推荐 | 原因 |
|---|---|---|
| 1:1 关系(用户-地址) | 嵌入(除非地址经常变) | 一次查询拿全部 |
| 1:N 关系(用户-订单) | 看 N 的数量: 小 → 嵌入;大 → 引用 |
文档大小限制 |
| N:N 关系(用户-角色) | 引用(双向 ID 数组) | 关系复杂 |
| 频繁更新子文档 | 引用 | 避免整个文档重写 |
| 需要单独查询子文档 | 引用 | 独立查询性能 |
(1) 两种关系建模策略
graph TB
subgraph "嵌入式文档(Embed)"
A1[users 集合] --> A2[文档 1<br/>address: {<br/> city: Tokyo<br/> country: Japan<br/>}]
end
subgraph "引用式文档(Reference)"
B1[users 集合] --> B2[文档 1<br/>address_id: ObjectId]
B3[addresses 集合] --> B4[文档 1<br/>city: Tokyo]
B2 -.->|查询| B3
end
(2) 选择策略
| 场景 | 推荐 | 原因 |
|---|---|---|
| 1:1 关系(用户-地址) | 嵌入(除非地址经常变) | 一次查询拿全部 |
| 1:N 关系(用户-订单) | 看 N 的数量: 小 → 嵌入;大 → 引用 |
文档大小限制 |
| N:N 关系(用户-角色) | 引用(双向 ID 数组) | 关系复杂 |
| 频繁更新子文档 | 引用 | 避免整个文档重写 |
| 需要单独查询子文档 | 引用 | 独立查询性能 |
(3) 嵌入式文档示例
// === 嵌入式:用户 + 多地址 ===
db.users.insertOne({
_id: ObjectId("507f1f77bcf86cd799439011"),
name: "Alice",
email: "alice@example.com",
addresses: [ // 嵌入数组
{
type: "home",
street: "123 Main St",
city: "Tokyo",
country: "Japan",
zip: "100-0001"
},
{
type: "work",
street: "456 Office Rd",
city: "Tokyo",
country: "Japan",
zip: "100-0002"
}
]
});
// === 查询:住在 Tokyo 的用户 ===
db.users.find({ "addresses.city": "Tokyo" });
▶ 示例 6:引用式文档示例
// === 引用式:用户 + 订单(多对一) ===
// users 集合
db.users.insertOne({
_id: ObjectId("507f1f77bcf86cd799439011"),
name: "Alice",
email: "alice@example.com"
});
// orders 集合
db.orders.insertMany([
{
_id: ObjectId("507f1f77bcf86cd799439012"),
user_id: ObjectId("507f1f77bcf86cd799439011"), // 引用
items: ["PHONE-001", "CASE-002"],
total: NumberDecimal("649.98"),
createdAt: new Date()
},
{
_id: ObjectId("507f1f77bcf86cd799439013"),
user_id: ObjectId("507f1f77bcf86cd799439011"), // 引用
items: ["LAPTOP-001"],
total: NumberDecimal("1299.99"),
createdAt: new Date()
}
]);
// === 使用 $lookup 关联查询(类似 SQL JOIN) ===
db.users.aggregate([
{ $match: { name: "Alice" } },
{ $lookup: {
from: "orders",
localField: "_id",
foreignField: "user_id",
as: "orders"
}}
]);
输出:
// 执行成功
9. 综合实战:电商用户文档设计
(1) 场景需求
设计一个电商平台的用户文档,需求:
- 用户基本信息(姓名、邮箱、注册时间)
- 多个收货地址(嵌入式)
- 偏好设置(语言、货币、通知)
- 统计信息(订单总数、消费总额)
- 关注列表(引用其他用户)
- 头像(GridFS 引用)
(2) 文档设计
// === 综合用户文档 ===
db.users.insertOne({
_id: ObjectId("507f1f77bcf86cd799439011"),
// === 基本信息 ===
email: "alice@example.com",
username: "alice_chen",
displayName: "Alice Chen",
phone: "+81-90-1234-5678",
// === 认证 ===
passwordHash: "$2b$10$...", // bcrypt 哈希(不入明文)
emailVerified: true,
twoFactorEnabled: false,
// === 偏好(嵌套文档)===
preferences: {
language: "ja",
currency: "JPY",
timezone: "Asia/Tokyo",
notifications: {
email: true,
sms: false,
push: true,
marketing: false
}
},
// === 收货地址(嵌入式数组)===
addresses: [
{
addressId: ObjectId("..."),
type: "home",
isDefault: true,
street: "1-2-3 Shibuya",
city: "Tokyo",
prefecture: "Tokyo",
zip: "150-0002",
country: "Japan",
phone: "+81-90-1234-5678"
}
],
// === 统计(高频更新字段建议拆分)===
stats: {
totalOrders: 25,
totalSpent: NumberDecimal("125430.50"),
averageRating: 4.7,
lastOrderAt: ISODate("2026-06-15T10:30:00Z")
},
// === 关注列表(引用式)===
followingIds: [
ObjectId("507f1f77bcf86cd799439012"),
ObjectId("507f1f77bcf86cd799439013")
],
// === 头像引用(GridFS)===
avatarFileId: ObjectId("507f1f77bcf86cd799439099"),
// === 元数据 ===
createdAt: ISODate("2025-03-01T10:00:00Z"),
updatedAt: ISODate("2026-07-01T15:23:00Z"),
lastLoginAt: ISODate("2026-07-01T10:00:00Z"),
isActive: true,
role: "customer" // customer | admin | moderator
});
(3) mongoose Schema 映射
const UserSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true, lowercase: true },
username: { type: String, required: true, unique: true, index: true },
displayName: { type: String, required: true },
phone: { type: String },
passwordHash: { type: String, required: true, select: false },
emailVerified: { type: Boolean, default: false },
twoFactorEnabled: { type: Boolean, default: false },
preferences: {
language: { type: String, default: 'en' },
currency: { type: String, default: 'USD' },
timezone: { type: String, default: 'UTC' },
notifications: {
email: { type: Boolean, default: true },
sms: { type: Boolean, default: false },
push: { type: Boolean, default: true },
marketing: { type: Boolean, default: false }
}
},
addresses: [{
addressId: { type: mongoose.Schema.Types.ObjectId, default: () => new mongoose.Types.ObjectId() },
type: { type: String, enum: ['home', 'work', 'other'], default: 'home' },
isDefault: { type: Boolean, default: false },
street: { type: String, required: true },
city: { type: String, required: true },
prefecture: String,
zip: { type: String, required: true },
country: { type: String, required: true },
phone: String
}],
stats: {
totalOrders: { type: Number, default: 0 },
totalSpent: { type: mongoose.Schema.Types.Decimal128, default: 0 },
averageRating: { type: Number, default: 0 },
lastOrderAt: Date
},
followingIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
avatarFileId: { type: mongoose.Schema.Types.ObjectId },
role: { type: String, enum: ['customer', 'admin', 'moderator'], default: 'customer', index: true },
isActive: { type: Boolean, default: true, index: true }
}, { timestamps: true });
❓ 常见问题
_id: ObjectId() 或 UUID。firstName 和 FirstName 是不同的字段。MongoDB 严格区分大小写。建议全程使用一种风格(推荐 camelCase)。_id 字段吗?_id 是文档唯一标识,修改会破坏引用关系。如果需要业务主键(如订单号),可使用 _id: "ORDER-2026-07-001" 这种自定义主键,但查询性能会下降。0.1 + 0.2 = 0.30000000000000004 经典问题。{ 姓名: "Alice" } 是合法的,但调试、日志、第三方工具支持差。建议全程英文。📖 小节
- BSON 是 MongoDB 的二进制存储格式,比 JSON 支持更多类型(Date/Decimal128/Binary 等)
- ObjectId 是 12 字节唯一标识,由时间戳 + 随机值 + 计数器组成
- BSON 支持 12+ 种数据类型,重点区分 Double / Int32 / Long / Decimal128
- 字段命名推荐 camelCase,避免以
$开头、不含. - 单文档大小限制 16 MB,大文件用 GridFS、长文本用 Elasticsearch
- 嵌入式适合 1:1 和少量 1:N 关系,引用式适合复杂关系
- mongoose Schema 定义文档结构,自动处理 BSON 类型转换
📝 作业
-
基础题(⭐):在 mongosh 中插入一个商品文档(含 6+ 字段:String、Number、Date、Array、Object、Boolean),用
findOne()查询并验证字段类型。 -
基础题(⭐):编写 Node.js 脚本,用 mongoose 创建一个用户 Schema(含 Decimal128 类型的 balance、Buffer 类型的 avatar),插入数据并打印 ObjectId 的时间戳。
-
进阶题(⭐⭐):设计一个博客文章的文档结构(含 5+ 字段),用
insertMany插入 5 篇文章,演示嵌入式评论数组的设计。 -
进阶题(⭐⭐):编写脚本提取 100 个文档的 ObjectId 中的时间戳,按日期分组统计每天的文档数量。
-
进阶题(⭐⭐):对比嵌入式文档和引用式文档的查询性能:用 100 万条数据,分别用嵌入和引用存储「用户-订单」关系,用
$lookup和嵌套查询测响应时间。 -
挑战题(⭐⭐⭐):用 GridFS 实现一个文件上传/下载 API,支持上传 100MB 大文件,验证分块存储机制,并实现下载进度跟踪。