MongoDB 深入原理
文档型 NoSQL 数据库,以 JSON 文档格式存储数据。与关系型数据库的根本区别:表结构灵活,数据可以嵌套,扩展性强。
1. 核心概念:从"表"到"文档"
1.1 对比关系型数据库
| 关系型 MySQL | MongoDB | 说明 |
|---|---|---|
| Database | Database | 逻辑容器 |
| Table | Collection | 文档集合 |
| Row | Document | 一条记录 |
| Column | Field | 字段 |
| PRIMARY KEY | _id(自动生成) |
主键 |
| JOIN | $lookup / 嵌套文档 | 关联方式 |
1.2 文档结构示例
// 用户文档 - 结构可以完全不同
{
"_id": ObjectId("..."),
"username": "zhangsan",
"profile": {
"age": 28,
"city": "Beijing",
"tags": ["Java", "MongoDB"]
},
"orders": [
{ "orderId": "A001", "amount": 100 },
{ "orderId": "A002", "amount": 200 }
]
}
同一个 Collection 里的文档可以有完全不同的字段,这是 MySQL 里无法想象的。
2. CRUD 操作
2.1 插入
db.users.insertOne({
username: "zhangsan",
email: "zhangsan@example.com"
})
// 批量插入
db.users.insertMany([
{ username: "user1", email: "u1@example.com" },
{ username: "user2", email: "u2@example.com" }
])
2.2 查询
// 基本查询
db.users.find({ username: "zhangsan" })
// 条件查询:年龄大于25岁
db.users.find({ "profile.age": { $gt: 25 } })
// 嵌套字段直接访问
db.users.find({ "profile.city": "Beijing" })
// 数组查询:包含某个标签
db.users.find({ "profile.tags": "Java" })
// 查询嵌套数组中的元素
db.users.find({ "orders.amount": { $gte: 100 } })
// 分页查询
db.users.find().skip(20).limit(10)
// 返回指定字段
db.users.find({}, { username: 1, email: 1, _id: 0 })
// 排序
db.users.find().sort({ createdAt: -1 }) // -1 降序,1 升序
2.3 更新
// 更新单个文档
db.users.updateOne(
{ username: "zhangsan" },
{ $set: { "profile.age": 30 } }
)
// 更新嵌套字段(使用点号)
db.users.updateOne(
{ username: "zhangsan" },
{ $set: { "orders.0.amount": 150 } } // 更新第一个订单
)
// 数组元素更新:修改 orders 里的某个订单
db.users.updateOne(
{ username: "zhangsan", "orders.orderId": "A001" },
{ $set: { "orders.$.amount": 180 } } // $ 定位匹配元素
)
// 批量更新
db.users.updateMany(
{ "profile.city": "Beijing" },
{ $set: { "profile.city": "北京" } }
)
// 增减操作
db.users.updateOne(
{ username: "zhangsan" },
{ $inc: { "profile.age": 1 } } // 年龄 +1
)
2.4 删除
// 删除单条
db.users.deleteOne({ username: "zhangsan" })
// 批量删除
db.users.deleteMany({ "profile.age": { $lt: 18 } })
// 删除集合(相当于清空表)
db.users.deleteMany({})
3. 聚合管道(Aggregation Pipeline)
MongoDB 最强大的特性之一,类似 SQL 的 SELECT + GROUP BY + JOIN,但更灵活。
3.1 基本结构
db.orders.aggregate([
{ $match: { status: "completed" } }, // 过滤
{ $group: { // 分组
_id: "$category",
totalAmount: { $sum: "$amount" },
avgAmount: { $avg: "$amount" },
count: { $sum: 1 }
}},
{ $sort: { totalAmount: -1 } } // 排序
])
等价 SQL:
SELECT category, SUM(amount), AVG(amount), COUNT(*)
FROM orders
WHERE status = 'completed'
GROUP BY category
ORDER BY SUM(amount) DESC
3.2 常用聚合阶段
| 阶段 | 作用 | 类比 SQL |
|---|---|---|
$match |
过滤文档 | WHERE |
$group |
分组统计 | GROUP BY |
$sort |
排序 | ORDER BY |
$limit |
限制数量 | LIMIT |
$skip |
跳过数量 | OFFSET |
$project |
选择字段/新增字段 | SELECT |
$lookup |
左连接其他集合 | LEFT JOIN |
$unwind |
展开数组 | 行转列 |
3.3 复杂聚合示例
统计每个城市的用户数、平均订单金额,并找出 Top 3 城市:
db.users.aggregate([
{ $lookup: { // 关联订单表
from: "orders",
localField: "_id",
foreignField: "userId",
as: "orderList"
}},
{ $unwind: "$orderList" }, // 展开订单数组
{ $group: {
_id: "$profile.city",
userCount: { $sum: 1 },
avgOrderAmount: { $avg: "$orderList.amount" }
}},
{ $sort: { userCount: -1 } },
{ $limit: 3 }
])
4. 索引原理
4.1 为什么需要索引
假设 Collection 有 1000 万条文档,查询 {"username": "zhangsan"} 需要全表扫描。
MongoDB 的默认主键 _id 已经是索引。创建其他字段索引:
// 单字段索引
db.users.createIndex({ username: 1 })
// 复合索引(按字段顺序决定索引有效性)
db.users.createIndex({ "profile.city": 1, "profile.age": 1 })
// 多键索引(针对数组字段)
db.users.createIndex({ "profile.tags": 1 })
// 文本索引(全文搜索)
db.users.createIndex({ description: "text" })
4.2 索引类型
| 类型 | 说明 | 使用场景 |
|---|---|---|
| 单字段索引 | 单个字段 | 常见查询 |
| 复合索引 | 多个字段组合 | 多条件查询 |
| 多键索引 | 数组字段 | tags 包含某值 |
| 文本索引 | 分词全文搜索 | 内容搜索 |
| 地理空间索引 | 经纬度 | 附近的人/门店 |
| 哈希索引 | 字段哈希值 | 分片 shard key |
4.3 索引生效规则
复合索引 {a: 1, b: 1, c: 1} 的查询规则:
✅ 查询条件包含 a → 使用索引(左前缀)
✅ 查询条件包含 a, b → 使用索引
✅ 查询条件包含 a, b, c → 使用索引
❌ 查询条件只有 b 或 c → 不使用索引
最左前缀原则:条件必须从索引最左边的字段开始。
4.4 索引副作用
索引会占用磁盘空间,且每次写入(INSERT/UPDATE/DELETE)都要维护索引。
写入远多于查询的场景(如日志),过多索引反而拖累性能。
5. 分片集群(Sharded Cluster)
5.1 什么时候需要分片
- 单机 MongoDB 磁盘打满
- 单机内存放不下热数据
- 写入成为瓶颈(并发写入量大)
5.2 分片集群架构
客户端 → mongos(路由节点)
↓
Config Servers(配置服务器,存储元数据)
↓
Shard1 | Shard2 | Shard3(分片节点)
- mongos:路由进程,无状态,接受客户端请求,根据配置服务器中的元数据决定路由到哪个分片
- Config Server:存储集群拓扑(哪些数据在哪个分片),必须用副本集保证高可用
- Shard:实际存储数据的节点,每个 Shard 也是副本集
5.3 分片键选择
数据根据分片键(shard key)分散到各个分片。
// 对 users 集合按 user_id 分片
sh.shardCollection("app.users", { user_id: "hashed" })
选择分片键的原则:
- 基数够大:分片键取值种类要多(如 user_id),不要用性别、状态这种低基数字段
- 写入分散:避免所有写入集中在某个分片(热点key)
- 查询定向:常用查询条件包含分片键,让查询直接路由到目标分片而不是广播
6. 与 MySQL 的关键区别
6.1 什么时候用 MongoDB
| 场景 | 为什么选 MongoDB |
|---|---|
| 对象存储(JSON 结构) | 文档直接映射,无需 ORM 转换 |
| 表结构频繁变更 | 字段随时加减,不用 ALTER TABLE |
| 快速迭代的业务 | 开发周期短,不用提前设计复杂 Schema |
| 内容管理、评论等半结构化数据 | 灵活文档结构 |
| 地理位置查询 | 原生支持 2dsphere 索引 |
| 海量数据 + 高并发写入 | 分片集群 + WiredTiger 引擎 |
6.2 什么时候继续用 MySQL
| 场景 | 为什么选 MySQL |
|---|---|
| 有强事务需求(ACID) | MongoDB 4.0 才支持事务,单机级别 |
| 复杂关联 JOIN | MongoDB 关联能力弱 |
| 数据分析、报表(复杂 SQL) | MongoDB 聚合 pipeline 功能有限 |
| 数据一致性要求极高 | MySQL 主从复制更成熟 |
| 固定报表、预算系统 | 关系模型更直观 |
6.3 一个常见误区
MongoDB 不是银弹。很多人因为"灵活"选了 MongoDB,最后遇到的问题:
- 数据膨胀:文档结构乱放,没人敢删字段
- 关联查询性能差:$lookup 跨集合关联很慢
- 事务限制:跨分片事务支持有限
- 运维复杂度:分片集群运维比 MySQL 主从难得多
7. 性能优化实战
7.1 慢查询分析
// 查看慢查询
db.setProfilingLevel(1, { slowms: 100 }) // 记录超过100ms的查询
// 查看慢查询日志
db.system.profile.find().pretty()
// 分析查询计划
db.users.find({ username: "zhangsan" }).explain("executionStats")
7.2 Explain 分析
db.users.find({ "profile.city": "Beijing", "profile.age": { $gt: 25 } })
.explain("executionStats")
关键指标:
IXSCAN:使用了索引COLLSCAN:全表扫描(需要优化)nReturned:返回文档数totalDocsExamined:检查的文档数(越接近 nReturned 越好)
7.3 Covered Query
查询所有字段都是索引的一部分时,MongoDB 只需读索引文件,不需要读数据文件(覆盖索引):
// 创建一个覆盖查询的复合索引
db.users.createIndex({ username: 1, email: 1 })
// 查询只返回索引包含的字段
db.users.find({ username: "zhangsan" }, { username: 1, email: 1, _id: 0 })
8. 副本集(Replica Set)的高可用
MongoDB 副本集 = 主节点 + 多个从节点 + 仲裁节点。
主节点(Primary) 从节点(Secondary) 仲裁节点(Arbiter)
↓ ↓
接受写入 异步复制 Oplog 只投票,不存数据
↓
写入成功后返回客户端
主节点挂了怎么办?
- 从节点通过心跳检测到主节点不可达
- 符合条件的从节点发起选举
- 仲裁节点投票,得票多的成为新主节点
- 应用自动重连新主节点
Arbiter 节点:不存储数据,只参与投票。用于奇数节点时打破平票。