1. 项目背景
业务场景:本地生活电商的 DBA 在监控中发现一个异常——某个商品集合的 tags 数组被建了 Multikey Index(多键索引),每个文档的 tags 平均只有 3 个元素。但该索引的大小竟然超过了数据本身——30GB 的集合数据配了 35GB 的 Multikey 索引。开发不理解:"3 个标签 × 5000 万文档 = 1.5 亿个索引键,为什么索引比数据还大?"
另一个问题——订单服务上线新功能时需要在 orderNo 上建唯一索引,但历史数据中有几百条重复的 orderNo(早期系统 Bug 导致)。createIndex({orderNo:1}, {unique:true}) 直接报错。DBA 想知道能不能让 MongoDB 跳过重复记录建索引?如果不能,最佳清理方案是什么?
痛点:不理解索引的底层实现就无法解释性能问题——Multikey Index 的索引键膨胀、唯一索引的冲突检测成本、后台建索引对线上写入的影响、复合索引 key 的编码方式如何影响前缀匹配。这些都是索引内部实现的"暗知识"。
2. 项目设计
小胖(看着磁盘报告目瞪口呆):大师!我们 tags 的 Multikey 索引 35GB,数据才 30GB。索引怎么可能比数据大?
大师:因为 Multikey Index 的特点——数组的每个元素都要建一个独立的索引键。但你说每个文档 3 个 tags,按道理索引键数量应该是文档数的 3 倍,为什么大小会超越数据?
小胖:对啊,我也这么算的。
大师:这里有两个容易被忽略的因素——索引大小不仅包括索引键本身,还包括每个索引键的 RecordId(8 字节)和 B-Tree 的页级开销(页头和页尾元数据)。更重要的是——WiredTiger 的 B-Tree 索引页和数据页的填充率不同。索引项通常比文档小,但页分裂会更频繁——尤其是在随机插入时,B-Tree 频繁分裂导致页填充率只有 50%-60%。
技术映射:B-Tree 索引页大小 = 4KB(默认),每个索引键 = 字段值 + RecordId + 页内指针。索引键数量多但单个键小——页分裂频繁导致空间利用率低,索引总面积就可能超过数据面积。
小胖:那复合索引 key 是怎么编码的?{city:1, createdAt:-1} 的索引 key 长什么样?
大师:MongoDB 的索引 key 使用一种称为 KeyString 的编码方式——将 BSON 值序列化为可比较的字节串。关键特性:
- 每个字段值编码后是可直接用 memcmp 做正确排序的字节串。
- 字段之间用特殊分隔符区分——前缀匹配就是截取到第一个分隔符之前。
- 降序字段(
-1)会被"比特翻转"——编码后的字节值取反,确保降序在 B-Tree 中自然排序。
技术映射:KeyString 编码 = BSON 值 → 可比较的字节串。它保证了 $gt/$lt 等操作能直接通过 memcmp 在索引高效执行。
小白:那唯一索引(Unique Index)在 B-Tree 层面是怎么实现的?插入时碰到重复键是立刻报错还是事务提交时报?
大师:唯一索引的冲突检测发生在一个文档的实际插入时刻(不是事务提交时)。MongoDB 在向 WiredTiger 的索引表插入新的索引键之前,先做一个 B-Tree 的精确查找——如果该键已存在,插入操作直接报 DuplicateKey 错,整个操作(包括单文档的原子操作)回滚。
技术映射:唯一约束在 WiredTiger 层面是通过 insert cursor 的"overwrite=false"模式实现的——不存在就插入,存在就报 WT_DUPLICATE_KEY 错误。这确保了唯一索引的高效和原子性。
小白:后台建索引(Background Index Build)对线上有影响吗?
大师:MongoDB 4.2+ 的 Backend Index Build 机制优化了很多——建索引时持有的是"collection lock?不——"它用的是两阶段锁协议。第一阶段只读扫描全集合建立索引数据的初始版本(持有共享锁,不阻塞写入)。第二阶段追 Oplog 增量(短暂持有排他锁完成最终一致性)。所以对线上写入的影响比旧版本小很多——只在最后阶段有短暂锁等待。
技术映射:后台建索引 = 全量扫描(只读) + Oplog 追增量 + 短暂锁完成切换。commitQuorum 参数控制复制集中多少个节点完成建索引后才对外可用。
小胖:那用什么替代方案处理历史数据中的重复值?我的 orderNo 有几百条重复。
大师:三种方案——① 删除重复文档(保留一条最新的);② 用 {orderNo:1, _id:1} 作为唯一索引(因为 _id 天然唯一,组合后一定唯一);③ 先建一个 non-unique 索引,找到重复值,清理后再改 unique。方案二是最常见且风险最小的。
大师(总结):索引内部四要素——KeyString 编码决定排序和比较、B-Tree 页分裂影响空间利用率、唯一约束通过插入时查找实现、后台建索引通过两阶段锁减少线上影响。
3. 项目实战
3.1 环境准备
沿用现有 MongoDB 环境。
3.2 分步实现
步骤一:Multikey Index 的索引键膨胀分析
目标:构造不同的数组场景,对比 Multikey Index 的大小。
use local_life
// 场景 A:小数组(3 个元素)
db.mk_test_a.drop()
for (var i = 0; i < 100000; i++) {
db.mk_test_a.insertOne({
name: "A_" + i,
tags: ["tag" + (i % 10), "tag" + (i % 20), "tag" + (i % 30)]
})
}
db.mk_test_a.createIndex({ tags: 1 })
var statsA = db.mk_test_a.stats()
print("小数组 (3个): 数据", (statsA.size / 1024 / 1024).toFixed(1), "MB",
"索引", (statsA.totalIndexSize / 1024 / 1024).toFixed(1), "MB")
// 场景 B:大数组(50 个元素)
db.mk_test_b.drop()
for (var i = 0; i < 5000; i++) {
var bigTags = []
for (var j = 0; j < 50; j++) bigTags.push("tag" + j)
db.mk_test_b.insertOne({ name: "B_" + i, tags: bigTags })
}
db.mk_test_b.createIndex({ tags: 1 })
var statsB = db.mk_test_b.stats()
print("大数组 (50个): 数据", (statsB.size / 1024 / 1024).toFixed(1), "MB",
"索引", (statsB.totalIndexSize / 1024 / 1024).toFixed(1), "MB")
// 场景 C:无数组(普通索引)
db.mk_test_c.drop()
for (var i = 0; i < 100000; i++) {
db.mk_test_c.insertOne({ name: "C_" + i, tag: "tag" + (i % 100) })
}
db.mk_test_c.createIndex({ tag: 1 })
var statsC = db.mk_test_c.stats()
print("无数组: 数据", (statsC.size / 1024 / 1024).toFixed(1), "MB",
"索引", (statsC.totalIndexSize / 1024 / 1024).toFixed(1), "MB")
// 关键发现:数组越大,Multikey Index 的索引键膨胀越严重
// 大数组上每个文档的索引键数量 = 数组元素数
// 标签长度相同但索引键数差异决定总索引大小
步骤二:复合索引 KeyString 编码观察
目标:通过 explain 观察复合索引的边界行为。
// 创建复合索引用来观察前缀匹配
db.keystring_demo.drop()
db.keystring_demo.createIndex(
{ city: 1, category: 1, price: 1, createdAt: -1 },
{ name: "idx_compound" }
)
// 插入不同组合的测试数据
db.keystring_demo.insertMany([
{ city: "深圳", category: "数码", price: 100, createdAt: new Date("2026-01-01") },
{ city: "深圳", category: "数码", price: 200, createdAt: new Date("2026-01-02") },
{ city: "深圳", category: "家居", price: 150, createdAt: new Date("2026-01-03") },
{ city: "广州", category: "数码", price: 300, createdAt: new Date("2026-01-04") },
{ city: "北京", category: "食品", price: 50, createdAt: new Date("2026-01-05") }
])
// 前缀匹配测试
var q1 = db.keystring_demo.find({ city: "深圳" }).explain("executionStats")
print("只用 city (前缀1):", q1.queryPlanner.winningPlan.inputStage.stage)
var q2 = db.keystring_demo.find({ city: "深圳", category: "数码" }).explain("executionStats")
print("city+category (前缀2):", q2.queryPlanner.winningPlan.inputStage.stage)
var q3 = db.keystring_demo.find({
city: "深圳", price: { $gt: 100 }
}).explain("executionStats")
print("city+price (跳过category) 前缀断裂:",
q3.queryPlanner.winningPlan.inputStage.indexName)
// 注意:price 是第三个字段,跳过了 category,索引前缀断裂
// 这种情况下 city 仍然可用于过滤,但 price 不能精确使用索引
// MongoDB 查询优化器可能会选择其他索引或接受部分前缀利用
// 观察 IndexBounds——复合索引中 Range 后面的字段如何使用
var q4 = db.keystring_demo.find({
city: "深圳", category: "数码", price: { $gt: 150 },
createdAt: { $lt: new Date("2026-06-01") }
}).explain("executionStats")
print("4字段都用到:", q4.queryPlanner.winningPlan.inputStage.stage)
print("注意:price是Range条件,price之后的createdAt即使有索引也无法精确过滤")
步骤三:唯一索引冲突检测与处理
目标:处理历史重复数据后建唯一索引。
db.unique_demo.drop()
// 插入含重复 orderNo 的测试数据
db.unique_demo.insertMany([
{ orderNo: "ORD_001", value: "第一单", createdAt: new Date("2026-01-01") },
{ orderNo: "ORD_001", value: "重复单", createdAt: new Date("2026-01-02") }, // 重复
{ orderNo: "ORD_002", value: "第二单", createdAt: new Date("2026-01-03") },
{ orderNo: "ORD_003", value: "第三单", createdAt: new Date("2026-01-04") },
{ orderNo: "ORD_003", value: "又重复", createdAt: new Date("2026-01-05") } // 重复
])
// 尝试建唯一索引——预期失败
try {
db.unique_demo.createIndex({ orderNo: 1 }, { unique: true })
} catch(e) {
print("建唯一索引失败(预期):", e.message.includes("duplicate key") ? "发现重复键" : e.message)
}
// === 方案一:删除重复(保留最早或最新的) ===
db.unique_demo.aggregate([
{ $sort: { createdAt: -1 } }, // 降序——保留最新的
{ $group: {
_id: "$orderNo",
docId: { $last: "$_id" }, // 每个分组只保留一条
count: { $sum: 1 }
}},
{ $match: { count: { $gt: 1 } } } // 只处理重复的
]).forEach(function(doc) {
// 删除该 orderNo 下除保留外的所有多余文档
db.unique_demo.deleteMany({
orderNo: doc._id,
_id: { $ne: doc.docId }
})
})
print("清理后的重复项检查...")
var duplicates = db.unique_demo.aggregate([
{ $group: { _id: "$orderNo", count: { $sum: 1 } } },
{ $match: { count: { $gt: 1 } } }
]).toArray()
print("剩余重复:", duplicates.length, duplicates.length === 0 ? "PASS" : "FAIL")
// 现在可以建唯一索引
db.unique_demo.createIndex({ orderNo: 1 }, { unique: true })
print("唯一索引创建:", "PASS")
// === 方案二:用 (orderNo + _id) 做唯一索引 ===
// 如果不想删重复数据,可以用复合唯一键
// db.unique_demo.createIndex({ orderNo: 1, _id: 1 }, { unique: true })
// 由于 _id 一定唯一,(orderNo, _id) 组合也必然唯一
// 但这不等同于 orderNo 唯一——同 orderNo 的不同 _id 允许并存
步骤四:索引构建过程监控
目标:观察后台建索引对各阶段的影响。
// heavy-index-build.js —— 大集合上建索引的性能监控
// 1. 准备一个大集合(如果还没有)
// 本章节可复用之前的 products 集合
// 2. 在后台建索引,同时观察 currentOp
db.products_idx.createIndex(
{ name: 1, price: 1 },
{ name: "idx_heavy", background: true } // 4.2+ 默认后台
)
// 3. 在另一个 mongosh 窗口中监控
var ops = db.currentOp({ active: true, "command.createIndexes": { $exists: true } })
print("正在建索引的操作:", ops.inprog.length)
if (ops.inprog.length > 0) {
ops.inprog.forEach(function(op) {
print(" 索引:", op.command.indexes[0].name)
print(" 进度:", JSON.stringify(op.progress || "无进度信息"))
print(" 运行时间:", op.secs_running, "s")
})
}
// 4. 查看索引构建的锁状态
var lockOps = db.currentOp({ waitingForLock: true })
print("等待锁的操作:", lockOps.inprog.length)
// 5. 查看 commitQuorum(仅复制集中有意义)
// 建索引时可以指定 commitQuorum:
// db.products_idx.createIndex({name:1}, {}, {commitQuorum: "majority"})
// 含义:多数节点完成索引构建后才返回成功(更安全但更慢)
步骤五:索引碎片与重建
目标:通过 compact 和 reIndex 回收碎片空间。
// 检查索引碎片
var stats = db.mk_test_a.stats()
print("索引大小:", (stats.totalIndexSize / 1024 / 1024).toFixed(1), "MB")
print("索引可复用空间:", (stats.totalIndexFreeStorageSize || 0 / 1024 / 1024).toFixed(1), "MB")
// 重建索引(WiredTiger 自动回收碎片,通常不需要手动操作)
// MongoDB 4.2+ 的 reIndex 命令会删除并重建所有索引
// db.mk_test_a.reIndex()
// 注意:reIndex 会锁定集合,生产环境慎用
// 4.4+ 推荐用 compact 而非 reIndex
// db.runCommand({ compact: "mk_test_a" })
// compact 压缩数据和索引文件,回收碎片但保留索引
// 执行期间不影响读写,但有性能开销
// 更安全的碎片回收方式:
// 1. 在 Secondary 上执行 compact
// 2. stepDown 将 Primary 切到这个已整理过的节点
// 3. 在另一个节点上重复
3.3 完整代码清单
| 文件 | 用途 |
|---|---|
mongodb-lab/scripts/ch35-multikey-bloat.js |
Multikey Index 膨胀分析 |
mongodb-lab/scripts/ch35-keystring-demo.js |
复合索引前缀匹配观察 |
mongodb-lab/scripts/ch35-unique-dedup.js |
唯一索引 + 重复清理 |
mongodb-lab/scripts/ch35-index-build-monitor.js |
建索引进度监控 |
3.4 测试验证
use local_life
// 1. Multikey Index 索引键数验证
var mkStats = db.mk_test_a.stats()
print("Multikey Index:", mkStats.totalIndexSize > 0 ? "PASS" : "FAIL")
print(" 提示:如果 nindexes > 1,检查是否包含 _id_ 索引的大小")
// 2. 唯一索引验证
var uniqueIndexes = db.unique_demo.getIndexes().filter(function(i) {
return i.unique === true && i.name !== "_id_"
})
print("唯一索引:", uniqueIndexes.length > 0 ? "PASS" : "FAIL")
// 3. 验证重复清理
var dups = db.unique_demo.aggregate([
{ $group: { _id: "$orderNo", count: { $sum: 1 } } },
{ $match: { count: { $gt: 1 } } }
]).toArray()
print("无重复:", dups.length === 0 ? "PASS" : "FAIL")
// 4. 验证复合索引前缀
var exp = db.keystring_demo.find({ city:"深圳", category:"数码" })
.explain("executionStats")
print("前缀匹配:", exp.queryPlanner.winningPlan.inputStage.stage === "IXSCAN" ? "PASS" : "FAIL")
print("\n=== 索引内部实现验证完成 ===")
4. 项目总结
4.1 索引内部机制速查
| 机制 | 实现方式 | 影响 |
|---|---|---|
| B-Tree 页 | WiredTiger 默认 4KB 页 | 页分裂频率影响空间利用率和写入性能 |
| KeyString 编码 | BSON → memcmp 可比较字节串 | 复合索引前缀匹配的本质 |
| 唯一约束 | insert cursor overwrite=false | 插入时 B-Tree 查找→ 存在则报错 |
| Multikey Index | 数组每个元素一个索引键 | 大数组导致索引键膨胀 |
| 后台建索引 | 全量扫描+Oplog追增量+短暂锁 | 对写入影响小但消耗 IO 和 CPU |
| 碎片回收 | compact / reIndex | 回收 B-Tree 页分裂产生的碎片 |
4.2 适用场景
索引内部知识适用:
- Multikey Index 膨胀诊断——大数组文档的索引调优。
- 唯一索引的历史数据清理——去重后建唯一约束。
- 复合索引字段顺序的深度理解——KeyString 前缀与 ESR 关系。
- 索引碎片化的容量规划——B-Tree 页填充率的长期影响。
4.3 注意事项
| 注意事项 | 说明 |
|---|---|
| Multikey Index 不能做覆盖索引 | 因为索引键与文档非一一对应,必须 FETCH |
| 复合索引中唯一约束是整体唯一 | {a:1, b:1} 的唯一= (a,b) 组合不重复,而非 a 不重复 |
| WiredTiger 的索引压缩 | B-Tree 索引页也受压缩算法影响(用 prefix compression 和 dictionary compression) |
| reIndex 需要锁表 | MongoDB 4.2+ 的 reIndex 会短暂持有排他锁 |
4.4 常见踩坑经验
故障案例一:Multikey Index 在 $elemMatch 下的误用
某团队用 { tags: "hot" } 查询触发了 Multikey Index,返回正确。但改成 { tags: { $elemMatch: { $eq: "hot" } } } 后反而走了 COLLSCAN——原因:$elemMatch 在 Multikey Index 上不能像普通的标量值匹配那样简单利用索引(plain scalar match 天然等价于 $elemMatch 的部分语义,但 $elemMatch 的多条件组合需要特殊索引处理)。解决:如果不需跨条件匹配同一个数组元素,直接写 tags: "hot" 即可。
故障案例二:建唯一索引时的 sparse: true 陷阱
某团队建了 {vipExpireAt: 1}, { unique: true, sparse: true } 索引。但普通用户没有 vipExpireAt 字段——稀疏索引忽略缺失字段。结果多个普通用户都可以共存(缺失字段的不入索引),但当一个普通用户升级 VIP 时——$set: {vipExpireAt: ...} 把 null 变成了具体日期,触发了索引的唯一约束(可能与其他 VIP 用户日期冲突,但与之前缺失字段的文档无关)。解决:稀疏唯一索引的使用场景有限,理解缺失字段与 null 的区别。
故障案例三:后台建索引期间的 Oplog 窗口消耗
某团队在复制集 Primary 上建一个大集合的索引。建索引期间写入量不减——Oplog 中产生了大量插入更新记录。索引构建的"增量追 Oplog 阶段"需要消耗 Oplog 窗口——如果 Oplog 窗口不够大,增量追不上,索引构建失败回滚。解决:建大索引前确保 Oplog 窗口 > 2× 预计索引构建时间;或临时增大 Oplog 大小。
4.5 思考题
- 如果一个文档的数组字段包含相同的元素(如
tags: ["a", "a", "b"]),Multikey Index 会为"a"创建几个索引键?为什么? - 复合唯一索引
{city:1, orderNo:1}中,如果文档 A 的 city 和 orderNo 都缺失——它计入唯一约束吗?如果两个文档都缺失这两个字段,它们会冲突吗?
(答案将在第 36 章末尾揭晓)
上一章思考题答案:
Plan Enumerator 不用全量执行来选最优——因为全量执行候选计划的代价甚至可能超过查询本身。对于一个返回 20 条文档的查询,如果全量执行一个不合适的候选计划(如扫描 100 万条才能判断能否排序),这 100 万条的扫描本身就是极大的开销。竞速机制让每个候选在最坏情况下只扫描首批批次(如 101 条),总开销是所有候选计划的首批成本之和,远低于单个候选计划的全量执行。
Plan Cache 命中但 totalKeysExamined 远超预期——区分 Plan Cache 问题还是统计信息问题:
db.collection.planCacheClear()后重新 explain,如果 totalKeysExamined 大幅减少→ 是 Plan Cache 缓存了过时计划;如果清除后仍很高→ 是统计信息(数据分布变化、索引选择性恶化)导致优化器在重竞速时仍选了同样的计划,此时需要优化索引设计或添加 hint。
延伸阅读与资源
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化)
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Redis 实战修炼与原理进阶
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
MongoDB 实战进阶与内核修炼
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号