预见猿份
主题
首页面试题模拟面试SQL练习在线工具关于我们老苗一对一私教学员评价
实战项目
零基础学习路线项目前置基础创新WMS项目Java微服务框架与实战云岚到家项目闪聚支付项目学成在线项目青橙电商项目JVM原理与实战调优分布式事务专题Java高频面试题MySQL从入门到精通达梦数据库从入门到实战MongoDB从入门到实战PostgreSQL从入门到实战TiDB从入门到实战Java数据结构与算法Java 并发编程(JUC)实战课程老苗一对一私教学员评价
blog我的会员
  • MongoDB从入门到实战|Java 程序员的文档数据库课程

    • 课程介绍
    • 第1章 认识MongoDB与环境搭建
    • 第2章 文档CRUD与Java Driver实战
    • 第3章 数据建模与Schema设计
    • 第4章 索引与性能优化
    • 第5章 聚合管道
    • 第6章 事务、并发与一致性
    • 第7章 Spring Data MongoDB实战
    • 第8章 副本集、分片与运维安全







----- 到底线了 -----

第4章 索引与性能优化 ​

🎯 本章学习目标 ​

  1. 用 Java 创建/查看/删除索引:单列、组合、唯一,理解 _id 自带索引这条出厂设定。
  2. 掌握 MongoDB 独有的多键索引(Multikey Index):数组字段如何被逐元素索引。
  3. 复用 MySQL 最左前缀经验,并记住三处行为差异(稀疏/缺失字段、方向、排序预算)。
  4. 用 explain 看懂 COLLSCAN / IXSCAN / FETCH / covered,配合 profiler 抓慢查询。
  5. 掌握 TTL 索引(自动过期)与文本索引,知道它们各自的 MySQL 对应物或缺位。
  6. 建立「内存优先」的容量直觉:WiredTiger 缓存与工作集的关系,对照 InnoDB Buffer Pool。

4.1 创建索引:mongosh/Java 双写法与默认设定 ​

javascript
// 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"); // 删除
1
2
3
4
5
6
7
8
java
// 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");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Document("field", 1) 里的 1/-1 是索引键方向。与 MySQL 不同:单列索引的正反方向几乎不影响使用(索引可双向扫),但在组合索引 + 排序的场景,方向就不再是随便写的了(见 4.4)。

4.2 多键索引:数组字段的自动全元素索引 ​

MySQL 对 JSON 数组要生成列(generated column)才能索引;MongoDB 直接把数组每个元素建索引条目——这就是「对数组字段 eq 即包含」(第 2 章)能走索引的原因:

javascript
// 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" });
1
2
3
4
5
java
// 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"));
1
2
3
4
5
6
7
8
9

注意一个容易误解的点:多键索引不是「建」出来的,而是数据「触发」出来的。createIndex({ tags: 1 }) 的语法和普通升序索引完全一样,没有任何专门写法——当某个文档的 tags 写入的是数组时,引擎才自动把索引升级为 multikey,为数组每个元素生成一条指向该文档的索引条目。建完用 getIndexes() 验证:

javascript
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 } }
1
2
3
4
5
java
// Java 侧同样能从 listIndexes() 里看到这个字段
products.listIndexes().forEach(d -> System.out.println(d.get("multikey")));
1
2

限制要知道:一个文档同一查询里最多一个数组字段用索引(如 eq(tags,'a').eq(tags,'b') 只有部分条件走索引,$all 多值会退化);多键索引不能成为覆盖索引里被投影的数组字段(FETCH 必要)。

4.3 最左前缀:MySQL 经验直接提款的部分 ​

组合索引 (userId, createTime) 下:

javascript
// 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 });             // ✅ 等值+排序全吃
1
2
3
4
5
java
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")); // ✅ 等值+排序全吃
1
2
3
4
5

失效场景与 MySQL 基本同款:跳过前缀列、前缀列用范围后截断(range 后的列只能当过滤键)、对字段套函数($regex 不以 ^ 开头、$expr 包裹字段)。

三处差异:

  1. 缺失字段也算「值」:没这个 key 的文档可用 Filters.exists("field", false) + 稀疏索引精准命中,MySQL NULL 技巧在这里对应「缺失语义」($exists、$type);
  2. 索引方向影响排序:单列方向随意,但 sort(a ASC, b DESC) 这种混合方向,需要建 Document("a",1).append("b",-1) 才完全匹配(MySQL 8.0 前做不到,InnoDB 8.0 也才支持降序索引);
  3. 一个查询通常只用一个索引:没有 MySQL 的 index_merge 常用形态,组合条件请认真设计联合索引列序。

4.4 explain:从 COLLSCAN 开始自查 ​

java
// 方式一: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
1
2
3
4
5
6
javascript
// 方式二:mongosh(日常排慢查最常用,输出结构同上)
db.t_order.find({ userId: ObjectId("...") }).explain("executionStats")
1
2

方式三: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 没有的原生能力) ​

javascript
// mongosh 对照
db.sessions.createIndex({ createTime: 1 }, { expireAfterSeconds: 86400 });
// 注意:字段必须是 Date 类型;数组字段会按「元素中最小的日期」判定过期
1
2
3
java
// Java Driver 对照
// 会话/验证码/临时 token 场景:createTime 超 24 小时自动删除,后台清理线程每 60s 跑一次
sessions.createIndex(new Document("createTime", 1),
        new IndexOptions().expireAfter(Duration.ofHours(24)));
1
2
3
4

对照 MySQL:以前要写定时 DELETE 任务 + 分片清理防大事务,现在声明式搞定。注意删除有最多 60s 级延迟,别拿它当强一致过期。

4.5.2 文本索引:轻量搜索 ​

javascript
// 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" } });          // 按相关度排序
1
2
3
4
5
6
java
// Java Driver 对照
articles.createIndex(new Document("title", "text").append("content", "text"));
articles.find(Filters.text("索引 教程"));                        // 分词相关性打分 $meta:textScore
1
2
3

对照 MySQL FULLTEXT:能用、够轻;但中文分词弱(默认按标点/空格切),正经站内搜索请上 Elasticsearch,MongoDB 侧用 $search(Atlas)或同步方案。

4.5.3 稀疏与部分索引 ​

javascript
// mongosh 对照
db.coupons.createIndex({ code: 1 }, { partialFilterExpression: { used: false } });
// 稀疏索引(只索引含该字段的文档,历史用法,新库优先 partial):
db.coupons.createIndex({ code: 1 }, { sparse: true });
1
2
3
4
java
// Java Driver 对照
// partialFilterExpression:比 MySQL「函数索引模拟部分索引」优雅
coupons.createIndex(new Document("code", 1),
        new IndexOptions().partialFilterExpression(Filters.eq("used", false)));
1
2
3
4

只索引「未核销」的券——券码高基数时索引体积和写放大都小得多。

4.6 慢查询抓取:profiler 对照慢查询日志 ​

javascript
// 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 不记)
1
2
3
4
5
java
// 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")));
1
2
3
4
5
6
7
项MySQLMongoDB
开关slow_query_log=1, long_query_time=1profile 0/1/2 + slowms
落盘独立 .log 文件库内 system.profile 集合( capped )
分析mysqldumpslow/pt-query-digest直接查 profile 集合 / currentOp() 看现场

4.7 索引的写代价与容量心法 ​

  1. 每个索引都是一份排序副本:写一次、维护 N 份。对照 MySQL「单表索引别超过 5~6 个」的经验,在这里同样成立(且多键索引的数组越长写放大越大)。
  2. WiredTiger 缓存 ≈ InnoDB Buffer Pool:热数据(工作集)+ 索引要装进 cacheSizeGB(默认约 (RAM-1)/2)。超出则磁盘 IO 暴增——「MongoDB 变慢」的头号运维原因,扩容内存或缩集合。
  3. nReturned 与 docsExamined 双高 → 索引设计问题;都低仍慢 → 内存/IO 或文档过大(FETCH 大量字段、含大数组)。
  4. 热点更新(第 3 章拆出的计数字段)注意:频繁改索引键 = 索引里删插搬迁,能 $inc 非索引字段就别动索引字段。

4.8 MySQL 索引经验迁移速查表 ​

MySQL 经验在 MongoDB备注
最左前缀✅ 完全适用组合索引列序策略照用
等值在前、范围在后✅ 适用同上
覆盖索引省回表✅ 适用(省 FETCH)投影字段 ⊆ 索引字段
前导 % 模糊不走索引✅ 同理$regex 无 ^ 锚定不走
主键即聚簇、二级索引回表❌ 模型不同默认堆存储+独立索引,无「回表聚簇」概念,但 FETCH 成本类似
函数包裹列索引失效✅ 同理表达式/$expr 包字段即失效
ORDER BY 吃索引✅ 且更讲究方向混合方向排序要建对键序
大 OFFSET 慢✅ 同理书签法 gt(_id) 替代 skip
无 NULL 索引?独有:缺失字段/稀疏索引玩法用 $exists+partial
—独有:多键索引 / TTL / 文本索引MySQL 无原生对应

4.9 本章小结 + 练习 ​

  1. 索引创建/查看/删除全在 createIndex/listIndexes/dropIndex;_id 自带唯一索引不可删。
  2. 数组即多键索引——MongoDB 查询性能的隐藏主角;内嵌数组按 父.子字段 建索引。
  3. explain 三关键词:COLLSCAN 抓全表、DocsExamined/NReturned 比值抓精准度、IXSCAN-only 即覆盖。
  4. TTL/部分索引是声明式运维能力;慢查询用 profiler + Compass 双刃;容量心法=工作集进内存。

✍️ 课后练习 ​

  1. 给第 3 章的 orders 集合设计索引:查询清单「按用户查最近订单(分页)」「按订单号精确查」「按 status+createTime 扫待处理」,写出索引定义与预期 explain 结果,并用代码验证 totalDocsExamined。
  2. 用 TTL 索引做一个「60 秒过期验证码」demo:插入后等待自动清理,观察清理延迟与 collStats 变化。
  3. 构造一个 skip(50000) 深分页慢查询,分别用 explain 量化其代价,再改写成书签法对比执行时间。
  4. (思考题)comments.author 建了索引,为什么 find({ "comments.0.text": "hi" }) 大概率不走?下标定位查询该怎么建索引?

📖 参考答案 ​

第 1 题:给 orders 集合设计三个索引并用 explain 验证

第 1 步,播种数据(1 万条,字段:userId / orderNo / status / createTime):

javascript
// 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);
1
2
3
4
5
6
7
8
9
10

第 2 步,对照查询清单建索引(等值在前、排序列方向跟上):

javascript
db.orders.createIndex({ userId: 1, createTime: -1 });              // ① 按用户查最近订单(分页):等列+排序列全吃
 db.orders.createIndex({ orderNo: 1 }, { unique: true });           // ② 订单号精确查:唯一兼防重
 db.orders.createIndex({ status: 1, createTime: 1 });              // ③ 扫待处理:等值 status 在前,范围时间在后
1
2
3

第 3 步,explain 验证 totalDocsExamined 与 nReturned 比贴 1:1:

javascript
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(待处理全量),若想覆盖查询把需要的字段加进索引尾部
1
2
3
4
5
6
7
8
9

Java 侧验证:orders.find(...).explain(ExplainVerbosity.EXECUTION_STATS).get("executionStats", Document.class) 取三个计数打印即可。

第 2 题:TTL 索引 60 秒过期验证码

第 1 步,建 TTL + 插验证码(字段必须是 Date 类型,字符串不会被清):

javascript
db.sms_codes.createIndex({ createTime: 1 }, { expireAfterSeconds: 60 });
db.sms_codes.insertOne({ phone: "137****", code: "8846", createTime: new Date() });
db.sms_codes.countDocuments();   // 1
1
2
3

第 2 步,轮询观察清理(TTL 后台线程每 60s 跑一次,所以实际在 60~120s 间某个时刻消失):

javascript
// 60 秒后:db.sms_codes.countDocuments() → 可能还是 1(本轮未到)
// 120 秒后:→ 0,已被自动删除;无需任何定时任务代码
1
2

第 3 步,看 collStats 变化(对照清理前后的存储占用):

javascript
db.sms_codes.stats()
// nDocument 从 1 → 0;⚠️ totalSize/storageSize 不会立即回落——删除的空间留在集合里复用,
// 和 2.9 讲的「删数据不马上还磁盘」同一心智
1
2
3

Java 版:sms.createIndex(new Document("createTime",1), new IndexOptions().expireAfter(Duration.ofSeconds(60))),插入/轮询同构。

第 3 题:skip(50000) 深分页量化 + 书签法对比

第 1 步,播种 6 万条(用第 1 题的循环改到 60000),建 {createTime:-1} 索引。

第 2 步,量化 skip 的代价:

javascript
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 行)
1
2
3

第 3 步,书签法改写(上一页最后一行的 createTime+_id 续翻):

javascript
db.orders.find({ createTime: { $lt: lastTime } }).sort({ createTime: -1 }).limit(20).explain("executionStats")
// totalKeysExamined: 20, totalDocsExamined: 20, executionTimeMillis: ≈1
// 代价不随页码增长:第 1 页和第 2500 页都是 20
1
2
3

结论输出示例: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/全档过滤)。两条出路:

  1. 查询侧降维:不需要位置时改用 { "comments.text": "hi" }(任意位置命中),完美走多键索引;
  2. 数据侧携带位置:真需要「按序号取评论」时,给每个元素存显式序号 { idx: 0, text: ... } 并建 { "comments.idx": 1, "comments.text": 1 } 组合索引(多键索引对等值组合仍可命中);或者干脆评论拆独立集合带自增序号(第 3 章引用式)——下标定位变成 postId+xh 等值查,干净利落。

下一章:聚合管道——把 MySQL 的 GROUP BY、窗口函数、JOIN 全部翻译成流水线思维。

← 第3章 数据建模与Schema设计第5章 聚合管道 →








如果发现文档内容有错误或排版错乱,请及时联系站长老苗修改,不胜感激。联系我们
关于我们 | 隐私政策 | 豫ICP备2026003386号-4 | 豫公网安备41010202004008号
目录

本页无章节