1. 索引到底是怎么工作的:先搞懂B-Tree再谈优化
很多朋友用MongoDB,上来就是db.collection.createIndex({field: 1}),加完之后查询变快了就完事,索引到底干了什么、为什么有些场景加了索引反而更慢,完全没搞明白。我见过不少线上事故就是这种“盲加索引”埋下的雷,等数据量上来之后写入被拖垮,才回头找原因。
MongoDB默认使用WiredTiger存储引擎,它的索引结构是B-Tree(准确的说是B+Tree的变体)。你可以把B-Tree想象成一棵倒过来的树,根节点在上,叶子节点在下,每个节点里存了一批有序的键值,叶子节点之间通过指针串联。查询的时候从根节点往下走,每一层都能排除掉大量无关数据,最终定位到目标键值所在的叶子节点,然后顺着叶子节点拿到对应的记录位置(RecordId),再去数据文件里把整条文档捞出来。
这个结构和MySQL的InnoDB索引确实很像,但有个关键差异:MongoDB的索引叶子节点存的是RecordId,而InnoDB的主键索引叶子节点存的是整行数据。也就是说,MongoDB的索引和数据是相对分离的,索引定位之后还需要一次“回表”操作才能拿到完整文档。这也就意味着,如果索引命中了但查询的字段不在索引里,就会产生额外的数据读取成本。后面聊覆盖索引的时候,这个原理就是基础。
另外要说清楚一个概念:索引不是越多越好。每建一个索引,写入时就要多维护一棵B-Tree,更新文档时索引也要跟着改,存储空间也要占。这就是为什么我经常跟团队说,索引设计是在“读性能”和“写性能”之间做权衡,不是无脑堆。
MongoDB从4.0开始支持多文档事务,从4.2开始支持分布式事务,索引在这个体系里的地位就更重要了。事务里做查询,索引走得好不好,直接决定事务持有锁的时间,进而影响整个系统的并发能力。所以,搞懂索引,不只是DBA的事,后端开发同样绕不开。
2. 索引类型逐个拆解:从最常用的到“冷门但好用”的
2.1 单字段索引:最简单也最容易被忽视
单字段索引就是在一个字段上建索引,语法是db.collection.createIndex({field: 1}),1代表升序,-1代表降序。对单字段索引来说,升序和降序的效果几乎一样,因为B-Tree从前往后和从后往前扫都行,这个特性跟复合索引完全不同,后面会细说。
很多人在一个集合上建了一堆单字段索引,其实从查询优化器的角度看,如果查询条件只有一个字段,单字段索引就是最直接的方案。但问题往往出在“多个查询条件”上,这时候你单纯建多个单字段索引,MongoDB只会选其中一个来用,剩下的字段靠过滤。这个就暴露出单字段索引的局限了。
有一个细节值得注意:MongoDB默认给每个集合的_id字段建了唯一索引,这个索引是删除不掉的。如果你的业务有自定义主键,比如订单号、设备ID,建议在建集合的时候就指定_id为业务主键,别让MongoDB自动生成ObjectId,这样既能省一个索引,又能保证数据语义清晰。我在实际项目里见过不少人用自动ObjectId,结果后续每次查订单都要额外createIndex({orderId: 1}),白白多占存储。
2.2 复合索引:查询优化的核心武器
复合索引是在多个字段上建的索引,比如db.collection.createIndex({userId: 1, createdAt: -1})。它的核心价值在于,可以同时满足多个字段的查询条件,并且在排序场景中直接走索引完成排序,避免内存排序。
复合索引有一个必须刻在脑子里的规则:最左前缀原则。也就是说,索引定义是{a: 1, b: 1, c: 1},它能支持a、a+b、a+b+c组合的查询,但不能直接支持b或b+c组合的查询。这个跟MySQL的联合索引是一样的逻辑。
举个实际例子。我的一个用户中心集合,核心查询场景是“查某个用户最近一周的订单”,条件是userId等值加createdAt范围。如果只建{userId: 1}的索引,MongoDB能通过索引快速定位到该用户的订单,但排序需要全部取出来之后在内存里排,数据量一大就慢。而建{userId: 1, createdAt: -1}复合索引之后,因为同一个userId下的数据在索引里已经按createdAt倒序排好了,查询直接顺序扫描就能拿到结果,内存排序直接省掉。
另一个关键的规则是等值匹配在前,范围匹配在后。把等值条件的字段放在复合索引的前面,范围条件的字段放在后面,这样才能最大化索引利用率。举个例子:db.collection.find({userId: "u123", createdAt: {$gte: ISODate("2024-01-01")}}),索引应该是{userId: 1, createdAt: 1},而不是反过来。如果反过来了,优化器通常也能用这个索引,但范围字段在前的索引,在查userId等值+createdAt范围的场景下,索引选择性会差很多。
复合索引的设计是MongoDB索引优化最核心的技能,一个好的复合索引能覆盖掉90%的常见查询,同时还能结合后面说的覆盖索引达到“免回表”的效果。每次开发新需求前,我都会把该集合的所有查询列出来,按频率排序,再按最左前缀原则和等值范围规则去设计复合索引,而不是单个字段一个个建。
2.3 多键索引:数组字段的索引方案
多键索引(Multikey Index)是MongoDB处理数组字段的索引。当你在一个值是数组的字段上建索引时,MongoDB会自动为数组中的每个元素都建立索引条目,查询时只要数组中的某个元素匹配,就能命中这条文档。
比如文章集合里有个tags字段,值是字符串数组,建了db.article.createIndex({tags: 1})之后,查询db.article.find({tags: "mongodb"})就会走这个多键索引,速度非常快。
需要注意的点是:多键索引的每个文档只能有一个字段是数组。如果你尝试在{a: 1, b: 1}这样的复合索引上,a和b都是数组,MongoDB会直接报错。原因很好理解,数组字段会产生笛卡尔积式的索引条目组合,如果两个字段都是数组,索引条目会爆炸式增长,存储和查询开销完全失控。
还有一个容易被忽略的坑:多键索引字段上有数组,那么$elemMatch和$all这类操作符的查询行为会跟单值字段不一样。比如db.inventory.find({sizes: {$elemMatch: {$gt: 10, $lt: 20}}})要求的是同一个数组元素同时满足两个条件,如果不用$elemMatch而是写sizes: {$gt: 10, $lt: 20},语义就变成了“存在一个大于10的元素,也存在一个小于20的元素”,这两个元素可以是不同的。这个在索引命中的逻辑上也会产生微妙差异,要多留个心眼。
2.4 文本索引:中文全文搜索的痛点与解法
文本索引(Text Index)用于支持字符串内容的全文检索,语法是db.article.createIndex({title: "text", content: "text"}),还可以指定权重{title: 10, content: 5}来调整字段的相关性排名。查询时用$text操作符:db.article.find({$text: {$search: "mongodb index"}})。
文本索引的底层原理是分词加倒排索引。MongoDB默认对英文支持很好,但对中文来说,默认分词器对中文的支持是“每字分词”,效果非常糟糕。举个例子,搜索“数据库”会把“数”“据”“库”三个字分别建索引,查询“据库”这种不完整的词,匹配结果就很莫名其妙。
对于中文全文搜索,我的建议是不要指望MongoDB的Text Search,它更适合英文内容的轻量级搜索。中文本地化搜索项目里,常规方案是用Elasticsearch,或者用MongoDB的模糊匹配(正则)配合索引做“前缀匹配”来凑合。比如搜索商品名,可以用db.product.find({name: /^华为/}),这样走索引效率不算差,但只支持前缀匹配,中间包含关键词的查询就无解了。
文本索引还有一个明显的限制:一个集合只能创建一个文本索引,并且文本索引不能跟其他类型的字段(比如普通等值字段)放在同一个索引里。也就是说,你不能既做$text全文搜索,又用同一个索引同时过滤分类字段,这个限制对复合查询影响很大。
2.5 哈希索引:分片集群的底层支撑
哈希索引(Hashed Index)是在字段的哈希值上建立索引,语法是db.collection.createIndex({userId: "hashed"})。它的最大作用是用于分片集群中分片键的快速定位——数据按照哈希值打散到各个分片上,查询某个具体的userId时,直接计算哈希就能定位到对应分片,效率极高。
但哈希索引有非常明显的局限:它只支持等值查询,不支持范围查询、排序、前缀匹配。因为哈希值是无序的,B-Tree的排序能力在哈希索引上完全失效。所以如果你需要同时按userId等值查询、按时间范围排序,哈希索引就撑不住了,还是要用普通复合索引。
在小数据量的单机部署里,哈希索引用处不大。我已经不止一次看到新手在单机环境建哈希索引,纯粹是浪费存储。哈希索引是在你有“分片集群”需求时才值得考虑的方案,而且要注意,分片键的基数要足够大,否则数据打散不均匀,某个分片负载过高,整个集群的性能都会受到影响。
2.6 地理位置索引:附近的人、门店、骑手都靠它
地理位置索引(Geospatial Index)是MongoDB非常有特色的一类索引。有两种类型:2dsphere和2d。2dsphere适用于地球球面坐标(经纬度),2d适用于平面坐标,数据量非常大、覆盖范围小(比如游戏地图)时才用2d。
实际开发中最常见的场景是“附近的门店”。建索引db.shop.createIndex({location: "2dsphere"}),其中location字段是GeoJSON格式:{type: "Point", coordinates: [lng, lat]}。然后查询:
db.shop.find({ location: { $near: { $geometry: { type: "Point", coordinates: [116.40, 39.90] }, $maxDistance: 5000 // 单位:米 } } })2dsphere索引支持的操作符有$near、$geoWithin、$geoIntersects等。这里有个容易踩坑的细节:GeoJSON的坐标顺序是经度在前、纬度在后,写成[lng, lat],千万别写反了。我见过不止一个人把纬度放前面,结果查询结果全部跑到地图另一边去了。
地理位置索引还有一个使用技巧:可以和普通字段建复合索引。比如“查某个城市里离我最近的超市”,索引可以建{city: 1, location: "2dsphere"},先过滤城市,再做地理距离排序,性能能提升一个量级。注意,2dsphere字段在复合索引里必须放最后一位,这是MongoDB的硬性约束。
2.7 部分索引:只索引“有需要的”数据
部分索引(Partial Index)是MongoDB 3.2引入的,它只对满足特定条件的文档建立索引。语法是db.collection.createIndex({status: 1}, {partialFilterExpression: {status: {$exists: true}}})。
部分索引的核心价值有两方面。一是缩小索引体积:比如一个订单集合里有1000万历史订单,但只有10万条是“待支付”状态,你频繁查询的都是待支付订单。这时可以建部分索引db.order.createIndex({userId: 1}, {partialFilterExpression: {status: "PENDING"}}),索引只包含10万条记录,体积小、维护成本低、查询速度快。二是实现唯一约束的“有条件化”:比如你只想保证“已激活用户”的手机号唯一,但对未激活用户可以重复,这就是部分索引的典型场景。
部分索引还有一个使用上的要点:查询条件必须带上partialFilterExpression中的字段,优化器才会选择这个索引。如果你查询的时候没带status: "PENDING"这个条件,MongoDB可能就不会走这个索引。所以在设计查询时,要确保查询条件与部分索引的过滤表达式能够匹配上。
2.8 稀疏索引与TTL索引:两个特殊场景
稀疏索引(Sparse Index)只对“包含该字段”的文档建立索引条目,不包含该字段的文档不会出现在索引里。这在字段“可有可无”的集合里很有用。比如用户集合里有个phone字段,只有绑定手机号的用户才有,查询“按手机号找用户”时,建db.user.createIndex({phone: 1}, {sparse: true})就能保证索引瘦身且不报“重复键”错误。
稀疏索引和部分索引功能上有重叠,但应用场景不同:部分索引是按“条件表达式”过滤,稀疏索引是按“字段是否存在”过滤。后者可以搭配唯一约束使用,比如db.user.createIndex({phone: 1}, {sparse: true, unique: true}),这样能保证已有手机号的用户不重复,同时允许多个用户没有手机号。这个组合在生产环境非常实用,很多人不知道。
TTL索引(TTL Index)是MongoDB实现数据自动过期删除的机制。建索引时指定过期时间:db.log.createIndex({createdAt: 1}, {expireAfterSeconds: 3600}),这条文档会在createdAt超过3600秒后被后台线程自动删除。
TTL索引有几个必须注意的点:
- TTL索引只能建在日期类型字段上,或者存了日期数组的字段上
- 如果字段不存在或不是日期类型,文档不会被删除
- 后台清理线程默认每60秒跑一次,所以过期数据最长会多存活近60秒
- TTL在副本集环境里只在primary节点上执行删除操作,oplog会同步到从节点
- 千万不要在分片集合的shard key上建TTL索引,会直接报错
TTL索引适合日志、会话、验证码这类自动过期场景,用好了能省掉一堆定时任务代码。我在一个短信验证码的项目里就用它做自动清理,效果非常稳。
2.9 通配符索引:文档结构不固定时的兜底方案
通配符索引(Wildcard Index)是MongoDB 4.2引入的。当文档结构不确定、字段名不固定时,比如保存用户自定义属性的集合,你可以建一个通配符索引:db.userAttr.createIndex({"$**": 1}),这样对文档中所有字段都建立索引。
实际生产中我不推荐一上来就用$**这种全量通配符索引,因为索引体积会非常大。更合理的做法是限定范围:db.userAttr.createIndex({"custom.$**": 1}),只对custom这个子对象下面的字段做索引。或者用wildcardProjection参数指定只索引某些字段。
通配符索引可以跟特定字段的索引共存,但也有限制:一个集合只能有一个通配符索引,并且通配符索引不能与其他字段组成复合索引。所以它更像是一个“在数据模型还没稳定时的过渡方案”,一旦你的数据模式定型了,还是应该换成精确字段的复合索引。
3. 索引管理实操:从创建到调优的完整路径
3.1 索引的创建与删除:几条命令搞定
索引创建最基本的方式是createIndex。注意,如果你对一个已存在大量数据的集合建索引,createIndex默认会阻塞集合上的所有读写操作。虽然MongoDB在4.2版本之后把索引构建改成了“混合构建”模式,能在一定程度上减少对业务的影响,但在数据量极大的时候,还是建议在业务低峰期操作。
索引构建时,后台任务和前台任务的区别是一个重要的经验点。可以先查看当前索引构建进度:
db.currentOp({ $or: [ {op: "command", "command.createIndexes": {$exists: true}}, {op: "insert", ns: /system\.indexes/} ] })这个命令能列出现正在构建的索引任务。如果发现某一个索引构建严重拖垮了业务,可以通过db.killOp(opid)来终止它。
删除索引的语法是db.collection.dropIndex({field: 1}),或者用db.collection.dropIndex("索引名")。如果你没指定索引名,MongoDB会默认用字段名加排序方向拼接,比如userId_1_createdAt_-1。查看集合的所有索引用db.collection.getIndexes()。
命名索引是一个好习惯。尤其在一个集合上有几十个索引时,自动生成的索引名会让你在日志和监控里完全分不清谁是谁。手动命名的方式:
db.order.createIndex( {userId: 1, createdAt: -1}, {name: "idx_user_created_at_desc"} )建议命名规范为idx_前缀,后面按字段名加排序方向拼接,这样一眼就能看懂这个索引是干嘛的。
3.2 explain()分析:判断索引是否真正生效
MongoDB的explain("executionStats")是索引调优最核心的工具。用法:
db.order.find({userId: "u123", status: "PAID"}).explain("executionStats")重点看这几个关键指标:
queryPlanner.winningPlan:优化器最终选择的执行计划,看里面有没有IXSCAN(索引扫描),有没有SORT(排序操作)。出现SORT说明排序没走索引,可能需要调整复合索引字段顺序executionStats.nReturned:实际返回的文档数executionStats.totalDocsExamined:扫描的文档总数executionStats.totalKeysExamined:扫描的索引条目总数
判断一个查询是否高效,核心指标就是totalDocsExamined是否接近nReturned。如果totalDocsExamined是几万,nReturned只有几条,说明索引选择性差,查询的时候扫了大量无关文档,索引设计有问题。
举个实际例子。我排查过一个查询:db.order.find({status: "PAID", amount: {$gt: 100}}).sort({createdAt: -1}),索引是{status: 1, amount: 1}。explain出来发现,查询虽然走了IXSCAN,但后面挂了SORT,说明排序没吃到索引红利。后来调整索引为{status: 1, amount: 1, createdAt: -1},排序直接走索引,整个查询从几百毫秒降到个位数毫秒。
有一个容易踩的坑是:explain的结果可能具有误导性。MongoDB的查询优化器会缓存执行计划,如果数据分布变化了,缓存的计划未必是最优的。遇到数据量剧增后查询变慢的情况,可以执行db.collection.getPlanCache().clear()清理计划缓存,让优化器重新评估。这个操作在线上是安全的,不用担心。
3.3 索引性能监控:从慢查询日志到系统指标
慢查询日志是索引优化的第一入口。MongoDB默认超过100ms的查询会记录到日志里。你可以临时调低阈值来收集更细的慢查询样本:
db.setProfilingLevel(1, {slowms: 50})db.system.profile表里就能看到所有超过50ms的操作。分析这个表,找出COLLSCAN(全表扫描)或者totalDocsExamined数值离谱的查询,把这些查询收集起来,统一做索引设计。
还有一个非常实用的监控指标:db.serverStatus().metrics.indexes,可以查看索引访问和索引维护的计数器。另外db.collection.stats()里有totalIndexSize,可以横向对比每个索引的大小,看有没有明显异常膨胀的索引。
到了集群层面,MongoDB Atlas或者其他监控平台一般会有“索引建议”功能,根据实际查询负载自动推荐需要新建的索引。如果你用的是自建MongoDB,也可以用第三方工具如Percona Monitoring and Management(PMM)或者开源的mongotop、mongostat来辅助观察。mongostat重点关注qr|qw(读写队列)和ar|aw(活跃读写操作数),如果队列长期不降,大概率是某个查询在锁上堆积,索引就是排查重点。
4. 索引设计实战:一个订单系统的完整案例
4.1 场景分析与索引规划
假设一个电商订单系统,核心集合orders,初始数据量在500万左右,日增约2万条。常用的查询场景有:
- 查某个用户的订单列表,按时间倒序
- 查某个用户的某个状态(待支付/已支付/已取消)的订单
- 查某个店铺某段时间内的订单
- 按订单号精确查询
- 统计数据:某天内某个店铺的订单总额
把这些查询列出来之后,逐个分析:
场景1走{userId: 1, createdAt: -1}复合索引,userId等值、createdAt排序。 场景2需要{userId: 1, status: 1, createdAt: -1}。注意这里我把status放在createdAt之前,是因为status是等值条件,createdAt是排序条件,按“等值在前、范围/排序在后”的规则来。 场景3走{shopId: 1, createdAt: 1},如果需要同时过滤状态再加status。 场景4订单号一般是唯一键,直接建唯一索引。 场景5聚合统计,如果业务量大,可以考虑用$match在前面的聚合管道,配合场景3的索引先缩小数据范围。
最终这一套组合下来,大概需要5到6个索引。有人可能会问,场景1和场景2能不能只用一个索引{userId: 1, status: 1, createdAt: -1}?答案是:如果场景1的查询条件只有userId和createdAt,不带status,那么这个复合索引也能用,但会多扫描一层status的索引条目。在数据量小的时候差异不大,数据量大了就能感觉到。所以具体取舍要结合实际查询频率和写入压力,这没有标准答案。
4.2 覆盖索引与索引下推:两种让查询“飞起来”的技巧
覆盖索引(Covered Query)是指查询的所有字段都在索引里,MongoDB不需要回表读文档,直接从索引就能返回结果,性能极快。实现方式就是只查询索引中包含的字段,并用_id: 0把_id排除掉:
db.order.find( {userId: "u123", status: "PAID"}, {orderNo: 1, amount: 1, _id: 0} )前提是索引里包含userId、status、orderNo、amount这几个字段。这种查询的好处是,索引体积通常比文档小很多,磁盘IO和内存缓存命中率都要好很多。
实际操作用explain验证时,如果看到executionStats.totalDocsExamined为0,并且winningPlan里的IXSCAN下面有FETCH被省略,说明已经走了覆盖索引。出现FETCH步骤就说明还是有回表操作。
索引下推在MongoDB 3.2之后也有体现,叫Index Filter,也就是索引条件中的部分字段在索引层先做过滤,减少从数据页读取文档的次数。不过这跟MySQL的Index Condition Pushdown还是有点区别,MongoDB里更常见的叫法是“索引条目的字段过滤”。总之,设计索引时尽量把过滤字段都塞进索引里,这样在索引扫描阶段就能完成大部分过滤。
4.3 禁用索引扫描的三种场景
有时候你发现查询走的索引反而比全表扫描慢,MongoDB优化器也有判断失误的时候。这时候可以用hint()强制走某个索引:
db.order.find({status: "PAID"}).hint({status: 1, createdAt: -1})适用hint的典型场景有三个:
- 数据分布极度倾斜。比如status字段90%都是PAID,只有10%是PENDING。这时候如果你查的是那少数的10%,优化器可能还是会选status索引,但实际扫出来的数据量还是很大。这时可以手动
hint另一个更精准的索引。 - 复合索引字段顺序不合理,但你没权限马上改索引,先用
hint稳住线上。 - 聚合管道的
$lookup关联查询,优化器的连接顺序选择不理想,可以配合hint指定关联表使用的索引。
hint是紧急手段,不能用它来掩盖索引设计缺陷,长期依赖hint说明索引规划有问题。
5. 常见问题排查与实战心得
5.1 问题速查表
| 现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 查询慢,explain显示COLLSCAN | 条件字段没建索引 | explain查看winningPlan | 给查询条件字段建合适的索引 |
| 慢查询日志里大量SORT步骤 | 复合索引字段顺序不对,排序没走索引 | explain检查winningPlan是否有SORT | 调整索引,把排序字段加到索引里 |
| 写入变慢,磁盘空间增长过快 | 索引过多或索引体积过大 | db.collection.stats()查看indexSizes | 删除不用的索引,合并重复索引 |
totalDocsExamined远大于nReturned | 索引选择性差 | explain执行统计 | 设计更高选择性的复合索引,或改变查询条件 |
| 修改文档时提示“duplicate key” | 唯一索引冲突 | 查看错误码11000 | 检查数据是否重复,或移除唯一索引约束 |
| 文本搜索中文结果不可用 | 分词器对中文支持差 | 尝试搜索单个字验证 | 换Elasticsearch,或用正则前缀匹配兜底 |
| TTL不生效 | 字段不是日期类型 | 检查文档字段类型 | 确保字段为Date类型 |
| 分片集群插入报错 | Shard key索引缺失或不是hased | 查看分片配置 | 在分片键上建索引 |
| 复合索引查询时索引失效 | 查询字段顺序不符合最左前缀 | explain查看是否IXSCAN | 调整查询条件,或重建索引调整字段顺序 |
5.2 经验总结:我在线上环境踩过的坑
第一个坑是给低基数(low-cardinality)字段建索引。比如status字段只有三五个取值,建了索引也帮不上大忙,查询优化器很可能直接全表扫。我自己做过一次实验:100万条数据,status只有4种取值,查status为某个值的记录,全表扫描耗时差不多200ms,用索引反而要300ms(因为索引扫描之后还要回表几百次)。所以给这种字段建索引之前,心里要先有个数——它的区分度够不够。
第二个坑是索引字段顺序与查询条件顺序不一致。很多人建索引时下意识按“查询条件书写顺序”来,比如find({status: 1, userId: 1}),createIndex({status: 1, userId: 1})。这不一定错,但更合理的做法是:先写等值字段(哪个区分度高放前面),再写排序字段和范围字段。上面那个例子,如果status和userId都是等值条件,应该把userId放前面,因为它的基数远大于status。
第三个坑是滥用$regex导致索引失效。记住:^开头的正则表达式可以用索引,但中间或后面带通配符的,索引就废了。如果你有这种“包含匹配”的需求,而且数据量很大,老老实实上Elasticsearch或者用专门的全文检索方案,别在MongoDB里硬扛。
第四个坑是忽略索引构建期间对业务的影响。给一个几千万的大集合建索引,即便MongoDB声称“混合构建”,也依然会对读写产生一定的性能影响。我的经验是,给大集合建索引一定要选低峰期,建之前先评估好索引大小(可以先用db.collection.aggregate([{$indexStats: {}}])看看历史索引使用情况),确定这个索引真的有必要再加。
5.3 使用MongoDB Compass和性能分析器做可视化排查
MongoDB Compass(官方GUI工具)的性能标签页里有“索引建议”模块,它会根据你正在执行的查询自动推荐索引。我通常会把Compass当作辅助工具,但不会完全依赖它的推荐,因为它的建议是基于当前查询模式的,未必考虑到写入代价和未来的查询变化。
分析慢查询有一个非常有效的工作流:
- 开启profiling,把慢查询日志收集出来
- 把慢查询按集合分组,统计每个集合慢查询次数
- 针对最高频的慢查询,用
explain("executionStats")分析 - 记录所有
COLLSCAN的查询条件和totalDocsExamined指标 - 统一设计复合索引,一次性覆盖多个高频查询
- 用
hint逐条验证性能提升 - 业务低峰期创建索引,创建后观察一周的慢查询数量和写入延迟
这套流程我反复在多个项目里验证过,效果很稳定。
5.4 更新到MongoDB 4.4之后的变化
不少人的环境还停留在4.0或者4.2,但MongoDB 4.4里有一个跟索引相关的重大改进值得一提:复合索引里可以包含一个多键字段了。在4.4之前,复合索引里如果包含多键字段(数组字段),会限制多键字段在复合索引的排序位置。4.4之后这个限制放松了。同时4.4还支持隐藏索引(Hidden Index),可以把一个索引临时“隐藏”起来,让优化器不选它,用来评估删除这个索引对线上查询的影响。
隐藏索引在线上索引治理上是把利器。我做一个索引下线评估时,通常先隐藏索引,观察一两周业务,确认没有慢查询上涨,再真正删除。这种做法比直接删索引安全得多。
// 创建隐藏索引 db.order.createIndex( {status: 1, createdAt: -1}, {hidden: true} ) // 取消隐藏 db.order.collMod("order", { index: { keyPattern: {status: 1, createdAt: -1}, hidden: false } })如果你的环境已经是4.4+,强烈建议把隐藏索引用起来,管理成本能降不少。
6. 写在最后:索引管理是持续迭代的过程
索引设计不是一次性工作。业务在变,查询模式在变,数据规模在变,索引方案必须跟着调整。我的习惯是每两周做一次索引健康检查,对照慢查询日志、索引使用统计和磁盘空间变化,把那些“建了但几乎没被使用”的索引果断下线。毕竟每一个多余的索引,都是在给写入操作加负担。
另外多说一句,我见过太多团队因为“担心线上出问题”,该清理的索引一直不敢动,结果写性能越来越差,最后变成容量事故。其实有了隐藏索引和explain分析这两个工具,你在线上做索引变更的胆子可以大一点,但前提是每一步都要有数据支撑。
MongoDB的索引体系比大多数人想象的要丰富。单字段索引、复合索引、多键索引、文本索引、哈希索引、地理索引、部分索引、TTL索引、通配符索引,每类索引都有它独特的适用场景。理解了它们各自的原理和边界,你才能在设计数据模型的时候做出更合理的选择。希望这篇指南能帮你少走一些弯路,把每一个索引都花在刀刃上。