学完这篇,你能独立完成 MongoDB 的建库建集合、设计文档结构、建对索引,并用聚合管道写出统计报表,不再把它当成“只会存 JSON 的数据库”来用。
第一步:启动一个能连的实例
本地练习最省事的方式是 Docker:
docker run -d --name mongo -p 27017:27017 mongo:7
docker exec -it mongo mongosh
进去之后就是一个 JS 交互环境,先切库:
use blog
注意:MongoDB 里 use 一个不存在的库不会立刻创建,直到你写入第一条数据它才真正落盘。别以为建库失败了。
第二步:理解文档模型,并完成增删改查
MongoDB 只有三层:数据库 → 集合(collection)→ 文档(document)。文档就是 BSON,可以嵌套、可以放数组,这是它和 MySQL 最大的区别——能内嵌的数据就别拆表。
db.articles.insertOne({
title: "MongoDB 索引入门",
slug: "mongo-index",
author: { id: 1001, name: "阿乐" },
tags: ["mongodb", "index"],
views: 0,
status: "published",
createdAt: new Date()
})
对应的改动操作:
db.articles.updateOne(
{ slug: "mongo-index" },
{ $set: { status: "draft" }, $inc: { views: 1 }, $addToSet: { tags: "tutorial" } }
)
$set 改字段、$inc 自增、$addToSet 去重追加数组元素,这三个是最常用的。查询集合里嵌套字段要用引号把路径包起来:
db.articles.find({ "author.id": 1001, tags: "mongodb" })
注意:单条文档有 16MB 上限。不要设计会无限增长的数组(比如把一条帖子的所有评论塞进数组),评论应该单独建集合。这条踩坑的人非常多,等数组撑爆了再改就要做数据迁移。
第三步:建索引,并且验证它真的生效
没建索引的查询就是全表扫描(COLLSCAN),几十万条数据之后就会明显变慢。
db.articles.createIndex({ slug: 1 }, { unique: true })
db.articles.createIndex({ status: 1, createdAt: -1 })
db.sessions.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
第三条是 TTL 索引,到时间自动删文档,做会话表、验证码表特别合适。
建完必须验:
db.articles.find({ status: "published" }).sort({ createdAt: -1 }).explain("executionStats")
看两个数:stage 是不是 IXSCAN,以及 totalDocsExamined 是否接近 nReturned。差得远说明索引没吃上。
复合索引的字段顺序按 ESR 原则排:Equality(等值)→ Sort(排序)→ Range(范围)。上面的 {status:1, createdAt:-1} 就是等值在前、排序在后。
注意:索引不是越多越好,每个索引都会拖慢写入并占空间。原则是“按查询建索引”,不是“按字段建索引”。上线前用 explain 把你实际会跑的查询都过一遍。
第四步:用聚合管道做统计
聚合管道就是一串 stage 依次处理文档,理解成 Unix 管道最直观。比如统计每个标签下的文章数和平均浏览:
db.articles.aggregate([
{ $match: { status: "published" } },
{ $unwind: "$tags" },
{ $group: { _id: "$tags", count: { $sum: 1 }, avgViews: { $avg: "$views" } } },
{ $sort: { count: -1 } },
{ $limit: 10 }
])
$unwind 把数组拆成多条,$group 按 _id 分组聚合,这两个是聚合的核心。
要做关联查询用 $lookup(相当于 left join):
db.articles.aggregate([
{ $match: { status: "published" } },
{ $lookup: { from: "users", localField: "author.id", foreignField: "_id", as: "authorInfo" } },
{ $unwind: "$authorInfo" },
{ $project: { title: 1, "authorInfo.name": 1, views: 1 } }
])
$project 控制输出字段,1 是保留、0 是排除。
如果一页要同时拿到“列表 + 总数”,用 $facet 一次查询解决:
db.articles.aggregate([
{ $match: { status: "published" } },
{ $facet: {
list: [{ $sort: { createdAt: -1 } }, { $skip: 0 }, { $limit: 10 }],
total: [{ $count: "n" }]
}}
])
注意:聚合默认有 100MB 内存限制,超出会报错。大结果集要加 .allowDiskUse(true),或者把 $match、$limit 尽量往前放,先削减数据量再计算。
第五步:几个上线前必须知道的点
$match 永远放管道第一段,否则后面所有 stage 都在处理全量数据,而且用不上索引。
- 深翻页不要用大
skip。skip(100000) 会真的扫过十万条。改用游标:{ _id: { $lt: lastId } } 配合 limit。
$lookup 是性能敏感操作,本质是嵌套查询。数据量大时优先考虑把必要字段冗余进文档(比如上面把 author.name 内嵌),而不是每次都 join。
- 事务需要副本集,单机
mongod 用不了。本地测试可以起一个单节点副本集。
- 字段名在每条文档里都会重复存储,名字起短一点(
views 比 view_count 省),百万级文档能省出可观的磁盘。
小结
- 文档模型的核心是“能内嵌就内嵌”,但警惕无界增长的数组,单文档 16MB 是硬上限。
- 索引用
explain 验证,复合索引按 ESR 排序字段,TTL 索引适合做自动过期数据。
- 聚合管道按
$match → $unwind → $group → $sort → $limit 的顺序思考,$match 越靠前越快,$facet 适合一次拿列表和总数。
- 深翻页用游标代替
skip,$lookup 别滥用,能冗余就冗余。