1. 项目背景

业务场景:你加入了一家创业公司"本地生活电商",技术负责人告诉你,公司要用 MongoDB 替代 MySQL 来支撑商品、订单和用户系统。你打开官方文档,扑面而来的是 database、collection、document、BSON、replica set、shard、mongod、mongos……一堆陌生术语。更头疼的是,同事说 MongoDB 是"文档数据库",但你发现它也有索引、也有 ACID 事务、也能做 JOIN——那它和关系型数据库到底有什么区别?什么时候该用它,什么时候不该?

痛点:如果你不理解 MongoDB 的术语体系和架构原理,后续学习就会陷入"抄命令但不知道为什么"的困境。典型的翻车现场:

  1. 把 MongoDB 当成 MySQL 用,创建几十个集合,到处做关联查询,然后抱怨 MongoDB 慢。
  2. 不理解文档模型,把一对多关系全部用嵌入塞进一个文档,写入时频繁导致文档膨胀和内存压力。
  3. 搞不清 mongod、mongos、config server 的分工,拿到分片集群的连接串就一通乱连,读写全打在 mongos 上还不知道为什么。
  4. 没有架构全景图,团队内对"这个数据在哪个 shard 上""oplog 到底在干什么"反复沟通,降低协作效率。

2. 项目设计

小胖(午饭后冲进工位,嘴里还嚼着薯片):大师大师,我看了一上午 MongoDB 文档,头晕眼花。什么 document、collection、BSON、replica set……我感觉自己在背英语单词。能不能用一句话告诉我,MongoDB 到底是个啥?

大师(放下手中的咖啡):一句话?好。MongoDB 是一个面向文档的分布式数据库,你可以把它理解为一个"超级 JSON 存储器"——它用类似 JSON 的 BSON 格式存储数据,天然支持嵌套和数组,能水平扩展,自带高可用。

小胖:JSON 存储器?那不就是 Redis 吗?

小白(从屏幕后面探出头):不一样。Redis 主要是内存缓存,数据在内存里,虽然也能持久化但主要场景是缓存和消息队列。MongoDB 是正经的磁盘数据库,数据持久写在 WiredTiger 存储引擎里。而且 Redis 的数据结构是 key-value、list、set、hash,MongoDB 的核心单位是 document。

大师:小白说得对。你可以这样类比——Redis 像是黑板上的便利贴,随手贴随手撕,快但容量有限;MongoDB 像是图书馆的书架,书可以摆得很密,每本书(document)内容可以很丰富,还能按分类(索引)快速查找。

技术映射:Redis 定位为内存数据结构存储,侧重低延迟缓存;MongoDB 定位为通用文档数据库,侧重灵活 schema 和持久存储。

小胖:哦……那它和 MySQL 又有什么区别?我们以前全用 MySQL,大师你为什么突然要换?

大师:问得好。MySQL 是关系型数据库,你需要先定义表结构(schema),每条记录必须严格符合列定义。比如用户表有 id、name、email 三列,你不能随便加一个"喜好标签"列。MongoDB 的 document 没有固定字段约束——一个集合里的两个文档可以有完全不同的字段。

小白:但这也意味着脏数据的风险吧?如果一个文档里 "age" 是数字,另一个文档里 "age" 是字符串"二十五",查询的时候不就炸了?

大师:没错,这是灵活性必须付出的代价。所以 MongoDB 后来引入了 JSON Schema Validator,可以在 collection 级别定义字段类型、必填、枚举值等约束。实际项目中我们通常会开启校验。

技术映射:关系型数据库(RDBMS)强调 schema-first,适合结构化、关联性强、一致性要求高的场景;MongoDB 强调 schema-flexible,适合结构多变、嵌套复杂、读写模式简单且需要快速迭代的场景。

小胖:那 MongoDB 的术语怎么对应 MySQL 啊?我脑子里的对比表是空的。

大师:画个表你就懂了:

MySQL 术语 MongoDB 术语 说明
database database 数据库,一样
table collection 集合,相当于表
row document 文档,相当于行
column field 字段,相当于列
primary key _id 主键,MongoDB 默认用 ObjectId
index index 索引,概念基本一致
JOIN $lookup / 嵌入 MongoDB 用嵌入或引用替代 JOIN

小白:我注意到 MongoDB 没有 JOIN 的等价概念,$lookup 看起来不像 SQL JOIN 那么好用。如果我们有大量关联查询,是不是不该用 MongoDB?

大师:这就是选型关键。MongoDB 的设计哲学是"数据随访问模式存"。如果你的查询总是一起拿用户和他的收货地址,那就把地址嵌在用户文档里——一次查询拿到全部。如果你像 MySQL 那样把地址拆到另一个表,每次都要做两次查询或 $lookup,不仅代码复杂,性能也会下降。

技术映射:MongoDB 的嵌入模型适合"一起读、一起写"的数据;引用模型适合"独立读写、独立生命周期"的数据。

小胖:文档里还提到了 mongod、mongos、replica set、shard,这些又是什么鬼?

大师:这就是 MongoDB 的架构全景。想象一个数据中心的物理架构:

  • mongod:核心数据库进程,负责存储数据、执行查询。单机模式下就一个 mongod 在干活。
  • replica set(复制集):一组 mongod 节点,一个 Primary(主节点)负责写入,多个 Secondary(从节点)同步数据。Primary 挂了,Secondary 自动选举接替——高可用。
  • shard(分片):每个分片本身就是一个复制集。数据按分片键拆分到不同分片上——水平扩展。
  • mongos:分片集群的查询路由器,客户端连接 mongos,mongos 根据分片键把请求路由到正确的 shard。
  • config server(配置服务器):存储分片集群的元数据(哪个分片存了哪些数据)。

小白:所以最小生产部署是 3 个 mongod 组成一个复制集?如果要分片,就是 N 个复制集 + mongos + config server?

大师:一言蔽之。单机 < 复制集(高可用)< 分片集群(高可用 + 水平扩展)。

技术映射:无论是单机、复制集还是分片集群,数据最终都由 mongod 进程中的 WiredTiger 存储引擎持久化到磁盘,通过 B-Tree 组织,用 Journal(WAL)保证崩溃恢复。

小胖:我好像有点感觉了。总结一下:MongoDB 用 BSON 格式存数据,document 是基本单位,collection 是 document 的容器,database 是 collection 的容器。单机靠 mongod,高可用靠复制集,大规模靠分片集群。对不?

大师:再加一句:你写的每一条 find()、insertOne(),都会经过"Driver → 连接池 → mongod/mongos → 查询解析 → 执行引擎 → WiredTiger 存储引擎 → 磁盘"这一整条链路。

小白:那 WiredTiger 是什么?

大师:MongoDB 默认的存储引擎,负责数据实际如何存到磁盘、如何读取、如何缓存。它用 B-Tree 组织数据,有自己的缓存和并发控制机制。第 33 章我们会深入它。现在你先记住名字就行。

3. 项目实战

3.1 环境准备

组件 版本/工具 用途
MongoDB 8.0(Docker 官方镜像) 数据库服务
Docker 24+ 容器运行环境
mongosh 最新版 MongoDB 命令行客户端
Compass 最新版 图形化管理工具

启动单节点 MongoDB:

# 拉取镜像并启动单节点 MongoDB
docker run -d \
  --name mongo-ch01 \
  -p 27017:27017 \
  -e MONGO_INITDB_ROOT_USERNAME=admin \
  -e MONGO_INITDB_ROOT_PASSWORD=admin123 \
  mongo:8.0

进入容器使用 mongosh:

docker exec -it mongo-ch01 mongosh -u admin -p admin123 --authenticationDatabase admin

3.2 分步实现

步骤一:理解 database 与 collection 的创建

目标:创建本地生活电商的第一个 database,并在其中创建多个 collection。

// 切换到(或创建)local_life 库
use local_life

// MongoDB 的 collection 是懒创建的——插入文档时才真正创建
// 查看当前库中的所有集合
show collections
// 此时应该为空,因为还没有插入任何文档

可能遇到的坑

  • use local_life 执行后不会立即创建数据库,只有第一次插入数据后数据库才真正存在。用 show dbs 验证时会发现刚 use 的库不显示。
  • 数据库名称不能为空、不能包含 / \ . " * < > : | ? $ 等特殊字符,小写为宜。

步骤二:理解 document 与 BSON 类型

目标:插入第一批业务文档,观察 BSON 类型如何在 mongosh 中表现。

// 插入一个用户文档
db.users.insertOne({
  name: "张三",
  age: 28,
  email: "zhangsan@example.com",
  tags: ["新用户", "手机端"],
  address: {
    province: "广东",
    city: "深圳",
    detail: "南山区科技园路1号"
  },
  createdAt: new Date(),
  balance: NumberDecimal("99.99")  // Decimal128 类型,精确金额
})

// 查看插入结果
db.users.find().pretty()

运行结果(命令行输出示例):

{
  _id: ObjectId('6601a2b3c4d5e6f7a8b9c0d1'),
  name: '张三',
  age: 28,
  email: 'zhangsan@example.com',
  tags: [ '新用户', '手机端' ],
  address: { province: '广东', city: '深圳', detail: '南山区科技园路1号' },
  createdAt: ISODate('2026-03-20T08:30:00.000Z'),
  balance: Decimal128('99.99')
}

技术要点

  • _id 如果未显式指定,MongoDB 自动生成 ObjectId,它是一个 12 字节的 BSON 类型,结构为:4 字节时间戳 + 5 字节随机值 + 3 字节计数器。
  • 金额推荐用 NumberDecimal() 而非 double,避免浮点精度问题(如 0.1 + 0.2 ≠ 0.3)。
  • 日期类型直接用 new Date(),存储为 BSON Date 类型(UTC 时间戳)。

步骤三:理解 namespace 与 cursor

目标:查询数据并理解 cursor 的概念。

// 插入多条商品数据
db.products.insertMany([
  { name: "iPhone 16 手机壳", category: "手机配件", price: 29.9, stock: 200 },
  { name: "蓝牙耳机", category: "数码影音", price: 199.0, stock: 50 },
  { name: "充电宝 20000mAh", category: "手机配件", price: 89.0, stock: 150 }
])

// find() 返回的不是数据本身,而是一个 cursor(游标)
const cursor = db.products.find({})
// cursor 是一个迭代器,mongosh 默认自动迭代前 20 条并展示
// 可以手动迭代更多
while (cursor.hasNext()) {
  printjson(cursor.next().name)
}

可能遇到的坑

  • mongosh 中 find() 返回的是 cursor,如果数据量大,mongosh 只展示前 20 条并在末尾显示 Type "it" for more——输入 it 可以继续迭代。
  • cursor 在服务端有超时时间(默认 10 分钟),长时间不消费会自动关闭。
  • namespace 的全称是 <database>.<collection>,如 local_life.products,在系统监控中经常看到这种格式。

步骤四:理解索引的基本概念

目标:为商品集合创建第一个索引,感受有索引和无索引的查询差异。

// 先插入 10 万条模拟数据,感受无索引的全表扫描
for (let i = 0; i < 100000; i++) {
  db.products.insertOne({
    name: "商品_" + i,
    category: "类目_" + (i % 100),
    price: Math.random() * 1000,
    stock: Math.floor(Math.random() * 500)
  })
}

// 查看查询执行计划(无索引时)
db.products.find({ category: "类目_42" }).explain("executionStats")
// 观察 winningPlan.stage = "COLLSCAN"(全集合扫描)
// executionStats.totalDocsExamined = 100000(扫描了全部文档)

// 创建单字段索引
db.products.createIndex({ category: 1 })

// 再次查看执行计划
db.products.find({ category: "类目_42" }).explain("executionStats")
// 观察 winningPlan.stage = "IXSCAN"(索引扫描)
// executionStats.totalDocsExamined 大约 1000(只扫描了命中索引的文档)

步骤五:绘制 MongoDB 架构图

目标:用 Mermaid 绘制一张团队可复用的 MongoDB 架构全景图(保存为 mongo-architecture.mmd)。

graph TB subgraph 客户端层 A[应用程序 Driver] B[mongosh CLI] C[Compass GUI] end subgraph 路由层-分片集群 D[mongos查询路由器] end subgraph 分片1-ReplicaSet E1[Primary mongod] E2[Secondary mongod] E3[Secondary mongod] end subgraph 分片2-ReplicaSet F1[Primary mongod] F2[Secondary mongod] F3[Secondary mongod] end subgraph 配置服务 G[Config ServerReplica Set] end subgraph mongod内部 H[查询解析 & 优化器] I[执行引擎 SBE/Classic] J[WiredTiger 存储引擎] K[(磁盘B-Tree + Journal)] end A --> D B --> D C --> D D --> G D --> E1 D --> F1 E1 --> E2 E1 --> E3 F1 --> F2 F1 --> F3 E1 --> H H --> I I --> J J --> K style K fill:#f9f,stroke:#333

代码解释

  • 客户端通过 Driver 或 CLI 发送请求,在分片集群场景下先经过 mongos 路由。
  • mongos 查询 config server 确定数据在哪个分片,然后将请求转发到对应分片的 Primary 节点。
  • mongod 进程内部:查询解析器将 BSON 请求转为内部查询对象 → 执行引擎运行查询计划 → WiredTiger 从磁盘(B-Tree)或缓存中读写数据。
  • 单机/复制集场景下没有 mongos,客户端直接连接 Primary mongod。

3.3 完整代码清单

本章实验代码包含以下文件:

文件 用途
ch01/docker-compose.yml 单节点 MongoDB 启动配置
ch01/init.js 初始化脚本:创建库、集合、插入样例数据
ch01/query-demo.js 演示查询、游标、explain 的脚本
ch01/mongo-architecture.mmd Mermaid 架构图源代码

3.4 测试验证

// 验证数据库和集合创建
show dbs                           // 应能看到 local_life
db.users.countDocuments()         // 应为 1
db.products.countDocuments()      // 应为 100003(含循环插入的 10 万条)

// 验证索引创建
db.products.getIndexes()          // 应看到 _id_ 和 category_1 两个索引

// 清理测试数据(可选)
db.products.dropIndex({ category: 1 })
db.products.drop()
db.users.drop()

4. 项目总结

4.1 优缺点对比

维度 MongoDB(文档型) MySQL(关系型) 对比结论
Schema 灵活性 极高,无固定字段 严格,需先定义表结构 MongoDB 适合快速迭代
嵌套数据查询 嵌入模式下一次查询完成 需多次 JOIN MongoDB 读路径更短
复杂关联查询 $lookup 性能较差 JOIN 成熟高效 MySQL 更适合报表分析
事务支持 4.0+ 支持多文档事务 原生成熟 差距已大幅缩小
水平扩展 原生分片集群 需中间件(如 ShardingSphere) MongoDB 更简易
生态与工具 20 年积累尚浅 40+ 年生态沉淀 MySQL 工具链更丰富

4.2 适用场景

推荐使用 MongoDB

  1. 内容管理(CMS)、博客、商品目录——结构多变,嵌套多前端直接消费的 JSON。
  2. IoT 日志、事件存储——写入量大、数据结构扁平化、按时间范围查询。
  3. 实时分析、用户行为追踪——数据不断增长,聚合管道可替代简单离线报表。
  4. 电商单品页——嵌套详情、规格、库存、评论,一次查询获取完整页面数据。
  5. 移动应用后端——开发速度快,schema 与前端 JSON 直接映射。

不推荐使用 MongoDB

  1. 银行核心账务系统——需要极强的 ACID 保证和复杂的多表事务。
  2. 高度关联的 ER 模型——如 ERP、供应链系统,多表 JOIN 是核心操作。

4.3 注意事项

注意事项 说明
ObjectId 非严格单调递增 同一秒内多机器生成的 _id 可能乱序,不要依赖 _id 做严格时序排序
文档大小限制 16MB 单个 BSON document 不能超过 16MB,大文本用 GridFS
字段名影响存储 每次插入都存储字段名,短字段名能显著减少存储(如用 "nm" 替代 "userDisplayName")
数据库命名 不能包含大写字母(Windows 不区分大小写但 Linux 区分),建议全小写
集合命名 避免以 system. 开头,那是系统保留前缀

4.4 常见踩坑经验

故障案例一:ObjectId 作为排序字段的坑

某团队在对订单集合按创建时间排序时,直接用 sort({ _id: -1 }),认为 ObjectId 前 4 字节是时间戳所以天然有序。结果某次大促两台应用服务器同时写入时,_id 出现交错——前一个文档的 _id 时间戳部分比后一个大,但实际物理时间相反。根因:ObjectId 的时间戳精确到秒,同一秒内不同机器生成的 _id 相对顺序取决于随机值部分。解决:显式使用 createdAt 字段排序。

故障案例二:浮点数金额导致对账不平

某电商最初用 Double 类型存储金额,9.99 + 0.01 写入数据库后变成了 10.000000000000002,月底财务对账发现 10 万笔交易累计偏差 0.03 元。根因:IEEE 754 浮点数精度问题。解决:改用 NumberDecimal() 或整数分(以分为单位存 Long)。

故障案例三:数据库名称区分大小写导致的连接错误

开发在 macOS 上用 use MyShop 创建了数据库,部署到 Linux 生产环境后应用报错 "database not found"。根因:Linux 文件系统区分大小写,MongoDB 在 Linux 上对数据库名也区分大小写。解决:一律使用全小写数据库名,在项目规范中明确规定。

4.5 思考题

  1. 如果在 _id 字段上创建索引,会发生什么?为什么 MongoDB 不需要我们在 _id 上手动建索引?
  2. 一个 document 嵌套了 100 层子文档,查询第 99 层的字段,WiredTiger 需要做什么额外的操作?这和关系型数据库的 JOIN 有何异同?

(答案将在第 2 章末尾揭晓)

延伸阅读与资源

python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战

大型语言模型(LLM) vLLM 高性能推理落地实战

Agent开发之LlamaIndex 实战修炼与源码进阶

大语言模型Transformers 实战修炼与源码剖析

posted on 2026-07-18 18:58  一天不进步,就是退步  阅读(25)  评论(0)    收藏  举报