--- title: "02-mongoDB" created: 2026-03-18 tags: - 项目筑基 --- # mongoDB ## 视频: [🎬 B站视频 BV1qyebzyEAj](https://player.bilibili.com/player.html?bvid=BV1qyebzyEAj&high_quality=1&autoplay=0#video-iframe) ## 理论篇 ### **一:MongoDB 是什么?为什么需要它?** #### **1.1 从一个真实问题说起** 想象你在运营一款游戏,有**十多亿用户数据**需要存储。如果用 MySQL: ```sql -- MySQL 的痛点:表结构是固定的 CREATE TABLE users ( id INT PRIMARY KEY, name VARCHAR(100), level INT, -- 未来可能要加的字段?只能预留... reserved_field_1 VARCHAR(255), reserved_field_2 VARCHAR(255), reserved_field_3 VARCHAR(255) -- 浪费空间! ); -- 某天产品说:"给VIP用户加个专属徽章字段" -- → 需要 ALTER TABLE,十亿行数据的表改结构?噩梦! ``` **MongoDB 的回答:不需要固定表结构。** ```javascript // 普通用户 { "_id": 1, "name": "张三", "level": 10 } // VIP用户 —— 直接多加字段,无需改结构 { "_id": 2, "name": "李四", "level": 50, "vip": true, "badge": "黄金徽章" } // 两条数据可以有不同的字段,和平共处在同一个集合中 ``` #### **1.2 核心概念映射** ![[diagram-1773802384732-4a5de46b.png]] | **MySQL 概念** | **MongoDB 概念** | **说明** | | --- | --- | --- | | Database | Database | 完全一致 | | Table(表) | Collection(集合) | 集合不需要预定义结构 | | Row(行) | Document(文档) | 文档是类 JSON 格式 | | Column(列) | Field(字段) | 文档间字段可以不一致 | | PRIMARY KEY | `_id` | 每个文档自动拥有 `_id` 主键 | #### **1.3 文档(Document)—— MongoDB 的灵魂** 文档是 MongoDB 的基本数据单元,长得像 JSON: ```json { "_id": ObjectId("507f1f77bcf86cd799439011"), // 自动生成的主键 "name": "王五", "age": 28, "address": { // 可以嵌套对象 "city": "北京", "district": "海淀区" }, "hobbies": ["编程", "游泳"], // 可以包含数组 "created_at": ISODate("2026-03-18T10:00:00Z") // 支持日期类型 } ``` **关键特点:** - 每个文档必须有 `_id` 字段(不指定则自动生成 ObjectId) - 字段值支持丰富的数据类型:字符串、数字、布尔、数组、嵌套文档、日期、二进制等 - 同一集合中的文档**不要求**拥有相同的字段 #### **1.4 查询语法对比** | **操作** | **MySQL** | **MongoDB** | | --- | --- | --- | | 查询 | `SELECT * FROM users WHERE age > 25` | `db.users.find({ age: { $gt: 25 } })` | | 插入 | `INSERT INTO users (name, age) VALUES ('张三', 25)` | `db.users.insertOne({ name: "张三", age: 25 })` | | 更新 | `UPDATE users SET age = 26 WHERE name = '张三'` | `db.users.updateOne({ name: "张三" }, { $set: { age: 26 } })` | | 删除 | `DELETE FROM users WHERE age < 18` | `db.users.deleteMany({ age: { $lt: 18 } })` | ### **二:存储引擎 —— WiredTiger 深度解析** 存储引擎是数据库的"心脏",决定了数据如何在磁盘上存储和读取。MongoDB 默认使用 **WiredTiger**。 #### **2.1 BSON 格式** MongoDB 在内部并不直接存储 JSON,而是使用 **BSON(Binary JSON)**: ![[image-f278dfb2.png]] **BSON 相比 JSON 的优势:** | **特性** | **JSON** | **BSON** | | --- | --- | --- | | 格式 | 纯文本 | 二进制 | | 解析速度 | 需要解析文本 | 直接内存映射,更快 | | 数据类型 | 有限(字符串、数字等) | 丰富(增加 Date、Binary、ObjectId 等) | | 空间 | 较紧凑 | 略大(包含长度前缀便于遍历) | #### **2.2 数据页(Page)** WiredTiger 不会一条一条地读写文档,而是将数据组织成 **32KB 的数据页(Page)**![[diagram-1773802696725-534ec3e3.png]] **为什么要用数据页?** - 磁盘 I/O 的最小单位是"块",批量读写比逐条读写高效得多 - 一次读入一页,连带周围文档一起加载到内存,利用**空间局部性** #### **2.3 索引结构 —— 变种 B+ 树** ![[image-2ca6a93f.png]] MongoDB 的 B+ 树有一个重要变种——**写时复制(Copy On Write)**: **Copy On Write 的精妙之处:** - **读操作**:继续读旧版本的节点,不被阻塞 - **写操作**:复制要修改的节点,在副本上修改 - 结果:**读写互不阻塞**,高并发性能优秀 #### **2.4 缓存(Cache)** ![[image-1be49a59.png]] - WiredTiger 维护内存缓存,存放**热点数据页** - 缓存命中 → 纳秒级响应;缓存未命中 → 毫秒级磁盘 I/O - 默认使用 `(RAM - 1GB) / 2` 或 `256MB`(取较大值)作为缓存大小 #### **2.5 数据持久化机制** 这是 WiredTiger 最精密的部分,通过 **Journal + Checkpoint** 双重保障数据不丢失: ![[image-0ee6d94b.png]] **两层保障的含义:** | **机制** | **写入方式** | **触发频率** | **作用** | | --- | --- | --- | --- | | **Journal** | 顺序写入(append-only) | 每次写操作 | 崩溃恢复的"保险",记录每个操作 | | **Checkpoint** | 随机写入(批量刷脏页) | 默认每 60 秒 | 真正把数据落盘 | **崩溃恢复场景:** > 假设在两次 Checkpoint 之间服务器宕机了 → 重启后,从最后一个 Checkpoint 开始,重放 Journal 日志中的操作 → 数据恢复完成,不丢失 ### **三:Server 层架构** Server 层是连接"用户查询"和"存储引擎"的桥梁: ![[image-a1f3e0eb.png]] #### **各模块职责** | **模块** | **职责** | **类比** | | --- | --- | --- | | **连接管理模块** | 管理客户端 TCP 连接、认证 | 餐厅前台 | | **查询解析器** | 解析查询语法,检查语法错误 | 服务员确认订单 | | **查询优化器** | 选择最优索引,生成执行计划 | 厨师长决定烹饪方案 | | **执行器** | 按计划调用存储引擎接口 | 厨师执行烹饪 | **查询语句到存储引擎的映射:** ```javascript db.users.find({ age: 25 }) → WiredTiger.search() db.users.updateOne(...) → WiredTiger.update() db.users.insertOne(...) → WiredTiger.insert() db.users.deleteOne(...) → WiredTiger.remove() ``` ### **四:从单机到分布式集群** #### **4.1 单机 MongoDB 的瓶颈** ![[image-e3ccafcf.png]] 单机的三大瓶颈: - **CPU**:查询并发量大时处理不过来 - **内存**:热数据超出内存容量,缓存命中率下降 - **磁盘**:数据量过大,I/O 成为瓶颈 **解决方案 → 分布式集群** #### **4.2 分布式集群完整架构** ![[diagram-1773804390358-dcb7a505.png]] #### **4.3 三大核心组件** **① 分片(Shard)—— 水平拆分数据** ![[diagram-1773804597019-6afda0fd.png]] - 将数据按**分片键**(如 `_id`)的范围拆分到不同节点 - 每个分片只存储一部分数据,分摊存储和计算压力 - 支持**范围分片**和**哈希分片**两种策略 **② mongos 路由 —— 请求的"交通警察"** ![[image-8a20f7c6.png]] - 客户端不直接连接分片,而是连接 mongos - mongos 根据分片信息**精确路由**请求到对应分片 - 如果查询涉及多个分片,mongos 负责**合并结果** **③ 副本集(Replica Set)—— 高可用保障** ![[diagram-1773805214486-805826f2.png]] ![[image-67cf3499.png]] - 每个分片都是一个副本集(通常 1 主 + 2 副本) - **主节点**:处理所有写操作,也可处理读 - **副本节点**:通过 oplog 同步数据,可分担读操作 - **自动故障转移**:主节点宕机后,副本节点自动选举出新主节点 #### **4.4 完整的读写流程** **读流程:** ![[image-f1cc08cd.png]] **写流程:** 副本节点Journal日志WiredTiger分片主节点mongos客户端副本节点Journal日志WiredTiger分片主节点mongos客户![[image-79b39975.png]] ### **五:理论篇总结 —— 知识全景图** ![[image-8a57b98d.png]] ## **实践篇** ### **一:环境搭建** #### **1.1 安装 MongoDB(以 Docker 方式为例,最简单)** ```bash # 拉取 MongoDB 官方镜像 docker pull mongo:7.0 # 启动 MongoDB 容器 docker run -d \ --name mongodb \ -p 27017:27017 \ -e MONGO_INITDB_ROOT_USERNAME=admin \ -e MONGO_INITDB_ROOT_PASSWORD=password123 \ -v mongodb_data:/data/db \ mongo:7.0 # 验证是否启动成功 docker ps | grep mongodb ``` #### **1.2 连接 MongoDB** ```bash # 方式1:使用 mongosh(官方 Shell) docker exec -it mongodb mongosh -u admin -p password123 # 方式2:使用连接字符串(应用程序中常用) # mongodb://admin:password123@localhost:27017 ``` 进入 mongosh 后,你会看到: ```text Current Mongosh Log ID: 65f... Connecting to: mongodb://127.0.0.1:27017 Using MongoDB: 7.0.x Using Mongosh: 2.x.x test> ← 这里可以输入命令了 ``` ### **二:CRUD 基础操作** #### **2.1 数据库和集合操作** ```javascript // 查看所有数据库 show dbs // 切换/创建数据库(使用即创建) use gamedb // 查看当前数据库 db // 查看当前数据库的所有集合 show collections // 创建集合(通常不需要显式创建,插入文档时自动创建) db.createCollection("players") // 删除集合 db.players.drop() // 删除数据库 db.dropDatabase() ``` #### **2.2 插入文档(Create)** ```javascript // 插入单个文档 db.players.insertOne({ name: "张三", level: 25, server: "东方明珠", class: "战士", equipment: ["屠龙刀", "黄金甲"], stats: { hp: 5000, mp: 1000, attack: 350 }, created_at: new Date() }) // 插入多个文档 db.players.insertMany([ { name: "李四", level: 30, server: "东方明珠", class: "法师", equipment: ["寒冰法杖"], stats: { hp: 3000, mp: 5000, attack: 500 }, vip: true, // 注意:只有这个文档有 vip 字段! created_at: new Date() }, { name: "王五", level: 15, server: "星辰大海", class: "刺客", equipment: ["影刃", "暗影斗篷"], stats: { hp: 3500, mp: 2000, attack: 450 }, created_at: new Date() }, { name: "赵六", level: 42, server: "东方明珠", class: "战士", equipment: ["天罚之剑", "不灭战甲", "龙鳞盾"], stats: { hp: 8000, mp: 1500, attack: 600 }, guild: "王者之巅", // 只有这个文档有 guild 字段! created_at: new Date() } ]) ``` #### **2.3 查询文档(Read)** ```javascript // ===== 基础查询 ===== // 查询所有文档 db.players.find() // 美化输出 db.players.find().pretty() // 查询特定条件 db.players.find({ class: "战士" }) // 查询单个文档 db.players.findOne({ name: "张三" }) // ===== 比较运算符 ===== // $gt 大于 | $gte 大于等于 | $lt 小于 | $lte 小于等于 | $ne 不等于 db.players.find({ level: { $gt: 20 } }) // 等级大于20 db.players.find({ level: { $gte: 15, $lte: 30 }}) // 等级在15到30之间 // ===== 逻辑运算符 ===== // $and(默认多条件就是 AND) db.players.find({ class: "战士", level: { $gt: 20 } }) // $or db.players.find({ $or: [ { class: "法师" }, { level: { $gt: 40 } } ] }) // ===== 数组查询 ===== // 包含某个元素 db.players.find({ equipment: "屠龙刀" }) // 数组长度 db.players.find({ equipment: { $size: 3 } }) // 装备数量为3的玩家 // ===== 嵌套文档查询 ===== // 使用点号访问嵌套字段 db.players.find({ "stats.attack": { $gt: 400 } }) // ===== 字段存在性查询 ===== // 查找有 vip 字段的文档 db.players.find({ vip: { $exists: true } }) // 查找有公会的玩家 db.players.find({ guild: { $exists: true } }) // ===== 投影(选择返回的字段)===== // 只返回 name 和 level(1 表示包含,0 表示排除) db.players.find({}, { name: 1, level: 1, _id: 0 }) // ===== 排序、跳过、限制 ===== // 按等级降序排列 db.players.find().sort({ level: -1 }) // 分页:跳过前2条,取2条(第2页,每页2条) db.players.find().sort({ level: -1 }).skip(2).limit(2) // ===== 计数 ===== db.players.countDocuments({ server: "东方明珠" }) ``` #### **2.4 更新文档(Update)** ```javascript // ===== 更新单个文档 ===== // $set:设置字段值(字段不存在则创建) db.players.updateOne( { name: "张三" }, // 过滤条件 { $set: { level: 26, title: "初级勇者" } } // 更新操作 ) // $inc:数值增减 db.players.updateOne( { name: "张三" }, { $inc: { level: 5 } } // 等级 +5 ) // $unset:删除字段 db.players.updateOne( { name: "李四" }, { $unset: { vip: "" } } // 移除 vip 字段 ) // ===== 数组操作 ===== // $push:向数组添加元素 db.players.updateOne( { name: "张三" }, { $push: { equipment: "凤凰之翼" } } ) // $pull:从数组移除元素 db.players.updateOne( { name: "张三" }, { $pull: { equipment: "黄金甲" } } ) // $addToSet:向数组添加元素(不重复) db.players.updateOne( { name: "张三" }, { $addToSet: { equipment: "屠龙刀" } } // 已存在则不添加 ) // ===== 更新多个文档 ===== // 所有战士的血量 +1000 db.players.updateMany( { class: "战士" }, { $inc: { "stats.hp": 1000 } } ) // ===== upsert:不存在则插入 ===== db.players.updateOne( { name: "孙七" }, { $set: { name: "孙七", level: 1, class: "弓箭手" } }, { upsert: true } // 如果孙七不存在,就创建这个文档 ) ``` #### **2.5 删除文档(Delete)** ```javascript // 删除单个文档 db.players.deleteOne({ name: "孙七" }) // 删除多个文档 db.players.deleteMany({ level: { $lt: 10 } }) // 删除等级小于10的 // 删除所有文档(保留集合) db.players.deleteMany({}) ``` ### **三:索引 —— 性能的关键** #### **3.1 为什么需要索引?** ```text // 没有索引时:全集合扫描(Collection Scan) // 假设有 1000 万玩家,查找 name = "张三" // → 需要逐条检查所有文档 😱 // 有索引时:B+ 树查找 // → 几次树节点跳转就找到了 ⚡ ``` #### **3.2 索引操作** ```javascript // ===== 创建索引 ===== // 单字段索引(1: 升序,-1: 降序) db.players.createIndex({ name: 1 }) // 复合索引 db.players.createIndex({ server: 1, level: -1 }) // 唯一索引 db.players.createIndex({ name: 1 }, { unique: true }) // TTL 索引(文档过期自动删除,适合日志、会话等) db.sessions.createIndex( { created_at: 1 }, { expireAfterSeconds: 3600 } // 1小时后自动删除 ) // 文本索引(全文搜索) db.articles.createIndex({ content: "text" }) // ===== 查看索引 ===== db.players.getIndexes() // ===== 删除索引 ===== db.players.dropIndex({ name: 1 }) // 删除所有非 _id 索引 db.players.dropIndexes() ``` #### **3.3 执行计划分析(explain)** ```javascript // 查看查询的执行计划 db.players.find({ name: "张三" }).explain("executionStats") // 关键字段解读: // "stage": "COLLSCAN" → 全集合扫描(没用到索引,慢!) // "stage": "IXSCAN" → 使用了索引扫描(快!) // "totalDocsExamined" → 扫描了多少文档 // "executionTimeMillis" → 执行耗时(毫秒) ``` **实际示例:** ```javascript // 创建索引前 db.players.find({ level: { $gt: 20 } }).explain("executionStats") // → stage: "COLLSCAN", totalDocsExamined: 4(全表扫描) // 创建索引 db.players.createIndex({ level: 1 }) // 创建索引后 db.players.find({ level: { $gt: 20 } }).explain("executionStats") // → stage: "IXSCAN", totalDocsExamined: 3(只扫描匹配的文档) ``` ### **四:聚合管道** 聚合管道是 MongoDB 最强大的数据处理功能,类似 SQL 的 GROUP BY + 各种函数: ![[image-e1397f48.png]] #### **4.1 常用聚合操作** ```javascript // ===== 示例1:统计每个职业的玩家数量和平均等级 ===== db.players.aggregate([ // 阶段1:按职业分组,统计数量和平均等级 { $group: { _id: "$class", // 按 class 字段分组 count: { $sum: 1 }, // 计数 avgLevel: { $avg: "$level" }, // 平均等级 maxLevel: { $max: "$level" } // 最高等级 } }, // 阶段2:按数量降序排列 { $sort: { count: -1 } } ]) // 输出类似: // { _id: "战士", count: 2, avgLevel: 33.5, maxLevel: 42 } // { _id: "法师", count: 1, avgLevel: 30, maxLevel: 30 } // { _id: "刺客", count: 1, avgLevel: 15, maxLevel: 15 } // ===== 示例2:筛选 → 分组 → 投影 ===== db.players.aggregate([ // 阶段1:只看"东方明珠"服务器 { $match: { server: "东方明珠" } }, // 阶段2:按职业分组 { $group: { _id: "$class", players: { $push: "$name" }, // 收集玩家名字到数组 totalAttack: { $sum: "$stats.attack" } // 总攻击力 } }, // 阶段3:美化输出 { $project: { _id: 0, class: "$_id", players: 1, totalAttack: 1 } } ]) // ===== 示例3:$unwind 展开数组 ===== // 统计哪些装备最受欢迎 db.players.aggregate([ { $unwind: "$equipment" }, // 将数组拆成多条文档 { $group: { _id: "$equipment", count: { $sum: 1 } } }, { $sort: { count: -1 } }, { $limit: 5 } // 取前5 ]) // ===== 示例4:$lookup 关联查询(类似 SQL JOIN)===== // 假设有一个 guilds 集合 db.guilds.insertMany([ { name: "王者之巅", leader: "赵六", memberCount: 50 }, { name: "星辰战队", leader: "某人", memberCount: 30 } ]) db.players.aggregate([ { $lookup: { from: "guilds", // 关联的集合 localField: "guild", // 本集合的字段 foreignField: "name", // 目标集合的字段 as: "guildInfo" // 输出字段名 } }, { $match: { guild: { $exists: true } } } ]) ``` ### **五:pymongo —— 在 Python 里操作 MongoDB** mongosh 里的语法到了 Python 里就是 pymongo(`pip install pymongo`),套路一一对应,只是包了一层方法调用。 **连接与库 / 集合的选择**: ```python from pymongo import MongoClient client = MongoClient("mongodb://localhost:27017/") # 库和集合都不用提前建:不存在时第一次写入会自动创建(惰性创建) db = client["game"] # 等价于 mongosh 的 use game players = db["players"] # 等价于 db.players ``` **CRUD 对照**: ```python # 插入:insertOne → insert_one result = players.insert_one({"name": "张三", "class": "战士", "level": 42}) print(result.inserted_id) # 自动生成的 ObjectId players.insert_many([ # insertMany → insert_many {"name": "李四", "class": "法师", "level": 30}, {"name": "王五", "class": "刺客", "level": 15}, ]) # 查询:find() 返回游标,直接 for 遍历;find_one 返回第一条文档 for p in players.find({"level": {"$gte": 30}}, {"name": 1, "level": 1, "_id": 0}): print(p) # 投影 {字段: 1/0} 和 mongosh 完全一样 one = players.find_one({"name": "张三"}) # 操作符原样搬过来:$gt / $in / $or 一个都不变,链上 sort / limit / skip 即可 rows = list(players.find({"level": {"$gt": 20}}).sort("level", -1).limit(10).skip(5)) count = players.count_documents({"class": "战士"}) # 更新:updateOne → update_one,$set / $inc 照旧 res = players.update_one({"name": "张三"}, {"$set": {"level": 43}}) print(res.modified_count) # 实际改了几条,一眼可见 players.update_many({"class": "战士"}, {"$inc": {"level": 1}}) # upsert:查不到就插入,加 upsert=True players.update_one({"name": "赵六"}, {"$set": {"level": 1}}, upsert=True) # 删除 players.delete_one({"name": "赵六"}) players.delete_many({"level": {"$lt": 10}}) ``` **聚合管道:把 mongosh 的数组原样传给 aggregate**: ```python pipeline = [ {"$match": {"server": "东方明珠"}}, {"$group": {"_id": "$class", "count": {"$sum": 1}, "avgLevel": {"$avg": "$level"}}}, {"$sort": {"count": -1}}, ] for row in players.aggregate(pipeline): print(row) ``` 三个我踩过的坑: - `_id` 存进去是 ObjectId 类型,查的时候传字符串 `"665f..."` 是查不到的,要先 `ObjectId("665f...")` 包一层(`from bson import ObjectId`)。 - Python 的 `datetime` 写进 MongoDB 会存成 **UTC**:本地时间直接存,读出来差 8 小时。要么存之前自己转 UTC,要么读出来加时区换算。 - `find()` 是游标,**只能遍历一次**,要反复用就 `list(...)` 落成列表;但数据量大时全 list 会把结果堆进内存,注意用 `skip`/`limit` 分页,或基于 `_id` 做范围查询翻页。 ### **六:事务、备份与运维速记** **多文档事务(4.0+,需副本集/分片集群,单机版不支持)**: ```python with client.start_session() as s: with s.start_transaction(): accounts.update_one({"name": "A"}, {"$inc": {"balance": -100}}, session=s) accounts.update_one({"name": "B"}, {"$inc": {"balance": 100}}, session=s) # with 正常退出自动提交,任一步抛异常自动回滚 ``` > 💡 MongoDB 的设计哲学是"**能用单文档原子性就别开事务**"——把需要一起改的数据内嵌成一个文档(03-理解篇的反范式化思路正是为此服务),事务是兜底手段不是常态。这也是它和关系型"先拆表再 JOIN"相反的根源。 **备份与恢复**: ```bash mongodump --uri="mongodb://localhost:27017" --db=game -o ./backup # 导出 BSON 目录 mongorestore --uri="mongodb://localhost:27017" --drop ./backup/game # 恢复;--drop 先清同名集合 ``` **慢查询排查三件套**: ```javascript db.players.find({"level": {"$gt": 30}}).explain("executionStats") // 重点看:totalDocsExamined 扫了多少 vs nReturned 返回几条——比值越接近 1 索引越高效 db.players.find({"level": {"$gt": 30}}).hint({"level_1"}) // 强制走指定索引做 A/B 对比 db.setProfilingLevel(1, { slowms: 100 }) // 记录 100ms 以上的慢查询 db.system.profile.find().sort({ ts: -1 }).limit(5) // 看最近的慢查询日志 ``` **Change Streams 一句话**:`collection.watch()` 监听集合的变更事件流(insert/update/delete),适合做缓存失效通知、数据同步——知道有这个东西,真用到再深挖。 ### **速查表** ```text ┌──────────────────────────────────────────────────────────────┐ │ MongoDB 操作速查表 │ ├──────────────┬───────────────────────────────────────────────┤ │ 插入 │ insertOne({}) / insertMany([{}, {}]) │ │ 查询 │ find({条件}, {投影}) / findOne({}) │ │ 更新 │ updateOne({条件}, {$set:{}}) │ │ │ updateMany({条件}, {$inc:{}}) │ │ 删除 │ deleteOne({}) / deleteMany({}) │ ├──────────────┼───────────────────────────────────────────────┤ │ 比较 │ $gt $gte $lt $lte $ne $in $nin │ │ 逻辑 │ $and $or $not $nor │ │ 数组 │ $push $pull $addToSet $size $elemMatch │ │ 字段 │ $set $unset $inc $rename $exists │ ├──────────────┼───────────────────────────────────────────────┤ │ 聚合阶段 │ $match → $group → $sort → $project → $limit │ │ │ $unwind $lookup $count $skip │ ├──────────────┼───────────────────────────────────────────────┤ │ 索引 │ createIndex({field:1}) / getIndexes() │ │ │ dropIndex() / explain("executionStats") │ └──────────────┴───────────────────────────────────────────────┘ ``` --- ⬅️ [[01-Redis 学习路线|01-Redis 学习路线]] 🏠 [[00-数据库|00-数据库]] ➡️ [[03-MongoDB 理解|03-MongoDB 理解]]