MongoDB 最容易被误用:拿它当 MySQL 用,把关系型的设计照搬过来,然后抱怨「不好用」。这篇讲清楚它适合的场景和正确的建模方式。
一、什么时候用 MongoDB
| 适合的 | 不适合的 |
|---|---|
| 结构多变(字段经常加、各文档字段不同) | 强结构化、字段固定的核心业务数据 |
| 一次读写的完整单元(如一个订单含明细) | 需要跨多实体 JOIN 的复杂查询 |
| 写多读少的行为日志、埋点 | 需要复杂事务的金融/账务场景 |
| 地理空间、数组/嵌套查询 | 强一致性、需要外键约束 |
一句话判断:如果你需要经常 JOIN 和多表事务,别用 MongoDB。
二、建模:内嵌 vs 引用
三、索引
// 单字段
db.orders.createIndex({ orderNo: 1 })
// 复合索引(顺序很重要,遵循 ESR 原则)
db.orders.createIndex({ orgId: 1, createTime: -1 })
// 唯一
db.users.createIndex({ phone: 1 }, { unique: true })
// TTL(自动过期,日志场景神器)
db.logs.createIndex({ createTime: 1 }, { expireAfterSeconds: 2592000 }) // 30 天
// 部分索引(只索引部分文档,省空间)
db.orders.createIndex({ orgId: 1 }, { partialFilterExpression: { status: 1 } })
// 查看与删除
db.orders.getIndexes()
db.orders.dropIndex("orgId_1_createTime_-1")
ESR 原则(复合索引字段顺序):Equality(等值)→ Sort(排序)→ Range(范围)。
// 查询:db.orders.find({orgId: 1, status: 2}).sort({createTime: -1})
db.orders.createIndex({ orgId: 1, status: 1, createTime: -1 }) // 等值在前,排序在后
执行计划:
db.orders.find({ orgId: 1 }).explain("executionStats")
重点看:stage(COLLSCAN 全表扫描要优化,应为 IXSCAN)、totalDocsExamined(扫描文档数)、totalKeysExamined(扫描索引数)、nReturned。totalDocsExamined 远大于 nReturned 说明索引没起作用。
四、聚合管道
聚合是 MongoDB 最强大的能力,常用的阶段:
db.orders.aggregate([
{ $match: { status: 1, createTime: { $gte: ISODate("2026-09-01") } } }, // 先过滤
{ $group: { _id: "$orgId", total: { $sum: "$amount" }, cnt: { $sum: 1 } } },
{ $sort: { total: -1 } },
{ $limit: 10 },
{ $lookup: { from: "orgs", localField: "_id", foreignField: "_id", as: "org" } },
{ $project: { orgName: "$org.name", total: 1, cnt: 1 } }
])
顺序决定性能:$match 一定要放在最前面(能用到索引并减少后续数据量),$sort + $limit 放一起,能大幅减少排序开销。
常用操作符速查:
| 操作符 | 用途 |
|---|---|
$match |
过滤(等价 WHERE) |
$group + $sum/$avg/$max/$push |
分组统计 |
$unwind |
拆数组(内嵌数组统计必备) |
$lookup |
左连接(4 个以上 $lookup 就该考虑换方案了) |
$project |
投影(等价 SELECT) |
$facet |
一次聚合出多组结果(做看板很好用) |
五、写操作与事务
// 批量写(性能好很多)
db.orders.insertMany([...], { ordered: false }) // ordered:false 出错继续
// 部分更新(不要用整体替换,会覆盖其它字段)
db.orders.updateOne(
{ _id: 1 },
{ $set: { status: 2 }, $inc: { version: 1 }, $push: { logs: {...} } }
)
// upsert
db.orders.updateOne({ orderNo: "A1" }, { $set: {...} }, { upsert: true })
// 事务(4.0+ 支持多文档事务,副本集/分片集群下可用)
const session = client.startSession();
await session.withTransaction(async () => { ... });
updateOne 一定要用 $set / $inc 这类更新操作符。直接 updateOne({_id}, {status: 2})(不带 $set)会把整个文档替换成 {status: 2},其它字段全没了 —— 这是最常见的丢数据事故。
六、副本集与分片
副本集(Replica Set):一主多从,自动故障转移。生产最小配置是一主两从(或一个从 + 一个仲裁节点)。
rs.initiate({
_id: "rs0",
members: [
{ _id: 0, host: "10.0.0.1:27017" },
{ _id: 1, host: "10.0.0.2:27017" },
{ _id: 2, host: "10.0.0.3:27017", arbiterOnly: true }
]
})
rs.status()
分片(Sharded Cluster):数据量超过单机容量时使用。关键决策是分片键:
| 分片键类型 | 优点 | 缺点 |
|---|---|---|
| 范围分片(如 createTime) | 范围查询高效 | 容易热点(新数据都写到一个分片) |
哈希分片(如 _id 哈希) |
分布均匀 | 范围查询要跨片 |
| 复合分片(租户 ID + 时间) | 兼顾 | 需要仔细设计 |
七、生产实践
案例:行为日志把磁盘写满
背景:某小程序商城公司,用 MongoDB 存用户访问、点击、搜索词、足迹等行为数据(脱敏后统计用)。
现象:上线几个月后磁盘使用率 95% 告警,MongoDB 拒绝写入。
排查:
db.stats() // dataSize vs storageSize
db.getCollectionNames().forEach(c => print(c, db[c].stats().storageSize))
发现 user_behavior 集合占了 800GB,且没有 TTL 索引,数据只增不减。另外 storageSize 远大于 dataSize —— 说明有大量删除后留下的碎片空间(WiredTiger 不会自动回收)。
处理:
- 加 TTL 索引,只保留 90 天:
db.user_behavior.createIndex({createTime:1},{expireAfterSeconds:7776000})。 - 历史数据按时间分批删除(每批删 1 小时的数据,避免长时间锁)。
- 回收碎片:对从库做
compact,或在低峰期对副本集成员重新同步(重建数据文件最彻底)。 - 长期:行为数据改走 Kafka → 数仓,MongoDB 只保留近期热数据。
案例:$lookup 拖垮聚合
现象:一个统计接口的聚合查询从 200ms 涨到 8s。
排查:explain 发现聚合里有 3 个 $lookup,其中一个关联的集合有 500 万文档,且关联字段没索引 —— 每个输入文档都要做一次全表扫描。
处理:给关联字段加索引;把其中一个 $lookup 改成应用层分批查询 + 内存拼装(有时比数据库 JOIN 更快);结果加缓存。
八、几条硬规则
- 不要无脑
find({}),一定带条件;查询字段建索引。 - 数组字段无上限增长是设计错误(评论、日志要用引用)。
- 更新用
$set,绝不用整体替换。 - 日志类数据必配 TTL 索引。
$lookup是慢查询的重灾区,超过 2 个就该重新评估建模。- 连接串要带
readPreference(读写分离)和w: majority(写安全级别)。 - 不要用 MongoDB 存需要强事务的账务数据 —— 4.0 以后虽然有事务,但性能和生态远不如 MySQL。
一起聊聊