MongoDB 是文档数据库的代表 —— 你拿到的不是行而是 JSON-like 文档,schema 灵活,查询语法表达力强。但很多人用它只是"把 JSON 存数据库",没用上索引、Aggregation Pipeline、副本集、分片这些核心能力,结果性能差、可靠性差,然后归咎于"MongoDB 不行"。这篇文章把 MongoDB 从数据模型讲到生产部署,讲清楚它适合什么、不适合什么。
数据模型:Document、Collection、Database
// 一个文档(Document) - 类似 JSON
{
"_id": ObjectId("..."),
"username": "mores",
"email": "m@x.com",
"profile": {
"age": 30,
"city": "Beijing"
},
"tags": ["dev", "blogger"],
"createdAt": ISODate("2026-05-15")
}
// Collection 类似关系数据库的表,但 schema 灵活
// Database 类似关系数据库的库
MongoDB 文档大小上限 16MB,字段命名规则、嵌套对象、数组都支持。无 schema 不代表无设计 —— 反而要在应用层精心设计文档结构,否则容易乱。
基础 CRUD
// 插入
db.users.insertOne({ username: "mores", email: "m@x.com" })
db.users.insertMany([{...}, {...}])
// 查询
db.users.find({ username: "mores" })
db.users.find({ age: { $gte: 18, $lt: 60 } })
db.users.find({ tags: { $in: ["dev", "designer"] } })
db.users.find({ "profile.city": "Beijing" }) // 嵌套字段
// 投影:只返回部分字段
db.users.find({ city: "Beijing" }, { username: 1, email: 1 })
// 更新
db.users.updateOne({ _id: ObjectId(...) }, { $set: { age: 31 } })
db.users.updateMany({ city: "Beijing" }, { $inc: { age: 1 } })
// 数组操作
db.users.updateOne({ _id: ... }, { $push: { tags: "blogger" } })
db.users.updateOne({ _id: ... }, { $pull: { tags: "old_tag" } })
db.users.updateOne({ _id: ... }, { $addToSet: { tags: "unique" } })
// 删除
db.users.deleteOne({ _id: ... })
db.users.deleteMany({ status: "inactive" })
索引
没有索引的 MongoDB 查询是全 collection 扫描。索引设计和 MySQL 类似:
// 单字段
db.users.createIndex({ username: 1 }) // 1 升序,-1 降序
db.users.createIndex({ email: 1 }, { unique: true })
// 复合索引:遵循"最左前缀"
db.orders.createIndex({ userId: 1, createdAt: -1 })
// 能用:WHERE userId = ?, WHERE userId = ? ORDER BY createdAt DESC
// 不能用:WHERE createdAt = ? (跳过了 userId)
// 数组索引:数组每个元素都建索引
db.users.createIndex({ tags: 1 })
// 查 { tags: "dev" } 用得上
// 全文索引
db.articles.createIndex({ content: "text" })
db.articles.find({ $text: { $search: "kubernetes" } })
// 地理空间索引
db.bikes.createIndex({ location: "2dsphere" })
db.bikes.find({
location: {
$near: {
$geometry: { type: "Point", coordinates: [116.4, 39.9] },
$maxDistance: 1000 // 1 公里内
}
}
})
// TTL 索引:自动删除过期文档
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 })
// 部分索引(只对部分文档建索引,省空间)
db.orders.createIndex(
{ createdAt: 1 },
{ partialFilterExpression: { status: "active" } }
)
// 查看索引
db.orders.getIndexes()
db.orders.find({ ... }).explain("executionStats") // 看是否用了索引
Aggregation Pipeline:MongoDB 的"SQL"
聚合管道由多个 stage 组成,文档流过每个 stage 做变换:
// "上个月每个城市的订单数和总金额"
db.orders.aggregate([
// 1. 过滤
{ $match: { createdAt: { $gte: ISODate("2026-04-01"), $lt: ISODate("2026-05-01") } } },
// 2. join 用户表(类似 SQL JOIN)
{ $lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}},
{ $unwind: "$user" },
// 3. 分组聚合
{ $group: {
_id: "$user.profile.city",
count: { $sum: 1 },
total: { $sum: "$amount" }
}},
// 4. 排序
{ $sort: { total: -1 } },
// 5. 限制
{ $limit: 10 }
])
Aggregation 比 SQL 表达力更灵活(数组、嵌套对象、表达式),但学习曲线陡。复杂查询调试要熟练用 explain 看每个 stage 性能。
事务
MongoDB 4.0+ 支持多文档事务,API 像 SQL:
const session = client.startSession();
try {
session.startTransaction();
db.accounts.updateOne({ _id: "A" }, { $inc: { balance: -100 } }, { session });
db.accounts.updateOne({ _id: "B" }, { $inc: { balance: 100 } }, { session });
session.commitTransaction();
} catch (e) {
session.abortTransaction();
} finally {
session.endSession();
}
但 MongoDB 事务有限制:
- 性能比单文档慢一个数量级 —— 用得多就该想是不是 schema 设计有问题。
- 跨分片事务在 4.2+ 才支持,且性能更差。
- 不能跨数据库(单 cluster 内可以)。
MongoDB 的设计哲学是"把相关数据嵌进同一个文档,避免多文档事务"。能嵌套就嵌套。
Schema 设计:嵌入 vs 引用
嵌入(Embedding)
{
_id: ...,
username: "mores",
addresses: [
{ type: "home", city: "Beijing", street: "..." },
{ type: "office", city: "Shanghai", street: "..." }
]
}
适合:数据生命周期相同、读取通常一起、子文档相对小且数量有限。
引用(Referencing)
// users collection
{ _id: 1, username: "mores" }
// orders collection
{ _id: ..., userId: 1, items: [...] }
// 查询时手动或用 $lookup 关联
适合:子文档数量不可控、独立访问、生命周期不同。
实战经验
1. "一起访问的数据放一起" 是 MongoDB 设计第一原则。
2. "1 对少"嵌入,"1 对多"引用,"多对多"看情况。
3. 避免无限增长的数组 —— 一个文档存几万条评论会变成大文档,操作慢。
4. 需要全局唯一约束的字段,要建唯一索引(MongoDB 不自动)。
副本集(Replica Set)
MongoDB 的高可用方案:一主 + N 从,主挂了自动选新主。
// 配置副本集
rs.initiate({
_id: "rs0",
members: [
{ _id: 0, host: "mongo1:27017" },
{ _id: 1, host: "mongo2:27017" },
{ _id: 2, host: "mongo3:27017", arbiterOnly: true } // 仲裁节点
]
})
// 客户端连接(写主、读从)
mongo "mongodb://mongo1:27017,mongo2:27017,mongo3:27017/?replicaSet=rs0"
Read Preference / Write Concern
// 读偏好:从哪里读
db.users.find().readPref("secondary") // 优先从从节点读
// 写关注:写多少节点才算成功
db.users.insertOne(
{ ... },
{ writeConcern: { w: "majority", j: true } } // 多数节点写盘
)
这套机制让 MongoDB 在 CAP 上可调:strong write concern + linearizable read = 强一致;最低配置 = 性能优先 + 最终一致。
分片(Sharding)
数据量超过单机时分片,通常几十 TB 以上才用。
// 启用分片
sh.enableSharding("mydb")
sh.shardCollection("mydb.orders", { userId: "hashed" })
// 按 userId 哈希分到不同 shard
// 看分片状态
sh.status()
分片设计的关键是选好 shard key:
- 分布均匀(避免热点)。
- 查询能利用(否则每次查都要 scatter-gather)。
- 一旦选定难以更改(MongoDB 5.0 之前不能改,5.0+ 才能 reshard)。
常见错误:用"自增 ID"做 shard key —— 所有新写都到一个分片,瞬间热点。
性能优化
- 查询用 explain:executionStats 里 "totalDocsExamined" 应接近 "nReturned"。差距大说明扫了很多没用的。
- 批量写:insertMany / bulkWrite 比循环 insertOne 快很多。
- 避免大文档:文档接近 16MB 性能急剧下降。监控并拆分。
- 合理的 working set:MongoDB 想性能好,常用数据要在内存里。指标:resident memory 至少能放下索引 + 热数据。
什么时候用 MongoDB
- 数据结构灵活,schema 演化频繁(博客文章、CMS、产品目录)。
- 需要嵌套结构(地址、配置、属性)。
- 读多写少,且查询模式可预测。
- 需要全文检索 + 地理空间 + 复杂聚合一站式。
什么时候不用
- 需要强一致的复杂事务(转账、库存)—— 还是 RDBMS 更稳。
- 大量 JOIN 关联查询 —— MongoDB 的 $lookup 性能差,关系数据用关系数据库。
- 极高写吞吐 + 时序数据 —— InfluxDB / ClickHouse 更专业。
- 需要 SQL 标准生态(BI 工具、报表)。
常见坑
坑 1:把 schema 不固定理解为"不用设计"。错。schema 灵活不代表无设计,反而要更精心。新字段加进去后,查询、索引、迁移都要考虑。
坑 2:用 _id 自动生成的 ObjectId 排序当时间序。ObjectId 前 4 字节是时间戳,大致按时间递增,但同一秒内多个节点生成不严格递增。需要严格时间序用专门的字段。
坑 3:跨 shard 查询。 查询不带 shard key,MongoDB 要 fan-out 到所有 shard,性能差。设计 shard key 时考虑查询模式。
坑 4:writeConcern 选 1。 默认 w:1 时,主节点写完返回。如果主立刻挂,从未必复制完,数据丢。重要数据 w:"majority"。
坑 5:聚合内存超限。Aggregation 默认 100MB 内存上限,超了报错。复杂聚合加 { allowDiskUse: true }。
Change Streams:监听数据变化
MongoDB 3.6+ 提供 Change Streams,实时监听 collection 的变化:
// 监听 orders 集合的所有变化
const changeStream = db.orders.watch();
changeStream.on('change', (change) => {
console.log(change.operationType, change.fullDocument);
});
// 带过滤
db.orders.watch([
{ $match: { 'fullDocument.status': 'paid' } }
]);
// 从某个 resumeToken 续传(支持断线恢复)
db.orders.watch([], { resumeAfter: lastToken });
Change Streams 让 MongoDB 也能做"事件驱动架构" —— 数据变了,推送给下游。底层依赖 oplog(副本集的操作日志),所以必须用副本集才能用。
聚合管道的高级用法
$facet:多个聚合在一个查询里
// "本月订单总数 + 按状态分布 + 每天数量"一次查询完
db.orders.aggregate([
{ $match: { createdAt: { $gte: ISODate("2026-05-01") } } },
{ $facet: {
total: [ { $count: "count" } ],
byStatus: [ { $group: { _id: "$status", count: { $sum: 1 } } } ],
daily: [
{ $group: {
_id: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } },
count: { $sum: 1 }
}},
{ $sort: { _id: 1 } }
]
}}
]);
// 返回 { total: [...], byStatus: [...], daily: [...] }
$bucket / $bucketAuto:数据分桶
// 用户年龄分布
db.users.aggregate([
{ $bucket: {
groupBy: "$age",
boundaries: [0, 18, 30, 50, 100],
default: "unknown",
output: { count: { $sum: 1 } }
}}
]);
// [{ _id: 0, count: 100 }, { _id: 18, count: 5000 }, ...]
$graphLookup:图遍历
// 找出某个员工的全部上下级
db.employees.aggregate([
{ $match: { name: "Alice" } },
{ $graphLookup: {
from: "employees",
startWith: "$reportsTo",
connectFromField: "reportsTo",
connectToField: "_id",
as: "managers",
maxDepth: 5
}}
]);
// 一次查询拿到 Alice 上面所有层级的 manager
索引的隐性消耗
索引太多会拖慢写入和增大存储。每加一个索引:
- 插入 / 更新需要维护额外索引树。
- 占空间(尤其 multikey 数组索引膨胀快)。
- 查询优化器要考虑的候选索引变多,plan 选择慢。
定期检查未使用的索引:
// MongoDB 4.0+
db.orders.aggregate([{ $indexStats: {} }])
// 看每个索引的 accesses.ops,长期为 0 说明没用,可以删
事务的代价
MongoDB 4.0 的多文档事务设计上是"对必要时支持",而不是"取代单文档原子操作"。性能差距:
- 单文档原子操作:几十万 QPS。
- 多文档事务:几千 QPS。
所以"schema 设计避免多文档事务"是最佳实践 —— 把相关数据放一个文档里,改一个文档就是原子的。
WiredTiger 存储引擎
MongoDB 3.0 起默认 WiredTiger,核心特性:
- 压缩:Snappy(快,默认)/ Zlib(更省空间,慢)/ Zstd(平衡)。
- Document-level locking:行级锁,比之前的库级锁并发好得多。
- Cache:用一半物理内存做 cache,装下"working set"性能最佳。
- Journal:WAL 日志,默认每 100ms 刷盘,保证耐久。
实战:从 SQL 思维过渡到 MongoDB 思维
// SQL 风格(反模式)
db.users.find({ _id: 1 })
// 拿到 user 后,再 db.orders.find({ userId: 1 })
// 来回查多次
// MongoDB 风格(更好):嵌入
{
_id: 1,
name: "...",
orders: [
{ id: 101, total: 100, ... },
{ id: 102, total: 200, ... }
]
}
// 一次查询拿全部
db.users.find({ _id: 1 })
注意:不是所有数据都嵌入。订单数量可控就嵌入,如果一个用户有几千个订单,嵌入会让文档膨胀超过 16MB。这种情况下用引用 + $lookup。
写在最后
MongoDB 不是"NoSQL"那么简单 —— 它是一套以"文档"为中心的完整数据库系统,有自己的查询语言、聚合体系、副本集、分片、事务。把它当"能存 JSON 的数据库"用的人感受到的痛苦,大多源于没真正学它的设计哲学。学透 MongoDB 后,你会发现它在某些场景下比关系数据库灵活得多;但也会清楚意识到,它不能替代关系数据库的所有用途。合适的场景用对工具,这才是数据库选型的根本。
—— 别看了 · 2026