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 适用场景

索引内部知识适用

  1. Multikey Index 膨胀诊断——大数组文档的索引调优。
  2. 唯一索引的历史数据清理——去重后建唯一约束。
  3. 复合索引字段顺序的深度理解——KeyString 前缀与 ESR 关系。
  4. 索引碎片化的容量规划——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 思考题

  1. 如果一个文档的数组字段包含相同的元素(如 tags: ["a", "a", "b"]),Multikey Index 会为"a"创建几个索引键?为什么?
  2. 复合唯一索引 {city:1, orderNo:1} 中,如果文档 A 的 city 和 orderNo 都缺失——它计入唯一约束吗?如果两个文档都缺失这两个字段,它们会冲突吗?

(答案将在第 36 章末尾揭晓)

上一章思考题答案

  1. Plan Enumerator 不用全量执行来选最优——因为全量执行候选计划的代价甚至可能超过查询本身。对于一个返回 20 条文档的查询,如果全量执行一个不合适的候选计划(如扫描 100 万条才能判断能否排序),这 100 万条的扫描本身就是极大的开销。竞速机制让每个候选在最坏情况下只扫描首批批次(如 101 条),总开销是所有候选计划的首批成本之和,远低于单个候选计划的全量执行。

  2. 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 实战修炼与源码剖析

posted on 2026-08-20 17:11  一天不进步,就是退步  阅读(0)  评论(0)    收藏  举报