🍃 MongoDB 从零到生产

文档数据库完全教程 — 从"这是什么"到"上线要注意什么"

BSON文档模型 CRUD操作 索引策略 聚合管道 副本集 分片集群 Spring Boot集成 生产踩坑

P1. 什么是 MongoDB?— 从微信朋友圈理解文档数据库

打开你的微信朋友圈看看——每条帖子长得都不一样:
• 有的只发了一段文字
• 有的发了9张照片
• 有的是视频 + 定位 + @了几个朋友 + 配了一段文字
• 有的是转发文章 + 自己的评论

没有人逼你填一个"固定表单"——想发图片就发图片,想加定位就加定位,每条朋友圈都是自由的、不同的结构

这就是 MongoDB 的核心思想:每条记录(文档)可以有不同的字段和结构

对比一下:MySQL 就像一个 Excel 表格——固定列,每行必须填同样的列。而 MongoDB 就像朋友圈——每条"帖子"想填什么就填什么,没人管你。

📖 核心概念对照

MySQL(关系型)MongoDB(文档型)微信朋友圈类比
数据库 Database数据库 Database你的微信账号
表 Table集合 Collection你的朋友圈(所有帖子的列表)
行 Row文档 Document一条朋友圈帖子
列 Column字段 Field这条朋友圈里的内容(文字、图片、视频、定位…)
主键 Primary Key_id 字段这条朋友圈的唯一编号
外键 Foreign Key引用(Reference)@朋友(提到某个人,要点进他主页看详情)
JOIN 连接嵌入式文档 / $lookup朋友圈自带的评论(嵌入式),或者点进朋友的主页看详情(lookup)
SQL 查询语言MQL (MongoDB Query Language)不同的"语言"查朋友圈
Schema 固定Schema 灵活(可约束)Excel固定列 vs 朋友圈(每条帖子想发什么就发什么)

🔗 MySQL vs MongoDB 存储结构对比

MySQL — orders 表(固定列)
| id | product | price | cup_size |
|----|---------|-------|----------|
| 1 | 经典套餐A | 15 | 标准 |
| 2 | 芝士莓莓 | 22 | 大杯 |
❌ 无法记录"加椰果""换燕麦奶"
❌ 新增字段要 ALTER TABLE
MongoDB — orders 集合(灵活文档)
{ _id: 1, product:"经典套餐A", price:15, cup:"标准" }

{ _id: 2, product:"芝士莓莓", price:22, cup:"大杯",
  toppings:["椰果","芝士奶盖"],
  milk:"燕麦奶", sugar:"半糖",
  note:"不要吸管" }
✅ 每个文档可以不一样
✅ 不需要 ALTER TABLE

🤔 什么时候用 MongoDB?什么时候用 MySQL?

维度选 MongoDB选 MySQL
数据结构不固定、经常变、嵌套深(如JSON配置、日志、商品属性)固定、关系明确(如财务、ERP)
读写模式写多读少、大量插入(如IoT数据、用户行为日志)读多写少、复杂查询(如报表、事务)
事务需求简单事务(4.0+支持多文档事务但性能有代价)强事务、ACID严格保证
扩展方向水平扩展(分片,数据量TB级)垂直扩展(主从读写分离)
JOIN需求少JOIN、数据可嵌入多表关联、复杂JOIN
典型场景内容管理、用户画像、物联网、实时分析、聊天消息订单系统、账户系统、库存管理、报表
⚠️ 生产中常见的做法:不是非此即彼!很多项目MySQL + MongoDB 混用——核心业务数据(订单、账户)放 MySQL 保证事务;日志、配置、用户行为等非结构化数据放 MongoDB。

P2. BSON 与文档模型 — MongoDB 的"底层语言"

📖 什么是 BSON?

BSON = Binary JSON(二进制JSON)。MongoDB 不直接存 JSON,而是存 BSON。

打个比方:你在朋友圈发了一条帖子——有文字(String)、有图片(Array)、有定位(Geo坐标)、有发布时间(Date)。这些不同类型的内容在微信后台是以一种有类型标签的格式存储的。BSON 就是 MongoDB 的这个"后台格式"——它给每个字段标上了类型(字符串、数字、日期、数组…),并且用二进制编码,让计算机读起来更快。
特性JSONBSON
格式文本(字符串)二进制
支持的数据类型string, number, boolean, null, array, object上面全部 + Date, ObjectId, BinData, Regex, Decimal128, 32位整数, 64位整数 等
遍历速度慢(要解析字符串)快(每个元素前有长度前缀,可直接跳过)
体积较小略大(多了类型信息和长度前缀)

🔗 JSON → BSON 转换示意

原始 JSON 文档
{
  "name": "经典套餐A",
  "price": 15,
  "inStock": true,
  "tags": ["热卖","经典"]
}
BSON 二进制结构(简化)
[4字节 总长度]
"name" : String  : "经典套餐A"
"price" : Number : 15
"inStock" : Boolean : true
"tags" : Array : ["热卖","经典"]
[结束标记 0x00]

📋 BSON 支持的数据类型(工作中常用的标 ⭐)

类型示例说明
⭐ String"hello"UTF-8 字符串,最常用
⭐ Int32 / Int644232位或64位整数
⭐ Double3.1464位浮点数
⭐ Booleantrue / false布尔值
⭐ Array["a","b","c"]数组,可以包含不同类型元素
⭐ Object (嵌入式文档){key: value}嵌套的子文档
⭐ ObjectIdObjectId("65a1...")12字节唯一ID,默认的 _id 类型
⭐ DateISODate("2025-01-01")UTC 日期时间(毫秒精度)
Nullnull空值
Decimal128NumberDecimal("9.99")高精度小数(金融场景用)
BinDataBinData(0,"base64...")二进制数据(存图片/文件)
Regex/pattern/flags正则表达式
TimestampTimestamp(1234567890, 1)内部复制用的时间戳

🔍 ObjectId 的组成(12字节 = 24位十六进制)

ObjectId 就像你朋友圈每条帖子的"唯一编号"。它里面藏了三个信息:你的用户ID(哪台机器发的)、发帖时间、一个随机数(保证同一秒发的帖子不会重复)。
ObjectId("65a1b2c3d4e5f6a7b8c9d0")
前4字节
时间戳
创建时间的秒数
65a1b2c3 → 2025-01-12
中间5字节
随机值
机器标识+进程ID
每台机器不同
后3字节
递增计数器
同一秒内递增
保证唯一性
💡 生产小知识:ObjectId 自带时间戳!你可以通过 ObjectId.getTimestamp() 获取文档创建时间,不需要额外存 create_time 字段。

📦 文档模型的两种设计方式

方式一:嵌入式文档(Embedding)— 把关联数据放在同一个文档里

一个订单文档(内嵌顾客+商品)
{
  "_id": ObjectId("65a1..."),
  "orderNo": "ORD20250112001",
  "customer": {            ← 内嵌
    "name": "张三",
    "phone": "138****1234"
  },
  "items": [               ← 内嵌数组
    {
      "product": "经典套餐A",
      "price": 15,
      "qty": 2
    },
    {
      "product": "芝士莓莓",
      "price": 22,
      "qty": 1
    }
  ],
  "totalAmount": 52,
  "status": "paid"
}
✅ 优点:一次查询拿到所有数据,无需 JOIN
❌ 缺点:文档不能无限大(最大 16MB),更新内嵌数组可能并发冲突

方式二:引用方式(Referencing)— 类似 MySQL 的外键

orders 集合
{
  "_id": ObjectId("aaa..."),
  "orderNo": "ORD001",
  "customerId": ObjectId("bbb..."),
  "items": [ ... ],
  "totalAmount": 52
}
customers 集合
{
  "_id": ObjectId("bbb..."),
  "name": "张三",
  "phone": "138****1234",
  "address": "科技路88号"
}
✅ 优点:文档小,适合频繁更新的数据、一对多很多的情况
❌ 缺点:需要 $lookup(类似 JOIN)或应用层多次查询
📐 选择原则:"一起读取的数据放一起(嵌入),分别更新的数据分开存(引用)"。这是 MongoDB Schema 设计的核心思想。

P3. CRUD 操作 — 增删改查从零开始

CRUD 操作就像操作一个没有固定列的 Excel 表

普通 Excel 表:每列都是定好的(A列=姓名,B列=年龄),你不能第一行填3列、第二行填7列。
MongoDB 的"Excel":每一行想填几列就填几列!第一行可以只有品名和价格,第二行可以再加上配料、甜度、备注、送餐地址…没人管你。

insertOne = 往 Excel 里加一行,想填几列就填几列
find = 在 Excel 里筛选(但支持嵌套条件,比普通筛选强得多)
update = 修改某个单元格(⚠️ 但记住用 $set 只改这一格,忘了 $set 会清空整行重写!)
delete = 删除一行

➕ Insert — 插入数据

// 切换/创建数据库(use 时自动创建) > use milk_tea_shop switched to db milk_tea_shop // 插入一条文档 > db.orders.insertOne({ orderNo: "ORD20250112001", customer: { name: "张三", phone: "138****1234" }, items: [ { product: "经典套餐A", price: 15, qty: 2 }, { product: "芝士莓莓", price: 22, qty: 1 } ], totalAmount: 52, status: "paid", createdAt: new Date() }) { "acknowledged": true, "insertedId": ObjectId("65a1b2c3d4e5f6a7b8c9d0") } // 批量插入 > db.orders.insertMany([ { orderNo: "ORD002", totalAmount: 30, status: "pending" }, { orderNo: "ORD003", totalAmount: 45, status: "paid" }, { orderNo: "ORD004", totalAmount: 18, status: "cancelled" } ]) { "acknowledged": true, "insertedIds": [ ObjectId("65a1b2c3..."), ObjectId("65a1b2c4..."), ObjectId("65a1b2c5...") ] }
⚠️ 注意:insertMany 默认是有序的(ordered: true),中间某条失败会停止后续插入。如果不在乎顺序,用 insertMany([...], {ordered: false}) 让成功的先插入。

🔍 Find — 查询数据

// 查询所有 > db.orders.find({}) // 条件查询:status 为 paid > db.orders.find({ status: "paid" }) // 比较操作符 > db.orders.find({ totalAmount: { $gt: 30 } }) // 大于 30 > db.orders.find({ totalAmount: { $gte: 30 } }) // 大于等于 > db.orders.find({ totalAmount: { $lt: 50 } }) // 小于 > db.orders.find({ totalAmount: { $lte: 50 } }) // 小于等于 > db.orders.find({ totalAmount: { $ne: 52 } }) // 不等于 // 范围查询 > db.orders.find({ totalAmount: { $gte: 20, $lte: 50 } }) // IN 查询 > db.orders.find({ status: { $in: ["paid", "shipped"] } }) // AND 条件(逗号分隔就是 AND) > db.orders.find({ status: "paid", totalAmount: { $gt: 30 } }) // OR 条件 > db.orders.find({ $or: [ { status: "paid" }, { totalAmount: { $lt: 20 } } ] }) // 查询内嵌文档的字段(用点号) > db.orders.find({ "customer.name": "张三" }) // 查询数组中的元素 > db.orders.find({ "items.product": "经典套餐A" })

🎯 投影 — 只返回需要的字段

// 只返回 orderNo 和 totalAmount(_id 默认返回) > db.orders.find( { status: "paid" }, { orderNo: 1, totalAmount: 1 } ) { _id: ObjectId("..."), orderNo: "ORD001", totalAmount: 52 } // 排除 _id > db.orders.find( { status: "paid" }, { _id: 0, orderNo: 1, totalAmount: 1 } ) { orderNo: "ORD001", totalAmount: 52 } // 排除某些字段 > db.orders.find({}, { items: 0, customer: 0 }) { _id: ..., orderNo: "ORD001", totalAmount: 52, status: "paid", createdAt: ... }

📝 Update — 更新数据

// ⚠️ 错误写法!这会把整个文档替换成 {status:"shipped"} > db.orders.updateOne({ orderNo: "ORD001" }, { status: "shipped" }) // 结果:文档变成 { _id:..., status: "shipped" } — 其他字段全没了! // ✅ 正确写法:用 $set 只更新指定字段 > db.orders.updateOne( { orderNo: "ORD001" }, { $set: { status: "shipped", updatedAt: new Date() } } ) { acknowledged: true, matchedCount: 1, modifiedCount: 1 } // 常用更新操作符 > db.orders.updateOne( { orderNo: "ORD001" }, { $set: { status: "delivered" }, // 设置字段值 $inc: { totalAmount: 5 }, // 数字加减(+5) $push: { "items": { product: "布丁", price: 3, qty: 1 } }, // 数组末尾追加 $unset: { note: "" } // 删除字段 } ) // 更新多条 > db.orders.updateMany( { status: "pending" }, { $set: { status: "cancelled", cancelReason: "超时未支付" } } ) // 找不到就插入(upsert) > db.orders.updateOne( { orderNo: "ORD999" }, { $set: { totalAmount: 100, status: "new" } }, { upsert: true } // 没找到就自动 insert ) { acknowledged: true, upsertedId: ObjectId("..."), matchedCount: 0, modifiedCount: 0 }
⚠️ 生产必看:忘记 $set 的后果
updateOne({_id:xxx}, {name:"新名字"}) — 没有 $set!
结果:整个文档变成 {_id:xxx, name:"新名字"},其他字段全部丢失!

这是 MongoDB 新手最常犯的错误之一。记住:更新操作一定要带操作符($set、$inc、$push 等)!

❌ Delete — 删除数据

// 删除一条 > db.orders.deleteOne({ orderNo: "ORD004" }) { acknowledged: true, deletedCount: 1 } // 删除多条 > db.orders.deleteMany({ status: "cancelled" }) { acknowledged: true, deletedCount: 5 } // ⚠️ 危险!删除整个集合的所有文档 > db.orders.deleteMany({})
⚠️ 生产建议:和 MySQL 一样,生产环境用软删除(加 deleted: true 字段),不要真删!

P4. 索引策略 — 让查询从 10 秒变 10 毫秒

📖 为什么需要索引?

没有索引 = 全集合扫描(Collection Scan)

文档1
文档2
文档3
文档4 ✅
文档5
...
文档100万
❌ 查 {status:"paid"} 要从头翻到尾,100万条全看一遍

有索引 = B-Tree 快速定位

索引(按 status 排序)
cancelled → [文档3, 文档7, ...]
paid → [文档1, 文档4, 文档8]
pending → [文档2, 文档5, ...]
shipped → [文档6, ...]
✅ 直接跳到 "paid" 那一页,只看 3 条,秒出结果

🔧 索引操作

// 创建单字段索引(1 = 升序, -1 = 降序) > db.orders.createIndex({ status: 1 }) "status_1" // 创建复合索引(多字段,顺序很重要!) > db.orders.createIndex({ status: 1, createdAt: -1 }) "status_1_createdAt_-1" // 创建唯一索引 > db.orders.createIndex({ orderNo: 1 }, { unique: true }) // 查看所有索引 > db.orders.getIndexes() [ { v: 2, key: { _id: 1 }, name: "_id_" }, ← 默认主键索引 { v: 2, key: { status: 1 }, name: "status_1" }, { v: 2, key: { status: 1, createdAt: -1 }, name: "status_1_createdAt_-1" } ] // 查看查询是否用了索引(重要!生产必用) > db.orders.find({ status: "paid" }).explain("executionStats") { "winningPlan": { "stage": "FETCH", "inputStage": { "stage": "IXSCAN", ← 用了索引扫描 ✅ "indexName": "status_1" } }, "executionStats": { "totalDocsExamined": 3, ← 只看了 3 条 "executionTimeMillis": 1 ← 1 毫秒 } } // 没有索引时 explain 会显示: { "winningPlan": { "stage": "COLLSCAN" ← 全集合扫描 ❌ }, "executionStats": { "totalDocsExamined": 1000000, ← 看了 100 万条 "executionTimeMillis": 856 ← 856 毫秒 } } // 删除索引 > db.orders.dropIndex("status_1")

📋 MongoDB 索引类型大全

索引类型创建语法适用场景说明
单字段索引 {field: 1} 按单个字段查询 最基础,字段值可以是标量或数组
复合索引 {a: 1, b: -1} 多字段组合查询 遵循最左前缀原则(和 MySQL 类似)
多键索引(Multikey) 自动创建 数组字段查询 字段是数组时自动变多键索引
文本索引(Text) {$text: { ... }} 全文搜索 支持分词搜索,但不如 ES 专业
地理空间索引 2dsphere / 2d 附近的人、门店搜索 $near, $geoWithin 查询
哈希索引 {field: "hashed"} 分片键(等值查询) 只支持等值查询,不支持范围
TTL 索引 expireAfterSeconds: 3600 自动过期删除(日志、session) 后台线程每60秒检查一次删除
唯一索引 {unique: true} 订单号、用户名等唯一约束 和 MySQL UNIQUE KEY 一样
部分索引(Partial) partialFilterExpression 只对满足条件的文档建索引 节省空间,如只对 status="active" 建索引

📐 复合索引的最左前缀原则

索引: {status: 1, createdAt: -1, totalAmount: 1}

{status} ✅ + {status, createdAt} ✅ + {status, createdAt, totalAmount} ✅
{createdAt} ❌ + {createdAt, totalAmount} ❌ + {status, totalAmount} ⚠️ 只用 status
规则:查询条件必须从索引最左字段开始,中间不能跳过。
ESR 原则:索引字段排序 = Equality(等值)→ Sort/Range(排序/范围)
→ 先放等值查询的字段,再放排序/范围的字段

⏱️ TTL 索引实战 — 自动清理过期数据

// 给 login_logs 的 createdAt 字段加 TTL,24小时后自动删除 > db.login_logs.createIndex( { createdAt: 1 }, { expireAfterSeconds: 86400 } // 86400秒 = 24小时 ) // 插入一条日志 > db.login_logs.insertOne({ userId: "u001", ip: "192.168.1.100", createdAt: new Date() }) // 24小时后,这条日志会被 MongoDB 后台线程自动删除! // 不需要写定时任务去清理
💡 生产用途:验证码、Session、临时Token、日志等有过期时间的数据,用 TTL 索引最省心。

P5. 聚合管道(Aggregation Pipeline)— MongoDB 的"数据分析神器"

聚合管道就像工厂的流水线:原材料(文档)进入管道,经过一道道工序(阶段),最后输出成品(统计结果)。

朋友圈的例子:你想知道"每种朋友圈这个月卖了多少杯、赚了多少钱"——这就需要聚合管道。

如果你用过 Excel 数据透视表,聚合管道就是它的"代码版":
$match = 先筛选(只看已支付的订单)
$group = 数据透视表的"分组"功能(按商品名分组,求和)
$sort = 排序(按金额从高到低)
$project = 只显示某些列(隐藏不需要的列)

同样的思路,只是从拖拽变成了写代码。
如果你会用 Excel 数据透视表,那聚合管道就是"代码版的数据透视表":
$match = 筛选(只看朋友圈类的数据)
$group = 数据透视表里的"行标签"(按 product 分组)
$sum / $avg = 数据透视表里的"值字段"(求和/平均)
$sort = 排序
$project = 只显示某些列

🔗 聚合管道 — 数据像水一样流过管道

输入文档 $match 过滤 $group 分组 $sort 排序 $project 投影 输出结果

📊 实战1:统计每种朋友圈的销售量和销售额

// 订单文档结构: // { orderNo, items: [{product, price, qty}], status, createdAt } > db.orders.aggregate([ // 第1步:先过滤出已支付的订单 { $match: { status: "paid", createdAt: { $gte: ISODate("2025-01-01") } } }, // 第2步:把 items 数组"展开"成多条记录(一个item一条) { $unwind: "$items" }, // 第3步:按商品名分组,计算总杯数和总金额 { $group: { _id: "$items.product", // 按商品名分组 totalQty: { $sum: "$items.qty" }, // 总杯数 totalRevenue: { $sum: { $multiply: ["$items.price", "$items.qty"] } }, // 总金额 orderCount: { $sum: 1 } // 订单数 } }, // 第4步:按总金额降序排列 { $sort: { totalRevenue: -1 } }, // 第5步:只输出需要的字段,改名更好看 { $project: { _id: 0, product: "$_id", totalQty: 1, totalRevenue: 1, orderCount: 1 } } ]) // 执行结果: [ { product: "经典套餐A", totalQty: 856, totalRevenue: 12840, orderCount: 423 }, { product: "芝士莓莓", totalQty: 512, totalRevenue: 11264, orderCount: 298 }, { product: "精选套餐B", totalQty: 340, totalRevenue: 9520, orderCount: 187 } ]

📊 实战2:每天的订单量和收入趋势

> db.orders.aggregate([ { $match: { status: "paid", createdAt: { $gte: ISODate("2025-01-01") } } }, { $group: { _id: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } }, orderCount: { $sum: 1 }, dailyRevenue: { $sum: "$totalAmount" } } }, { $sort: { _id: 1 } } ]) [ { _id: "2025-01-01", orderCount: 45, dailyRevenue: 2340 }, { _id: "2025-01-02", orderCount: 52, dailyRevenue: 2860 }, { _id: "2025-01-03", orderCount: 38, dailyRevenue: 1920 } ]

📊 实战3:$lookup — MongoDB 的"JOIN"

// 订单集合引用顾客ID,想查出订单+顾客信息 > db.orders.aggregate([ { $lookup: { from: "customers", // 关联的集合 localField: "customerId", // 当前集合的字段 foreignField: "_id", // 目标集合的字段 as: "customerInfo" // 结果放到哪个字段 } }, { $unwind: "$customerInfo" }, // 把数组展开成对象 { $project: { orderNo: 1, totalAmount: 1, "customerInfo.name": 1, "customerInfo.phone": 1 } } ]) [ { orderNo: "ORD001", totalAmount: 52, customerInfo: { name: "张三", phone: "138****1234" } } ]

📋 常用聚合阶段速查

阶段作用SQL 等价
$match过滤文档WHERE
$group分组聚合GROUP BY + SUM/COUNT/AVG
$sort排序ORDER BY
$project选择/重命名字段SELECT
$limit限制条数LIMIT
$skip跳过条数OFFSET
$unwind展开数组无直接等价(JOIN + 行展开)
$lookup关联查询LEFT JOIN
$count统计文档数SELECT COUNT(*)
$addFields添加/覆盖字段SELECT ... AS ...
$facet多管道并行多个子查询 UNION
$bucket分桶统计CASE WHEN 范围分组

P6. 副本集(Replica Set)— MongoDB 的高可用方案

MySQL 有主从复制(一主多从),MongoDB 有副本集(Replica Set)。本质差不多:一个主节点写,多个从节点读,主挂了自动切换。

🔗 副本集架构图

🟢 Primary(主节点)
接收所有写操作
默认也处理读操作
↕️ oplog 复制
🔵 Secondary 1
复制主节点数据
可配置为可读
🔵 Secondary 2
复制主节点数据
可配置为可读
⚪ Arbiter(仲裁节点)
不存数据
只参与投票选举
心跳机制:节点之间每2秒互发心跳,超过10秒没回应 → 认为节点挂了
自动故障转移:Primary 挂了 → 剩余节点投票选举新 Primary(通常10秒内完成)

📋 oplog(操作日志)— 复制的核心

客户端写入 Primary 记录 oplog Secondary 拉取 oplog 回放操作到自己的数据

oplog 是一个固定大小的集合(capped collection),存在 local.oplog.rs
它记录了 Primary 上的每一个写操作(insert/update/delete)。Secondary 不断从 Primary 拉取 oplog 并回放,实现数据同步。

⚠️ 注意:oplog 是固定大小(默认磁盘空间的5%),如果 Secondary 停机太久,oplog 被覆盖了,就需要全量重新同步。

🔧 副本集搭建(开发环境快速版)

// 1. 创建数据目录 $ mkdir -p /data/rs0-0 /data/rs0-1 /data/rs0-2 // 2. 启动3个 mongod 进程 $ mongod --replSet rs0 --port 27017 --dbpath /data/rs0-0 $ mongod --replSet rs0 --port 27018 --dbpath /data/rs0-1 $ mongod --replSet rs0 --port 27019 --dbpath /data/rs0-2 // 3. 连接到第一个节点,初始化副本集 $ mongosh --port 27017 > rs.initiate({ _id: "rs0", members: [ { _id: 0, host: "localhost:27017" }, { _id: 1, host: "localhost:27018" }, { _id: 2, host: "localhost:27019" } ] }) { ok: 1 } // 4. 查看副本集状态 > rs.status() { "members": [ { name: "localhost:27017", stateStr: "PRIMARY", ... }, ← 主节点 { name: "localhost:27018", stateStr: "SECONDARY", ... }, ← 从节点 { name: "localhost:27019", stateStr: "SECONDARY", ... } ← 从节点 ] }

⚙️ 读写分离配置

// Spring Boot 连接副本集的 URI spring.data.mongodb.uri=mongodb://user:pass@host1:27017,host2:27017,host3:27017/mydb?replicaSet=rs0 // 读偏好(Read Preference) // primary — 只从主节点读(默认,数据最新) // primaryPreferred — 优先主节点,主不可用才读从 // secondary — 只从从节点读(减轻主节点压力) // secondaryPreferred — 优先从节点,没有才读主 // nearest — 读网络延迟最低的节点 // URI 中设置读偏好 spring.data.mongodb.uri=mongodb://host1,host2,host3/mydb?replicaSet=rs0&readPreference=secondaryPreferred // 写关注(Write Concern) // w: 1 — 主节点确认即可(默认) // w: "majority" — 大多数节点确认才返回(更安全,更慢) // w: 3 — 3个节点都确认 spring.data.mongodb.uri=mongodb://host1,host2,host3/mydb?w=majority
⚠️ 生产建议:写操作用 w: "majority" 确保数据不丢(至少大多数节点都写成功了)。读操作如果可以接受短暂不一致,用 secondaryPreferred 减轻主节点压力。

P7. 分片集群(Sharding)— MongoDB 的水平扩展方案

单机 MongoDB 数据量到 TB 级了?一台机器存不下、读写也扛不住了?
分片 = 把数据拆到多台机器上,每台只存一部分数据。

类比:朋友圈生意太好了,一家店忙不过来,在不同区开分店(分片),每个分店负责附近的订单。

🔗 分片集群架构(三大组件)

📱 应用程序(mongos)
连接 mongos 路由
不知道数据在哪个分片
mongos 自动转发
⬇️
🗺️ Config Server(配置服务器)
存元数据:
哪些数据在哪个分片
chunk 范围映射
也是一个副本集(3节点)
⬇️
🟢 Shard 1
副本集
存userId 1~1000
🟢 Shard 2
副本集
存userId 1001~2000
🟢 Shard 3
副本集
存userId 2001~3000

📐 分片键(Shard Key)— 最关键的选择!

分片键决定了数据怎么分配到不同分片。选错了,性能灾难!

分片键策略示例优点缺点适用场景
范围分片 {userId: 1} 支持范围查询 可能数据倾斜(热点) 按时间/ID范围查询多
哈希分片 {userId: "hashed"} 数据分布均匀 范围查询要查所有分片 等值查询为主,写量大
复合分片键 {region: 1, userId: 1} 先按地区分再按ID 选择复杂 多维度查询
// 启用分片 > sh.enableSharding("milk_tea_shop") // 对 orders 集合启用分片,用哈希分片 > sh.shardCollection("milk_tea_shop.orders", { userId: "hashed" }) // 查看分片状态 > sh.status() --- Sharding Status --- shards: { _id: "shard01", host: "shard01/host1:27017", state: 1 } { _id: "shard02", host: "shard02/host2:27017", state: 1 } databases: { _id: "milk_tea_shop", partitioned: true, primary: "shard01" } milk_tea_shop.orders shard key: { "userId": "hashed" } chunks: shard01 34 shard02 33 shard03 33
⚠️ 分片键一旦选定就不能改!
MongoDB 4.4 之前分片键完全不能改。4.4+ 可以 refine(添加后缀字段),5.0+ 可以重新分片(resharding),但成本很高。
选分片键要考虑:
  1. 基数(Cardinality):分片键的值要足够多。如果只有3个值,再怎么分也只有3个chunk。
  2. 分布均匀:写入要均匀分布到各分片。用自增ID做分片键 → 所有写入都打到最后一个分片(热点)。
  3. 查询隔离:尽量让查询只打一个分片。如果查询条件不包含分片键,就要广播到所有分片(scatter-gather)。

📦 Chunk — 数据分片的最小单元

数据写入 按分片键分配到 Chunk Chunk 默认 128MB Chunk 太大?拆分(Split) Chunk 不均?迁移(Migrate)

Chunk 是 MongoDB 内部的数据分片单元。每个 Chunk 包含一个分片键值范围内的所有文档。
默认大小 128MB。当一个 Chunk 超过这个大小时,MongoDB 自动拆分成两个。
当某些分片的 Chunk 数量明显多于其他分片时,Balancer(均衡器)会自动迁移 Chunk 到少的分片。

P8. Schema 设计模式 — 怎么设计才好用?

📐 核心原则:"一起读的放一起,分别更新的分开存"

用微信朋友圈来理解这两种模式:

嵌入式(Embedding) = 朋友圈自带的评论和点赞
你打开一条朋友圈,评论和点赞就在帖子里面,不用再去别的地方找。一步到位,打开就有。
→ MongoDB 里就是把关联数据直接嵌在同一个文档里,一次查询全拿到。

引用式(Referencing) = 评论里 @了另一个朋友,要点进他主页才能看详情
帖子里只存了一个"引用"(朋友的ID),要看完整信息得去他的主页(另一个集合)查。
→ MongoDB 里就是只存 ID,需要时用 $lookup 去另一个集合查。

常见设计模式对比

模式1:嵌入(Embedding)
// 用户资料 + 地址放一起
{
  _id: "u001",
  name: "张三",
  addresses: [
    { city: "北京", detail: "科技路88号", isDefault: true },
    { city: "上海", detail: "南京路100号", isDefault: false }
  ]
}
✅ 一次查询全拿到
✅ 无需 JOIN
❌ 地址多时文档大
❌ 并发改地址可能冲突
模式2:引用(Referencing)
// 用户和地址分开存
// users 集合
{ _id: "u001", name: "张三" }

// addresses 集合
{ _id: "a001", userId: "u001", city: "北京", detail: "科技路88号" }
{ _id: "a002", userId: "u001", city: "上海", detail: "南京路100号" }
✅ 文档小
✅ 地址独立更新无冲突
❌ 需要 $lookup 或两次查询
❌ 读取稍慢

📋 6 种常见 Schema 设计模式

模式说明示例什么时候用
嵌入式文档 关联数据放同一文档 订单内嵌商品明细 1对少量(1:1~1:5),一起读,很少分别更新
引用模式 用 ID 关联不同集合 用户 → 地址集合 1对很多,数据经常单独更新
子集模式 只嵌入最近/最常用的N条 用户嵌入最近5条评论,其余放评论集合 1对非常多(1:1000+),但只需要热点数据
属性模式 动态属性用 key-value 数组 attrs: [{k:"颜色",v:"红"},{k:"尺码",v:"XL"}] 商品属性不确定(不同类目属性不同)
桶模式 一段时间的数据放一个文档 一条文档存一小时的 IoT 数据数组 时序数据、监控指标
多态模式 同一集合存不同类型的文档 products 集合存朋友圈/咖啡/甜品 有共同字段但各自有特有字段

🧪 属性模式实战:商品属性不确定怎么办?

// 朋友圈类商品 { _id: "p001", name: "经典套餐A", category: "朋友圈", price: 15, attrs: [ { k: "茶底", v: "红茶" }, { k: "甜度", v: "全糖" }, { k: "温度", v: "冷饮" } ] } // 咖啡类商品 — 完全不同的属性 { _id: "p002", name: "美式咖啡", category: "咖啡", price: 22, attrs: [ { k: "烘焙度", v: "中深" }, { k: "杯量", v: "大杯" }, { k: "咖啡因", v: "高" } ] } // 创建索引让属性可查 > db.products.createIndex({ "attrs.k": 1, "attrs.v": 1 }) // 查询:茶底是红茶的所有商品 > db.products.find({ attrs: { $elemMatch: { k: "茶底", v: "红茶" } } })

🧪 桶模式实战:IoT 温度传感器数据

// 不好的设计:每条数据一个文档 → 1小时3600条文档,太多了 { sensorId: "S001", ts: ISODate("2025-01-12T10:00:01Z"), temp: 25.1 } { sensorId: "S001", ts: ISODate("2025-01-12T10:00:02Z"), temp: 25.2 } ... // 3600 条! // ✅ 桶模式:一个文档存一整小时的数据 { sensorId: "S001", hour: ISODate("2025-01-12T10:00:00Z"), measurements: [ { ts: ISODate("...T10:00:01Z"), temp: 25.1 }, { ts: ISODate("...T10:00:02Z"), temp: 25.2 }, ... // 3600 条测量值放一个数组里 ], minTemp: 24.8, maxTemp: 26.3, avgTemp: 25.5 } // 文档数从 3600 → 1!查询速度快很多

P9. Spring Boot 集成 — 在 Java 项目中用 MongoDB

📦 1. 引入依赖

<!-- pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-mongodb</artifact> </dependency> <!-- 如果用响应式(WebFlux)--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId> </dependency>

⚙️ 2. 配置连接

# application.yml spring: data: mongodb: # 单节点连接 uri: mongodb://localhost:27017/milk_tea_shop # 副本集连接(推荐生产用) # uri: mongodb://user:pass@host1:27017,host2:27017,host3:27017/milk_tea_shop?replicaSet=rs0&authSource=admin # 连接池配置(重要!) # spring.data.mongodb.uri 不支持所有参数时,用下面的方式 # 更详细的配置(MongoClientSettings) spring: data: mongodb: host: localhost port: 27017 database: milk_tea_shop username: app_user password: ${MONGO_PASSWORD} authentication-database: admin

📝 3. 定义实体(Document)

import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; import java.time.LocalDateTime; import java.util.List; @Document(collection = "orders") // 映射到 orders 集合 public class Order { @Id // MongoDB 的 _id 字段 private String id; @Field("order_no") // 自定义字段名(默认用 Java 字段名) private String orderNo; private String status; private Double totalAmount; // 内嵌对象 private CustomerInfo customer; private List<OrderItem> items; private LocalDateTime createdAt; private LocalDateTime updatedAt; // getter/setter 省略... } // 内嵌的对象不需要 @Document 注解 public class CustomerInfo { private String name; private String phone; } public class OrderItem { private String product; private Double price; private Integer qty; }

🔍 4. 使用 MongoTemplate(最灵活的方式)

import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; import org.springframework.stereotype.Service; import java.util.List; @Service public class OrderService { private final MongoTemplate mongoTemplate; public OrderService(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } // ===== 查询 ===== // 按状态查订单 public List<Order> findByStatus(String status) { Query query = new Query(Criteria.where("status").is(status)); return mongoTemplate.find(query, Order.class); } // 组合条件:状态 + 金额范围 + 时间 public List<Order> findPaidOrders(Double minAmount, LocalDateTime since) { Query query = new Query(); query.addCriteria(Criteria.where("status").is("paid") .and("totalAmount").gte(minAmount) .and("createdAt").gte(since)); query.with(Sort.by(Sort.Direction.DESC, "totalAmount")); query.limit(50); return mongoTemplate.find(query, Order.class); } // 查内嵌字段 public List<Order> findByCustomerName(String name) { Query query = new Query( Criteria.where("customer.name").is(name) ); return mongoTemplate.find(query, Order.class); } // ===== 更新 ===== public void updateStatus(String orderNo, String newStatus) { Query query = new Query(Criteria.where("orderNo").is(orderNo)); Update update = new Update(); update.set("status", newStatus); update.set("updatedAt", LocalDateTime.now()); mongoTemplate.updateFirst(query, update, Order.class); } // 数组操作:往订单里追加商品 public void addItem(String orderNo, OrderItem item) { Query query = new Query(Criteria.where("orderNo").is(orderNo)); Update update = new Update(); update.push("items", item); // 追加到数组 update.inc("totalAmount", item.getPrice()); // 金额增加 mongoTemplate.updateFirst(query, update, Order.class); } }

📂 5. 使用 MongoRepository(更简单的方式)

import org.springframework.data.mongodb.repository.MongoRepository; import java.util.List; import java.util.Optional; // 只要继承 MongoRepository,常用方法自动实现 public interface OrderRepository extends MongoRepository<Order, String> { // 按方法名自动生成查询(Derived Query) List<Order> findByStatus(String status); List<Order> findByStatusAndTotalAmountGreaterThan(String status, Double amount); Optional<Order> findByOrderNo(String orderNo); List<Order> findByCustomerNameOrderByCreatedAtDesc(String name); // 自定义查询(@Query) @Query("{ 'status': ?0, 'items.product': ?1 }") List<Order> findByStatusAndProductName(String status, String productName); }
💡 选哪个?简单 CRUD 用 MongoRepository(代码少),复杂查询/聚合用 MongoTemplate(更灵活)。可以同时注入两者,按需选择。

📊 6. 聚合管道(Java 版本)

import org.springframework.data.mongodb.core.aggregation.*; import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; public List<ProductStats> getProductStats() { Aggregation aggregation = newAggregation( // $match: 过滤已支付 match(Criteria.where("status").is("paid")), // $unwind: 展开商品数组 unwind("items"), // $group: 按商品名分组统计 group("items.product") .sum("items.qty").as("totalQty") .sum( ArithmeticOperators.Multiply .valueOf("items.price") .multiplyBy("items.qty") ).as("totalRevenue") .count().as("orderCount"), // $sort: 按收入降序 sort(Sort.Direction.DESC, "totalRevenue"), // $project: 重命名字段 project("totalQty", "totalRevenue", "orderCount") .and("_id").as("product") .andExclude("_id") ); AggregationResults<ProductStats> results = mongoTemplate.aggregate(aggregation, "orders", ProductStats.class); return results.getMappedResults(); }

P10. 生产环境踩坑指南 — 前人踩过的坑你别再踩

坑1:忘记加索引 → 全集合扫描

⚠️ 现象
开发环境数据少(几百条),查询秒回。上线后数据到百万级,接口从 50ms 慢到 5 秒。
✅ 解决:
  1. 上线前用 .explain("executionStats") 检查每个查询的执行计划
  2. 确保 totalDocsExamined 接近 nReturned(看了多少条 ≈ 返回多少条)
  3. 如果看到 COLLSCAN,必须加索引
  4. $hint 强制使用指定索引:db.orders.find({...}).hint("status_1")

坑2:文档无限膨胀 → 超过 16MB 限制

⚠️ 现象
把用户所有操作日志用 $push 追加到一个文档里。用户活跃的文档越来越大,最终报错:
MongoError: BSONObj size: 16821xxx bytes is invalid. Size must be between 0 and 16793600(16MB)
✅ 解决:
  1. 用子集模式:只保留最近 N 条,旧数据移到另一个集合
  2. $slice 限制数组长度:$push: {logs: {$each: [newLog], $slice: -100}}(只保留最新100条)
  3. 或者直接分开存储,不用嵌入

坑3:大量更新导致锁竞争

⚠️ 现象
一个热门商品文档,10个请求同时 $push 到同一个数组字段。虽然 MongoDB 4.x 用了文档级锁,但高频更新同一个文档仍然会导致写等待。
✅ 解决:
  1. 热点数据不要嵌入大数组,改用引用模式(分开存)
  2. 使用 findAndModify 原子操作代替"先查后改"
  3. 用乐观锁(version 字段 + $inc)避免并发覆盖

坑4:副本集故障转移期间写入丢失

⚠️ 现象
Primary 突然宕机,新的 Primary 选举出来后发现部分写入丢失。原因是写关注只用了 w:1(默认),数据只写到了 Primary 还没来得及复制到 Secondary。
✅ 解决:重要数据用 w: "majority" 写关注。Spring Boot 中:
@Bean public MongoClientSettings mongoClientSettings() { return MongoClientSettings.builder() .writeConcern(WriteConcern.MAJORITY) // 大多数节点确认 .readConcern(ReadConcern.MAJORITY) // 读到大多数确认的数据 .build(); }

坑5:连接池耗尽

⚠️ 现象
高并发下报错:com.mongodb.MongoWaitQueueFullException: Too many operations waiting for a connection
✅ 解决:调整连接池参数
// Spring Boot 配置连接池 @Bean public MongoClientSettings mongoClientSettings() { ConnectionPoolSettings poolSettings = ConnectionPoolSettings.builder() .maxSize(100) // 最大连接数(默认100) .minSize(10) // 最小保持连接数 .maxWaitTime(5, TimeUnit.SECONDS) // 等待连接超时 .maxConnectionLifeTime(30, TimeUnit.MINUTES) // 连接最长存活 .build(); return MongoClientSettings.builder() .applyToConnectionPoolSettings(builder -> builder.applySettings(poolSettings)) .build(); }

坑6:内存不足 — WiredTiger 缓存

⚠️ 现象
MongoDB 占用内存越来越大,服务器 OOM 被 kill。
✅ 解决:
  1. MongoDB 默认使用 WiredTiger 存储引擎,内部缓存默认是 (RAM - 1GB) 的 50%256MB(取较大值)
  2. 如果服务器还跑了其他服务(如 Java 应用),需要限制 MongoDB 缓存大小
  3. 在 mongod.conf 中配置:storage.wiredTiger.engineConfig.cacheSizeGB: 2(限制为2GB)
  4. 同时注意操作系统的文件缓存也会占用内存

坑7:索引建太多 → 写入变慢

⚠️ 现象
为了查询快,给每个字段都建了索引。结果写入速度从 10000/s 掉到 1000/s。每次 insert/update 都要更新所有索引。
✅ 解决:
  1. 每个集合索引不超过 5~8 个
  2. 后台建索引避免锁表:db.orders.createIndex({field:1}, {background: true})(4.2+ 后台建索引已成默认行为)
  3. 定期用 $indexStats 检查哪些索引没被用过,删掉无用索引

📋 生产 Checklist

项目检查内容推荐配置
连接串是否配了 replicaSet副本集必须配,否则不知道主从
写关注w: majority?重要数据必须 majority
读偏好读写分离了吗?非实时数据用 secondaryPreferred
索引每个查询都 explain 了吗?上线前必须检查
连接池maxSize 够吗?高并发适当调大到 100~200
缓存WiredTiger cacheSizeGB?服务器共享时限制缓存大小
监控有监控吗?用 MongoDB Cloud Manager 或 Prometheus + Grafana
备份有定期备份吗?mongodump 定期全量 + oplog 增量
慢查询开启慢查询日志了吗?profile 级别设为 1,slowms: 100
安全开启了认证吗?生产必须开启 auth + TLS
// 开启慢查询日志 > db.setProfilingLevel(1, { slowms: 100 }) // 记录超过100ms的操作 // 查看慢查询 > db.system.profile.find().sort({ts:-1}).limit(10)
MongoDB 从零到生产 · 全栈资料库 · 持续更新