1. 项目背景
业务场景:你加入了一家创业公司"本地生活电商",技术负责人告诉你,公司要用 MongoDB 替代 MySQL 来支撑商品、订单和用户系统。你打开官方文档,扑面而来的是 database、collection、document、BSON、replica set、shard、mongod、mongos……一堆陌生术语。更头疼的是,同事说 MongoDB 是"文档数据库",但你发现它也有索引、也有 ACID 事务、也能做 JOIN——那它和关系型数据库到底有什么区别?什么时候该用它,什么时候不该?
痛点:如果你不理解 MongoDB 的术语体系和架构原理,后续学习就会陷入"抄命令但不知道为什么"的困境。典型的翻车现场:
- 把 MongoDB 当成 MySQL 用,创建几十个集合,到处做关联查询,然后抱怨 MongoDB 慢。
- 不理解文档模型,把一对多关系全部用嵌入塞进一个文档,写入时频繁导致文档膨胀和内存压力。
- 搞不清 mongod、mongos、config server 的分工,拿到分片集群的连接串就一通乱连,读写全打在 mongos 上还不知道为什么。
- 没有架构全景图,团队内对"这个数据在哪个 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)。
代码解释:
- 客户端通过 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:
- 内容管理(CMS)、博客、商品目录——结构多变,嵌套多前端直接消费的 JSON。
- IoT 日志、事件存储——写入量大、数据结构扁平化、按时间范围查询。
- 实时分析、用户行为追踪——数据不断增长,聚合管道可替代简单离线报表。
- 电商单品页——嵌套详情、规格、库存、评论,一次查询获取完整页面数据。
- 移动应用后端——开发速度快,schema 与前端 JSON 直接映射。
不推荐使用 MongoDB:
- 银行核心账务系统——需要极强的 ACID 保证和复杂的多表事务。
- 高度关联的 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 思考题
- 如果在
_id字段上创建索引,会发生什么?为什么 MongoDB 不需要我们在_id上手动建索引? - 一个 document 嵌套了 100 层子文档,查询第 99 层的字段,WiredTiger 需要做什么额外的操作?这和关系型数据库的 JOIN 有何异同?
(答案将在第 2 章末尾揭晓)
延伸阅读与资源
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号