第4章 索引与性能优化
🎯 本章学习目标
- 用 Java 创建/查看/删除索引:单列、组合、唯一,理解
_id自带索引这条出厂设定。 - 掌握 MongoDB 独有的多键索引(Multikey Index):数组字段如何被逐元素索引。
- 复用 MySQL 最左前缀经验,并记住三处行为差异(稀疏/缺失字段、方向、排序预算)。
- 用
explain看懂COLLSCAN / IXSCAN / FETCH / covered,配合 profiler 抓慢查询。 - 掌握 TTL 索引(自动过期)与文本索引,知道它们各自的 MySQL 对应物或缺位。
- 建立「内存优先」的容量直觉:WiredTiger 缓存与工作集的关系,对照 InnoDB Buffer Pool。
4.1 创建索引:mongosh/Java 双写法与默认设定
// mongosh 对照(索引文档里的 1/-1 与 Java 的 Document 完全同形)
use yjoffer
db.t_order.createIndex({ userId: 1 }, { name: "idx_user" });
db.t_order.createIndex({ userId: 1, createTime: -1 });
db.t_order.createIndex({ orderNo: 1 }, { unique: true });
db.t_order.getIndexes(); // 查看(对照 SHOW INDEX)
db.t_order.dropIndex("idx_user"); // 删除// Java Driver 对照
MongoCollection<Document> orders = db.getCollection("t_order");
// 单列索引(对照 MySQL: CREATE INDEX idx_user ON t_order(user_id))
orders.createIndex(new Document("userId", 1), new IndexOptions().name("idx_user"));
// 组合索引(1 升序 / -1 降序,对照联合索引 (a,b))
orders.createIndex(new Document("userId", 1).append("createTime", -1));
// 唯一索引(对照 UNIQUE KEY;_id 本来就是全局唯一的默认唯一索引,且不可删除)
orders.createIndex(new Document("orderNo", 1), new IndexOptions().unique(true));
// 查看/删除
orders.listIndexes().forEach(d -> System.out.println(d.toJson()));
orders.dropIndex("idx_user");Document("field", 1) 里的 1/-1 是索引键方向。与 MySQL 不同:单列索引的正反方向几乎不影响使用(索引可双向扫),但在组合索引 + 排序的场景,方向就不再是随便写的了(见 4.4)。
4.2 多键索引:数组字段的自动全元素索引
MySQL 对 JSON 数组要生成列(generated column)才能索引;MongoDB 直接把数组每个元素建索引条目——这就是「对数组字段 eq 即包含」(第 2 章)能走索引的原因:
// mongosh 对照
db.products.createIndex({ tags: 1 }); // 多键索引
db.products.find({ tags: "java" }, { title: 1 }); // ✅ 走索引
db.posts.createIndex({ "comments.author": 1 }); // 内嵌文档数组按 "数组.字段" 索引
db.posts.find({ "comments.author": "amy" });// Java Driver 对照
// products.tags = ["java","mysql"] 的文档,两个词各生成索引条目
products.createIndex(new Document("tags", 1)); // 多键索引
products.find(Filters.eq("tags", "java")) // ✅ 走索引
.projection(Projections.include("title"));
// 内嵌文档数组也能按 "数组.字段" 索引(对应第3章内嵌评论的查询)
posts.createIndex(new Document("comments.author", 1));
posts.find(Filters.eq("comments.author", "amy"));注意一个容易误解的点:多键索引不是「建」出来的,而是数据「触发」出来的。createIndex({ tags: 1 }) 的语法和普通升序索引完全一样,没有任何专门写法——当某个文档的 tags 写入的是数组时,引擎才自动把索引升级为 multikey,为数组每个元素生成一条指向该文档的索引条目。建完用 getIndexes() 验证:
db.products.insertOne({ title: "MongoDB 实战", tags: ["java", "mongodb"] });
db.products.createIndex({ tags: 1 });
db.products.getIndexes()
// 输出里出现 multikey 字段即证实(列出被「炸开索引」的数组路径):
// { v: 2, key: { tags: 1 }, name: 'tags_1', multikey: { tags: 1 } }// Java 侧同样能从 listIndexes() 里看到这个字段
products.listIndexes().forEach(d -> System.out.println(d.get("multikey")));限制要知道:一个文档同一查询里最多一个数组字段用索引(如 eq(tags,'a').eq(tags,'b') 只有部分条件走索引,$all 多值会退化);多键索引不能成为覆盖索引里被投影的数组字段(FETCH 必要)。
4.3 最左前缀:MySQL 经验直接提款的部分
组合索引 (userId, createTime) 下:
// mongosh 对照
db.t_order.find({ userId: u }); // ✅ 用前缀
db.t_order.find({ userId: u, createTime: { $lt: d } }); // ✅ 全命中
db.t_order.find({ createTime: { $lt: d } }); // ❌ 跳最左,集合扫或索引扫
db.t_order.find({ userId: u }).sort({ createTime: -1 }); // ✅ 等值+排序全吃orders.find(Filters.eq("userId", u)); // ✅ 用前缀
orders.find(Filters.eq("userId", u).and(Filters.lt("createTime", d))); // ✅ 全命中
orders.find(Filters.lt("createTime", d)); // ❌ 跳最左,集合扫或索引扫
orders.createIndex(new Document("userId",1).append("createTime",-1));
orders.find(Filters.eq("userId", u)).sort(Sorts.descending("createTime")); // ✅ 等值+排序全吃失效场景与 MySQL 基本同款:跳过前缀列、前缀列用范围后截断(range 后的列只能当过滤键)、对字段套函数($regex 不以 ^ 开头、$expr 包裹字段)。
三处差异:
- 缺失字段也算「值」:没这个 key 的文档可用
Filters.exists("field", false)+ 稀疏索引精准命中,MySQL NULL 技巧在这里对应「缺失语义」($exists、$type); - 索引方向影响排序:单列方向随意,但
sort(a ASC, b DESC)这种混合方向,需要建Document("a",1).append("b",-1)才完全匹配(MySQL 8.0 前做不到,InnoDB 8.0 也才支持降序索引); - 一个查询通常只用一个索引:没有 MySQL 的 index_merge 常用形态,组合条件请认真设计联合索引列序。
4.4 explain:从 COLLSCAN 开始自查
// 方式一:driver 直接 explain(executionStats 模式)
Document plan = orders.find(Filters.eq("userId", oid))
.explain(ExplainVerbosity.EXECUTION_STATS);
// 关注字段:
// winningPlan.stage: COLLSCAN(全表扫) / IXSCAN(索引扫) / FETCH
// executionStats.nReturned / totalDocsExamined / totalKeysExamined / executionTimeMillis// 方式二:mongosh(日常排慢查最常用,输出结构同上)
db.t_order.find({ userId: ObjectId("...") }).explain("executionStats")方式三:Compass 的 EXPLAIN 图形视图(学习期强烈推荐,一眼看出 SCAN 类型与索引利用)。
读结果的四条经验法则(对照 MySQL 的 rows_examined):
| 现象 | 含义 | 对策 |
|---|---|---|
stage: COLLSCAN 且 totalDocsExamined ≈ 集合总数 | 全表扫 | 给过滤字段建索引 |
totalDocsExamined ≈ nReturned | 精准命中 | 理想 |
totalKeysExamined >> nReturned | 索引扫了太多无关键 | 调整组合索引列序,让等值在前 |
FETCH 消失、仅 IXSCAN | 覆盖查询(数据全从索引拿) | 投影字段 ⊆ 索引字段才能达成,对照 MySQL「using index」 |
4.5 特殊索引三件套
4.5.1 TTL 索引:自动过期删除(MySQL 没有的原生能力)
// mongosh 对照
db.sessions.createIndex({ createTime: 1 }, { expireAfterSeconds: 86400 });
// 注意:字段必须是 Date 类型;数组字段会按「元素中最小的日期」判定过期// Java Driver 对照
// 会话/验证码/临时 token 场景:createTime 超 24 小时自动删除,后台清理线程每 60s 跑一次
sessions.createIndex(new Document("createTime", 1),
new IndexOptions().expireAfter(Duration.ofHours(24)));对照 MySQL:以前要写定时 DELETE 任务 + 分片清理防大事务,现在声明式搞定。注意删除有最多 60s 级延迟,别拿它当强一致过期。
4.5.2 文本索引:轻量搜索
// mongosh 对照
db.articles.createIndex({ title: "text", content: "text" });
db.articles.find({ $text: { $search: "索引 教程" } }); // 分词相关性打分
db.articles.find({ $text: { $search: "索引" } },
{ score: { $meta: "textScore" } })
.sort({ score: { $meta: "textScore" } }); // 按相关度排序// Java Driver 对照
articles.createIndex(new Document("title", "text").append("content", "text"));
articles.find(Filters.text("索引 教程")); // 分词相关性打分 $meta:textScore对照 MySQL FULLTEXT:能用、够轻;但中文分词弱(默认按标点/空格切),正经站内搜索请上 Elasticsearch,MongoDB 侧用 $search(Atlas)或同步方案。
4.5.3 稀疏与部分索引
// mongosh 对照
db.coupons.createIndex({ code: 1 }, { partialFilterExpression: { used: false } });
// 稀疏索引(只索引含该字段的文档,历史用法,新库优先 partial):
db.coupons.createIndex({ code: 1 }, { sparse: true });// Java Driver 对照
// partialFilterExpression:比 MySQL「函数索引模拟部分索引」优雅
coupons.createIndex(new Document("code", 1),
new IndexOptions().partialFilterExpression(Filters.eq("used", false)));只索引「未核销」的券——券码高基数时索引体积和写放大都小得多。
4.6 慢查询抓取:profiler 对照慢查询日志
// mongosh 对照:开关与查询
db.setProfilingLevel(1, { slowms: 100 }); // 记录 >100ms 的操作
db.getProfilingStatus(); // 看当前级别
db.system.profile.find({ millis: { $gt: 100 } }).sort({ ts: -1 }).limit(20); // 查慢日志
db.setProfilingLevel(0); // 关掉(默认级别 0 不记)// Java Driver 对照
db.runCommand(new Document("profile", 1).append("slowms", 100)); // 记录 >100ms 的操作
// 查看
db.getCollection("system.profile").find(new Document("millis", new Document("$gt", 100)))
.sort(Sorts.descending("ts")).limit(20)
.forEach(d -> System.out.println(d.getString("ns") + " " + d.get("millis") + "ms " + d.get("command")));| 项 | MySQL | MongoDB |
|---|---|---|
| 开关 | slow_query_log=1, long_query_time=1 | profile 0/1/2 + slowms |
| 落盘 | 独立 .log 文件 | 库内 system.profile 集合( capped ) |
| 分析 | mysqldumpslow/pt-query-digest | 直接查 profile 集合 / currentOp() 看现场 |
4.7 索引的写代价与容量心法
- 每个索引都是一份排序副本:写一次、维护 N 份。对照 MySQL「单表索引别超过 5~6 个」的经验,在这里同样成立(且多键索引的数组越长写放大越大)。
- WiredTiger 缓存 ≈ InnoDB Buffer Pool:热数据(工作集)+ 索引要装进
cacheSizeGB(默认约 (RAM-1)/2)。超出则磁盘 IO 暴增——「MongoDB 变慢」的头号运维原因,扩容内存或缩集合。 nReturned与docsExamined双高 → 索引设计问题;都低仍慢 → 内存/IO 或文档过大(FETCH 大量字段、含大数组)。- 热点更新(第 3 章拆出的计数字段)注意:频繁改索引键 = 索引里删插搬迁,能
$inc非索引字段就别动索引字段。
4.8 MySQL 索引经验迁移速查表
| MySQL 经验 | 在 MongoDB | 备注 |
|---|---|---|
| 最左前缀 | ✅ 完全适用 | 组合索引列序策略照用 |
| 等值在前、范围在后 | ✅ 适用 | 同上 |
| 覆盖索引省回表 | ✅ 适用(省 FETCH) | 投影字段 ⊆ 索引字段 |
前导 % 模糊不走索引 | ✅ 同理 | $regex 无 ^ 锚定不走 |
| 主键即聚簇、二级索引回表 | ❌ 模型不同 | 默认堆存储+独立索引,无「回表聚簇」概念,但 FETCH 成本类似 |
| 函数包裹列索引失效 | ✅ 同理 | 表达式/$expr 包字段即失效 |
ORDER BY 吃索引 | ✅ 且更讲究方向 | 混合方向排序要建对键序 |
| 大 OFFSET 慢 | ✅ 同理 | 书签法 gt(_id) 替代 skip |
| 无 NULL 索引? | 独有:缺失字段/稀疏索引玩法 | 用 $exists+partial |
| — | 独有:多键索引 / TTL / 文本索引 | MySQL 无原生对应 |
4.9 本章小结 + 练习
- 索引创建/查看/删除全在
createIndex/listIndexes/dropIndex;_id自带唯一索引不可删。 - 数组即多键索引——MongoDB 查询性能的隐藏主角;内嵌数组按
父.子字段建索引。 - explain 三关键词:COLLSCAN 抓全表、DocsExamined/NReturned 比值抓精准度、IXSCAN-only 即覆盖。
- TTL/部分索引是声明式运维能力;慢查询用 profiler + Compass 双刃;容量心法=工作集进内存。
✍️ 课后练习
- 给第 3 章的 orders 集合设计索引:查询清单「按用户查最近订单(分页)」「按订单号精确查」「按 status+createTime 扫待处理」,写出索引定义与预期 explain 结果,并用代码验证
totalDocsExamined。 - 用 TTL 索引做一个「60 秒过期验证码」demo:插入后等待自动清理,观察清理延迟与
collStats变化。 - 构造一个
skip(50000)深分页慢查询,分别用 explain 量化其代价,再改写成书签法对比执行时间。 - (思考题)
comments.author建了索引,为什么find({ "comments.0.text": "hi" })大概率不走?下标定位查询该怎么建索引?
📖 参考答案
第 1 题:给 orders 集合设计三个索引并用 explain 验证
第 1 步,播种数据(1 万条,字段:userId / orderNo / status / createTime):
// mongosh 播种(orders 集合,分批 insertMany 比循环 insertOne 快一个量级)
let uids = [ObjectId(), ObjectId(), ObjectId()];
let batch = [];
for (let i = 0; i < 10000; i++) {
batch.push({ userId: uids[i % 3], orderNo: "AB" + (100000 + i),
status: i % 10 == 0 ? 0 : 1, // 10% 待处理
createTime: new Date(Date.now() - i * 60000) }); // 每分钟一单,越新 i 越小
if (batch.length == 2000) { db.orders.insertMany(batch); batch = []; }
}
if (batch.length) db.orders.insertMany(batch);第 2 步,对照查询清单建索引(等值在前、排序列方向跟上):
db.orders.createIndex({ userId: 1, createTime: -1 }); // ① 按用户查最近订单(分页):等列+排序列全吃
db.orders.createIndex({ orderNo: 1 }, { unique: true }); // ② 订单号精确查:唯一兼防重
db.orders.createIndex({ status: 1, createTime: 1 }); // ③ 扫待处理:等值 status 在前,范围时间在后第 3 步,explain 验证 totalDocsExamined 与 nReturned 比贴 1:1:
db.orders.find({ userId: uids[0] }).sort({ createTime: -1 }).skip(0).limit(20).explain("executionStats")
// 预期输出关键行:
// winningPlan.stage: IXSCAN → FETCH
// executionStats.totalKeysExamined: 20, totalDocsExamined: 20, nReturned: 20 ← 1:1,索引精准
//(没建对时:totalDocsExamined: 10000 + COLLSCAN,一眼分得出差距)
db.orders.find({ orderNo: "AB100005" }).explain("executionStats") // nReturned=1, docsExamined=1
db.orders.find({ status: 0, createTime: { $lt: new Date() } }).explain("executionStats")
// 走 idx③:keysExamined≈docsExamined≈1000(待处理全量),若想覆盖查询把需要的字段加进索引尾部Java 侧验证:orders.find(...).explain(ExplainVerbosity.EXECUTION_STATS).get("executionStats", Document.class) 取三个计数打印即可。
第 2 题:TTL 索引 60 秒过期验证码
第 1 步,建 TTL + 插验证码(字段必须是 Date 类型,字符串不会被清):
db.sms_codes.createIndex({ createTime: 1 }, { expireAfterSeconds: 60 });
db.sms_codes.insertOne({ phone: "137****", code: "8846", createTime: new Date() });
db.sms_codes.countDocuments(); // 1第 2 步,轮询观察清理(TTL 后台线程每 60s 跑一次,所以实际在 60~120s 间某个时刻消失):
// 60 秒后:db.sms_codes.countDocuments() → 可能还是 1(本轮未到)
// 120 秒后:→ 0,已被自动删除;无需任何定时任务代码第 3 步,看 collStats 变化(对照清理前后的存储占用):
db.sms_codes.stats()
// nDocument 从 1 → 0;⚠️ totalSize/storageSize 不会立即回落——删除的空间留在集合里复用,
// 和 2.9 讲的「删数据不马上还磁盘」同一心智Java 版:sms.createIndex(new Document("createTime",1), new IndexOptions().expireAfter(Duration.ofSeconds(60))),插入/轮询同构。
第 3 题:skip(50000) 深分页量化 + 书签法对比
第 1 步,播种 6 万条(用第 1 题的循环改到 60000),建 {createTime:-1} 索引。
第 2 步,量化 skip 的代价:
db.orders.find().sort({ createTime: -1 }).skip(50000).limit(20).explain("executionStats")
// totalKeysExamined: 50020, totalDocsExamined: 50020, nReturned: 20, executionTimeMillis: ≈300
// ↑ 前 50000 条被「扫过又扔掉」——和 MySQL 大 OFFSET 同病(第 4 章迁移表倒数第 2 行)第 3 步,书签法改写(上一页最后一行的 createTime+_id 续翻):
db.orders.find({ createTime: { $lt: lastTime } }).sort({ createTime: -1 }).limit(20).explain("executionStats")
// totalKeysExamined: 20, totalDocsExamined: 20, executionTimeMillis: ≈1
// 代价不随页码增长:第 1 页和第 2500 页都是 20结论输出示例:skip 版 300ms/扫 5 万 → 书签版 1ms/扫 20,差两个数量级。Java 侧同理:Filters.lt("createTime", last).sort(Sorts.descending("createTime")).limit(20)。
第 4 题(思考题)参考思路
comments.author 建的是多键索引,索引条目只有「元素值 → 文档」,不记录数组下标;而 "comments.0.text" 问的是「第 0 个元素的 text」——位置信息在索引里根本不存在,引擎只能 FETCH 出文档再看第 0 个(COLLSCAN/全档过滤)。两条出路:
- 查询侧降维:不需要位置时改用
{ "comments.text": "hi" }(任意位置命中),完美走多键索引; - 数据侧携带位置:真需要「按序号取评论」时,给每个元素存显式序号
{ idx: 0, text: ... }并建{ "comments.idx": 1, "comments.text": 1 }组合索引(多键索引对等值组合仍可命中);或者干脆评论拆独立集合带自增序号(第 3 章引用式)——下标定位变成postId+xh等值查,干净利落。
下一章:聚合管道——把 MySQL 的 GROUP BY、窗口函数、JOIN 全部翻译成流水线思维。
