数据库与缓存 列表全部文章
数据库与缓存

MongoDB 笔记:文档模型设计、索引、聚合管道与什么时候不该用它

MongoDB 实战笔记:什么时候选文档型而不是关系型,内嵌与引用的取舍,索引与执行计划怎么看,聚合管道的常用阶段,副本集与分片的基本概念,以及一次行为日志把磁盘写满的排查。

更多

MongoDB 最容易被误用:拿它当 MySQL 用,把关系型的设计照搬过来,然后抱怨「不好用」。这篇讲清楚它适合的场景和正确的建模方式。

一、什么时候用 MongoDB

适合的 不适合的
结构多变(字段经常加、各文档字段不同) 强结构化、字段固定的核心业务数据
一次读写的完整单元(如一个订单含明细) 需要跨多实体 JOIN 的复杂查询
写多读少的行为日志、埋点 需要复杂事务的金融/账务场景
地理空间、数组/嵌套查询 强一致性、需要外键约束

一句话判断:如果你需要经常 JOIN 和多表事务,别用 MongoDB。

二、建模:内嵌 vs 引用

内嵌(Embedded)—— 一对少、读多写少、子文档不会独立访问 order { _id, orderNo, items: [ {sku, qty} ] } 一次查询拿到完整订单,无需 JOIN 注意:单文档上限 16MB 引用(Reference)—— 一对多/多对多、子实体会被独立访问 user { _id } ←→ order { user_id } 类似外键,需要 $lookup 或应用层查两次 数组无限增长时必须用引用 三条经验法则 1. 一对一 / 一对少且子文档总是随父文档一起读 → 内嵌 2. 数组会无上限增长(如评论、日志)→ 引用,否则文档会超过 16MB 3. 需要频繁单独更新子实体 → 引用

三、索引

// 单字段
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 不会自动回收)。

处理:

  1. 加 TTL 索引,只保留 90 天:db.user_behavior.createIndex({createTime:1},{expireAfterSeconds:7776000})。
  2. 历史数据按时间分批删除(每批删 1 小时的数据,避免长时间锁)。
  3. 回收碎片:对从库做 compact,或在低峰期对副本集成员重新同步(重建数据文件最彻底)。
  4. 长期:行为数据改走 Kafka → 数仓,MongoDB 只保留近期热数据。

案例:$lookup 拖垮聚合

现象:一个统计接口的聚合查询从 200ms 涨到 8s。

排查:explain 发现聚合里有 3 个 $lookup,其中一个关联的集合有 500 万文档,且关联字段没索引 —— 每个输入文档都要做一次全表扫描。

处理:给关联字段加索引;把其中一个 $lookup 改成应用层分批查询 + 内存拼装(有时比数据库 JOIN 更快);结果加缓存。

八、几条硬规则

  1. 不要无脑 find({}),一定带条件;查询字段建索引。
  2. 数组字段无上限增长是设计错误(评论、日志要用引用)。
  3. 更新用 $set,绝不用整体替换。
  4. 日志类数据必配 TTL 索引。
  5. $lookup 是慢查询的重灾区,超过 2 个就该重新评估建模。
  6. 连接串要带 readPreference(读写分离)和 w: majority(写安全级别)。
  7. 不要用 MongoDB 存需要强事务的账务数据 —— 4.0 以后虽然有事务,但性能和生态远不如 MySQL。
— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人