MongoDB: 文档与BSON:MongoDB的数据基石

最后更新:2026-08-26

BSON 是 MongoDB 的数据格式——它扩展了 JSON 的能力,支持日期、二进制、Decimal128 等原生类型。

本课程深入理解 BSON 数据格式、ObjectId 内部结构、字段类型体系,掌握文档设计的最佳实践。

1. 你将学到


2. 一个全栈工程师的真实故事

(1) 痛点:JSON 存进 MongoDB 后日期变成字符串

Charlie 是一名 Node.js 全栈工程师,正在迁移 MySQL 数据到 MongoDB:

"我把 MySQL 的订单数据转成 JSON 存进 MongoDB,发现所有日期都变成了字符串 new Date() 解析不了,金额精度丢失(0.1+0.2 ≠ 0.3),二进制的头像图片完全存不了。"

他用 JSON.stringify() 序列化订单数据,丢失了类型信息:

JAVASCRIPT
// ❌ 错误: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)格式存储数据,保留所有类型信息。

JAVASCRIPT
// ✅ 正确: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%(存储类型信息和长度信息)。

100%
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 使用的二进制序列化格式,特点:

100%
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 文档结构

要点解析

  1. BSON 保留字段插入顺序——这对 MongoDB 的索引和查询优化至关重要
  2. 每个字段前有 1 字节类型码,使 BSON 能区分 Date 和 String(JSON 不能)
  3. 嵌套文档和数组在 BSON 中递归存储,最大嵌套深度 100 层
  4. _id 字段始终在文档第一个位置,优化查询性能
JAVASCRIPT
// 一个 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 详情

JAVASCRIPT
// 插入一个文档
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

输出:

TEXT 📖 仅展示
// 执行成功

4. ObjectId 主键机制

概念说明:ObjectId 是 MongoDB 默认的主键类型,是一个 12 字节(96 位)的二进制值。与传统数据库的自增整数主键不同,ObjectId 采用分布式设计——由时间戳 + 随机值 + 计数器组成,无需中心化协调即可保证全局唯一性。ObjectId 的另一大优势是天然包含创建时间,可直接提取而不需额外字段。

工作原理:ObjectId 的 12 字节分为三段:前 4 字节是 Unix 时间戳(秒级精度),中间 5 字节是随机值(首次生成时由机器 ID + 进程 ID 决定,之后保持不变),后 3 字节是递增计数器(同一秒内从随机起始值递增)。这种设计使得同一秒内同一进程可生成约 1677 万个不重复 ObjectId。

100%
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 位)的二进制值:

100%
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 优势

要点解析

  1. ObjectId 的时间戳部分使其天然按插入时间排序——无需额外 createdAt 索引即可按时间范围查询
  2. 5 字节随机值在进程启动时生成并缓存,保证跨进程唯一性(2^40 ≈ 1 万亿种可能)
  3. 3 字节计数器在同一秒内递增,每秒可生成 2^24 ≈ 1677 万个不重复 ID
  4. getTimestamp() 方法可直接从 ObjectId 提取创建时间,无需额外查询
JAVASCRIPT
// 在 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 唯一性保证

100%
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 时间戳提取

JAVASCRIPT
// === 在 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)
    )
  }
});

输出:

TEXT 📖 仅展示
// 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 字节 金融金额(推荐)
100%
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:数值类型实战

JAVASCRIPT
// === 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") ✅

输出:

TEXT 📖 仅展示
// 执行成功

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 字段命名规则

合法的命名

JAVASCRIPT
// ✅ 合法的字段名
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 官方风格

JAVASCRIPT
// ✅ 推荐风格: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 命名规范

JAVASCRIPT
// 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;
  }
});

输出:

TEXT 📖 仅展示
// 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 设计哲学:避免存储巨型文档

(3) 大文档场景解决方案

100%
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 存储大文件

JAVASCRIPT
// === 存储大文件(>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'));

输出:

TEXT 📖 仅展示
// mongoose 操作成功执行
// 数据库查询/更新结果

8. 嵌入式文档 vs 引用

概念说明:MongoDB 文档关系建模有两大策略——嵌入式(Embed)和引用式(Reference)。嵌入式将关联数据直接嵌在父文档中,一次查询即可获取全部数据;引用式将关联数据存储在独立集合中,通过 ObjectId 引用,需要 $lookup 或应用层多次查询。两种策略的选择是 MongoDB 数据建模最核心的决策。

工作原理:嵌入式文档与父文档存储在同一个 BSON 文档中,共享同一生命周期——更新父文档时嵌入文档也被重写,查询父文档时嵌入文档一并返回。引用式文档是独立的 BSON 文档,有自己的 _id 和生命周期,更新互不影响,但查询需要额外的关联操作。

100%
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) 两种关系建模策略

100%
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) 嵌入式文档示例

JAVASCRIPT
// === 嵌入式:用户 + 多地址 ===
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:引用式文档示例

JAVASCRIPT
// === 引用式:用户 + 订单(多对一) ===
// 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"
  }}
]);

输出:

TEXT 📖 仅展示
// 执行成功

9. 综合实战:电商用户文档设计

(1) 场景需求

设计一个电商平台的用户文档,需求:

(2) 文档设计

JAVASCRIPT
// === 综合用户文档 ===
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 映射

JAVASCRIPT
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 });

❓ 常见问题

Q 为什么 MongoDB 用 BSON 而不是直接存 JSON?
A JSON 是文本格式,解析慢、类型少、无字段顺序保证。BSON 是二进制格式,解析快(毫秒级)、类型丰富(Date/Binary/Decimal128)、保留字段顺序(重要!),更适合数据库存储和查询。
Q ObjectId 真的是唯一的吗?
A 理论上同一秒内同一进程同一计数器不会重复。实际中几乎不可能碰撞(5 字节随机值 = 2^40 ≈ 1 万亿种可能)。如果需要绝对唯一(如金融),可以手动指定 _id: ObjectId() 或 UUID。
Q 字段名大小写敏感吗?
A 是的。firstNameFirstName 是不同的字段。MongoDB 严格区分大小写。建议全程使用一种风格(推荐 camelCase)。
Q 能修改 _id 字段吗?
A 可以但不推荐。_id 是文档唯一标识,修改会破坏引用关系。如果需要业务主键(如订单号),可使用 _id: "ORDER-2026-07-001" 这种自定义主键,但查询性能会下降。
Q Decimal128 和 Double 怎么选?
A 金融、金额、精确计算用 Decimal128。科学计算、统计、图形渲染用 Double(更快)。用错 Decimal128 会出现 0.1 + 0.2 = 0.30000000000000004 经典问题。
Q 嵌入式文档查询性能好吗?
A 好。MongoDB 对嵌入式文档建索引后,查询性能接近独立集合。但要注意:(1) 文档总大小不能超 16MB;(2) 数组字段索引(multikey index)有大小限制。
Q 字段名能用中文吗?
A 可以但不推荐。例如 { 姓名: "Alice" } 是合法的,但调试、日志、第三方工具支持差。建议全程英文。

📖 小节


📝 作业

  1. 基础题(⭐):在 mongosh 中插入一个商品文档(含 6+ 字段:String、Number、Date、Array、Object、Boolean),用 findOne() 查询并验证字段类型。

  2. 基础题(⭐):编写 Node.js 脚本,用 mongoose 创建一个用户 Schema(含 Decimal128 类型的 balance、Buffer 类型的 avatar),插入数据并打印 ObjectId 的时间戳。

  3. 进阶题(⭐⭐):设计一个博客文章的文档结构(含 5+ 字段),用 insertMany 插入 5 篇文章,演示嵌入式评论数组的设计。

  4. 进阶题(⭐⭐):编写脚本提取 100 个文档的 ObjectId 中的时间戳,按日期分组统计每天的文档数量。

  5. 进阶题(⭐⭐):对比嵌入式文档和引用式文档的查询性能:用 100 万条数据,分别用嵌入和引用存储「用户-订单」关系,用 $lookup 和嵌套查询测响应时间。

  6. 挑战题(⭐⭐⭐):用 GridFS 实现一个文件上传/下载 API,支持上传 100MB 大文件,验证分块存储机制,并实现下载进度跟踪。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏