CC 咖啡猫的工作空间 Coding Space

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" })

选择分片键的原则:

  1. 基数够大:分片键取值种类要多(如 user_id),不要用性别、状态这种低基数字段
  2. 写入分散:避免所有写入集中在某个分片(热点key)
  3. 查询定向:常用查询条件包含分片键,让查询直接路由到目标分片而不是广播

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,最后遇到的问题:

  1. 数据膨胀:文档结构乱放,没人敢删字段
  2. 关联查询性能差:$lookup 跨集合关联很慢
  3. 事务限制:跨分片事务支持有限
  4. 运维复杂度:分片集群运维比 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      只投票,不存数据
    ↓
写入成功后返回客户端

主节点挂了怎么办?

  1. 从节点通过心跳检测到主节点不可达
  2. 符合条件的从节点发起选举
  3. 仲裁节点投票,得票多的成为新主节点
  4. 应用自动重连新主节点

Arbiter 节点:不存储数据,只参与投票。用于奇数节点时打破平票。