MongoDB索引优化踩坑记录

接手了一个MongoDB项目,数据量涨到了900多万条,索引优化成了必修课。这里记录一些实际操作中踩过的坑。

索引创建方式

前台索引

默认情况下MongoDB用前台方式创建索引,这会把整个集合锁死:

1
2
// 前台创建索引 - 阻塞所有读写
db.collection.createIndex({ name: 1 })

特点:

  • 创建期间其他操作全部卡住
  • 速度相对快一些
  • 只适合维护窗口期或小数据量

后台索引

生产环境数据量大的时候,后台索引是救命稻草:

1
2
3
4
5
// 后台创建索引 - 不阻塞读写
db.collection.createIndex({ zipcode: 1 }, { background: true })

// 创建稀疏索引(跳过不存在该字段的文档)
db.collection.createIndex({ city: 1 }, { background: true, sparse: true })
对比项 前台索引 后台索引
是否阻塞
创建速度 慢约40%
资源占用 较低
使用场景 维护期 生产环境

注意:后台索引虽然不打断业务,但创建时间会更长,要做好时间规划。

千万级数据的实战经验

创建时间预估

亲身经历:927万条数据建索引,花了好几个小时。如果预估不足,会影响后续计划:

1
2
3
4
5
// 查看集合统计信息
db.collection.stats()

// 查看索引信息
db.collection.getIndexes()

查看索引进度

使用db.currentOp()可以监控索引创建的实时进度:

1
2
3
4
5
6
db.currentOp({
$or: [
{ op: "command", "query.createIndexes": { $exists: true } },
{ op: "insert", ns: /\.system\.indexes\b/ }
]
})

输出示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
"inprog": [
{
"desc": "conn1",
"threadId": "139911670933248",
"client": "127.0.0.1:37524",
"active": true,
"opid": 5014925,
"secs_running": 21,
"microsecs_running": 21800738,
"op": "command",
"ns": "test.$cmd",
"query": {
"createIndexes": "inventory",
"indexes": [
{
"ns": "test.inventory",
"key": { "item": 1 },
"unique": true,
"name": "item_1_unique_true"
}
]
},
"msg": "Index Build Index Build: 3103284/5000000 62%",
"progress": {
"done": 3103722,
"total": 5000000
},
"numYields": 0,
"locks": {
"Global": "w",
"Database": "W",
"Collection": "w"
},
"waitingForLock": false
}
],
"ok": 1
}

关键字段:

字段 说明
secs_running 已运行秒数
msg 进度百分比
progress.done 已处理文档数
progress.total 总文档数

终止索引创建

建索引过程中如果发现影响太大,可以干掉它:

1
2
// 终止指定opid的操作
db.killOp(5014925)

提示:

  • 没建完的索引会被清理掉
  • 已完成的不会受影响
  • 重启后未完成的可能会变成前台模式继续

索引创建期间的限制

连接问题

建索引时:

  • 执行创建的那个连接会被占用
  • 要干别的需要开新连接
  • 索引没建完之前不生效
  • 建完立刻能用

这期间不能做的操作

操作 后果
repairDatabase 失败
db.collection.drop() 失败
compact 失败

异常处理

mongod挂了怎么办:

  • 重启后没建完的索引会变成前台模式继续
  • 如果失败(比如重复键错误),mongod会报错退出

跳过失败的索引:

1
2
3
4
5
6
# 启动时跳过索引创建
mongod --noIndexBuildRetry

# 或在配置文件中设置
storage:
indexBuildRetry: false

性能优化建议

资源规划

内存方面:

  • 建索引需要足够内存
  • 内存不够的话速度会显著下降
  • 最好在业务低峰期操作

磁盘I/O:

  • 后台索引对性能影响小一些
  • 但还是会拖慢相关集合的操作
  • 避开高峰期是明智之选

索引创建策略

分批创建:

1
2
3
4
5
6
7
8
9
// 策略1:按业务优先级分批创建
// 第一批:核心查询字段
db.orders.createIndex({ userId: 1, createTime: -1 }, { background: true })

// 第二批:辅助查询字段
db.orders.createIndex({ status: 1 }, { background: true })

// 第三批:分析统计字段
db.orders.createIndex({ category: 1 }, { background: true })

覆盖索引:

1
2
3
4
5
6
7
8
9
10
// 避免冗余索引
// 已有索引 { userId: 1, createTime: -1 }
// 则不需要单独创建 { userId: 1 }

// 覆盖索引示例
db.users.createIndex(
{ userId: 1, name: 1, email: 1 },
{ background: true }
)
// 查询 SELECT userId, name, email 可直接使用索引

批量更新技巧

给数据加随机后缀

1
2
3
4
5
6
7
8
9
10
11
// 将name为"Guest"的记录改为"Guest"+4位随机数
db.robots.find({ name: "Guest" }).forEach(function(item) {
db.robots.update(
{ "_id": item._id },
{
$set: {
name: "Guest" + Math.floor(Math.random() * 10000 + 10000) % 10000
}
}
)
})

批量更新优化版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 使用bulkWrite批量更新
var bulk = db.robots.initializeUnorderedBulkOp();
var count = 0;

db.robots.find({ name: "Guest" }).forEach(function(item) {
bulk.find({ "_id": item._id }).updateOne({
$set: {
name: "Guest" + Math.floor(Math.random() * 10000 + 10000) % 10000
}
});
count++;

if (count % 1000 === 0) {
bulk.execute();
bulk = db.robots.initializeUnorderedBulkOp();
print("已处理: " + count);
}
});

if (count % 1000 !== 0) {
bulk.execute();
}

索引设计与监控

设计原则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 等值查询字段放前面
db.collection.createIndex({ status: 1, createTime: -1 })

// 2. 排序字段纳入索引
db.collection.createIndex({ category: 1, price: 1 })

// 3. 覆盖查询字段
db.collection.createIndex({ userId: 1, name: 1, email: 1 })

// 4. 使用部分索引(MongoDB 3.2+)
db.collection.createIndex(
{ price: 1 },
{ partialFilterExpression: { price: { $gt: 0 } } }
)

监控命令

1
2
3
4
5
6
7
8
9
10
11
// 查看查询使用的索引
db.collection.find({ userId: 1 }).explain("executionStats")

// 查看集合索引大小
db.collection.stats().indexSizes

// 查看慢查询日志
db.system.profile.find().sort({ ts: -1 }).limit(10)

// 启用慢查询分析(超过100ms)
db.setProfilingLevel(1, { slowms: 100 })

explain分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
"queryPlanner": {
"plannerVersion": 1,
"namespace": "test.inventory",
"indexFilterSet": false,
"parsedQuery": {
"item": { "$eq": "canvas" }
},
"winningPlan": {
"stage": "FETCH",
"inputStage": {
"stage": "IXSCAN",
"keyPattern": { "item": 1 },
"indexName": "item_1",
"isMultiKey": false,
"direction": "forward",
"indexBounds": {
"item": ["[\"canvas\", \"canvas\"]"]
}
}
}
},
"executionStats": {
"executionSuccess": true,
"nReturned": 1,
"executionTimeMillis": 0,
"totalKeysExamined": 1,
"totalDocsExamined": 1,
"executionStages": {
"stage": "FETCH",
"nReturned": 1,
"executionTimeMillisEstimate": 0,
"works": 2,
"advanced": 1,
"docsExamined": 1
}
}
}

七、最佳实践总结

7.1 索引创建Checklist

  • 评估索引必要性(EXPLAIN验证)
  • 选择后台创建方式(生产环境)
  • 预估创建时间并选择维护窗口
  • 监控创建进度(db.currentOp)
  • 验证索引效果(EXPLAIN确认)
  • 清理冗余索引

经验总结

场景 建议
数据量大 后台索引
并发高 分批创建,避开高峰
内存小 加内存或减少索引字段
写入多 控制索引数量
查询复杂 用复合索引

常用脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 查看所有集合索引大小
function showIndexSizes() {
db.getCollectionNames().forEach(function(collection) {
var stats = db[collection].stats();
print(collection + ":");
printjson(stats.indexSizes);
});
}

// 查找未使用索引(需开启profiling)
function findUnusedIndexes() {
return db.system.profile.aggregate([
{ $match: { op: { $in: ["query", "command"] } } },
{ $group: { _id: { ns: "$ns", index: "$planSummary" }, count: { $sum: 1 } } }
]);
}

// 索引使用分析
db.collection.aggregate([
{ $indexStats: {} }
])

写在最后

MongoDB索引管理是个需要经验的活儿。后台索引、进度监控、批量更新这些技巧,在实际运维中帮了我不少忙。

几点心得:

  1. 生产环境一定要后台索引,别堵了业务
  2. 盯着进度,心里有底
  3. 复合索引设计好,减少冗余
  4. 批量更新用Bulk,效率高
  5. 定期看看慢查询,及时调整

参考: