操作场景
在 MongoDB 中,合理设计索引是保障查询性能与系统稳定性的关键。在实际生产环境中,由于索引使用不当,往往会引发严重的可用性问题,例如:
查询性能骤降:核心查询未命中索引导致全表扫描,响应时间从毫秒级上升至秒级,引发接口超时。
内存溢出报错:千万级数据集无索引排序,突破了 MongoDB 默认的内存限制(4.4之前版本为32MB,4.4+版本为100MB),导致查询直接中断。
阻塞读写请求:在业务高峰期采用前台(Foreground)方式创建大表索引,导致集合被锁,业务读写被迫中断。
写入延迟激增:单个集合存在大量冗余索引(如20个以上),导致每次写入操作均需同步维护多个索引树,严重消耗计算资源。
本文档旨在:提供标准的索引设计与运维规范,确保核心查询精准命中索引,并保障线上环境的索引变更操作安全可控。
索引设计基本准则
准则一:ESR 准则(复合索引字段排序)
核心动作:构建复合索引时,务必按照 Equality(等值)、Sort(排序)、Range(范围) 的顺序排列字段。该原则基于 B-Tree 索引的遍历机制,旨在最大化索引利用率并消除内存排序。
顺序 | 类型 | 说明 | 查询示例 |
E | Equality(等值) | 索引最左位置,精确匹配可快速缩小扫描范围 | { status: "paid" } |
S | Sort(排序) | 紧跟 E 之后,利用索引有序性,避免内存排序 | .sort({ createTime: -1 }) |
R | Range(范围) | 索引最右位置,范围扫描后索引有序性中断,后续字段退化为逐条过滤 | { createTime: { $gte: ISODate("2024-01-01") } } |
业务应用:以订单查询接口为例,需求为"查找特定用户的已支付订单,按金额倒序,且只需最近一个月的数据"。
问题表现:索引设计为 {userId: 1, status: 1, createTime: 1, amount: -1},将 Range(createTime)放在了 Sort(amount)之前。索引在时间字段上发生发散,导致后续的金额排序无法走索引,触发内存排序,查询延迟达秒级且存在 OOM 风险。
优化操作:调整为 {userId: 1, status: 1, amount: -1, createTime: 1},严格遵循 ESR 顺序。等值字段精准过滤后,直接利用索引的天然顺序输出排序结果,最后通过时间字段进行边界截断。查询延迟从秒级降至 10ms,性能提升100倍以上。
ESR 索引设计示例:以下示例对比 ESR 正确顺序与错误顺序的索引设计,演示 Range 字段位置对排序性能的影响。
// 业务查询语句db.t_orders.find({userId: 10086, // E: 等值status: "paid", // E: 等值createTime: { $gte: ISODate("2024-02-01") } // R: 范围}).sort({ amount: -1 }) // S: 排序// 错误:Range 在 Sort 之前,索引在 createTime 发散后无法支撑 amount 排序,触发内存排序db.t_orders.createIndex({ userId: 1, status: 1, createTime: 1, amount: -1 },{ background: true })// 正确:ESR 顺序,等值过滤 → 索引排序 → 范围截断db.t_orders.createIndex({ userId: 1, status: 1, amount: -1, createTime: 1 },{ background: true })
准则二:最左匹配准则
核心动作:查询条件必须显式包含复合索引的最左侧字段,否则索引无效。若业务场景中存在跳过最左字段的独立查询需求,必须为该字段单独构建索引。
复合索引的 B-Tree 结构具有严格的字段先后依赖关系:先按第一字段进行全局排序;在第一字段相同的区间内,再按第二字段进行局部排序。若查询缺失了“最左字段”这一全局搜索基准,数据库便无法利用索引树进行路径导航,查询将被迫退化为全表扫描(COLLSCAN)。
假设集合已建立复合索引 { a: 1, b: 1, c: 1 },不同查询条件的索引利用路径如下表所示:
查询条件 | 索引利用率 | 执行说明 |
{ a: 1 } | 完全利用 | 命中最左前缀 a。 |
{ a: 1, b: 1 } | 完全利用 | 命中最左前缀 a, b。 |
{ a: 1, b: 1, c: 1 } | 完全利用 | 完整匹配全部索引字段 a, b, c。 |
{ a: 1, c: 1 } | 部分利用 | 仅能利用最左字段 a 进行索引分支定位,因缺失中间字段 b,导致 c 字段的索引匹配被阻断。 |
{ b: 1 } | 无法使用 | 缺失最左字段 a,失去搜索基准,触发全表扫描。 |
{ b: 1, c: 1 } | 无法使用 | 缺失最左字段 a,触发全表扫描。 |
业务应用:以某业务系统为例,原集合已建立复合索引 {userId: 1, status: 1, createTime: 1}。
问题表现:运营后台发起独立查询 db.orders.find({status: "pending"}),因缺失最左前缀 userId,复合索引完全无法使用,触发全表扫描。高峰期接口超时率飙升至30%。
优化操作:识别到跳过最左字段的查询需求后,为 status 字段单独建立单键索引 {status: 1},恢复系统可用性。
最左匹配索引示例:以下示例演示缺失最左字段导致索引失效的场景及对应解决方案。
// 已有复合索引db.t_orders.createIndex({ userId: 1, status: 1, createTime: 1 },{ background: true })// 正确:包含最左字段 userId,索引完全命中db.t_orders.find({ userId: 10086, status: "paid" })// 部分命中:包含最左字段 userId,但跳过 status,仅利用 userId 定位db.t_orders.find({ userId: 10086, createTime: { $gte: ISODate("2024-01-01") } })// 错误:缺失最左字段 userId,触发全表扫描db.t_orders.find({ status: "pending" })// 解决方案:为独立查询字段单独建立索引db.t_orders.createIndex({ status: 1 },{ background: true })
法则三:高基数字段前置准则
核心动作:在构建包含多个等值匹配(Equality)条件的复合索引时,务必将区分度高(高基数)的字段排在最前面,区分度低(低基数)的字段排在后面。
将高基数(区分度高)字段前置,可利用其显著的“选择性”在查询初期迅速缩小搜索范围,确保数据库在 B+ 树索引搜索阶段排除绝大多数无关记录,减少磁盘 I/O 吞吐和数据页扫描量。
根据最左匹配原则,高基数前置的索引可同时支撑“组合查询”与“单字段前缀查询”,从而实现索引的高效复用。通过这种策略可以减少冗余索引的建立,在保证查询速度的同时,有效降低存储空间占用及频繁写操作(增删改)带来的索引维护开销。
区分度级别 | 数据特征 | 字段示例 | 索引排位策略 |
高(High) | 具有唯一性或极少重复 | 用户 ID、订单号 | 应优先作为复合索引的最前置字段。 |
中(Medium) | 存在一定重复,但分类较多 | 城市、商品分类 | 有效。通常排在复合索引的中段,需联合使用。 |
低(Low) | 值域极小,海量重复 | 性别、布尔值 | 差。若必须纳入复合索引,应严格置于最末端。 |
业务应用:高基数前置的核心价值是高基数字段作为索引前缀时,能被更多查询模式复用,用更少的索引覆盖更多场景。
以某订单系统为例,原集合已建立复合索引
{userId, status}:随着业务的需求变化,产生以下两种查询需求:场景 A:
db.orders.find({ userId: "U123" })效果: 直接命中复合索引的最左前缀,由于 userId 区分度高,检索性能很好。
场景 B:
db.orders.find({ status: "completed" })分析: 该查询虽无法利用现有索引,但是由于 status 字段本身区分度很低,即使通过
{status, userId}索引匹配最左边的 status 字段,其性能也会很差。 索引创建规范
规范一:后台建索引原则(Background Indexing)
核心动作:在生产环境中执行创建索引操作时,必须显式指定 background: true 参数(注:针对 MongoDB 4.2 之前的版本)。
MongoDB 在执行默认的前台(Foreground)建索引操作时,会获取集合甚至数据库级别的排他锁(Exclusive Lock)。在此构建期间,目标集合上的所有读写请求均会被完全阻塞。通过启用 background: true 模式,数据库会在后台交替处理建索引任务与业务读写请求,从而消除全局锁阻塞等待,保障系统的高可用性。
说明:
自 MongoDB 4.2 起,所有索引构建均默认采用优化的非阻塞机制,background 参数虽被弃用但仍可被解析兼容。
业务应用:某电商 DBA 在白天业务期对千万级订单表执行单键索引创建。
问题表现:执行 db.t_orders.createIndex({ customerId: 1 }) 时未指定 background 参数。MongoDB 4.0默认前台建索引,获取排他锁导致整个订单库的读写操作全部阻塞,持续40分钟,直接引发大促期间严重故障。
优化操作:使用 background: true 后台模式建索引,构建期间业务正常运行,仅有轻微性能下降。多个索引可合并为一次批量构建操作,进一步降低 I/O 损耗。
后台建索引示例:以下示例演示前台建索引的阻塞风险及后台模式的正确用法。
// 错误:未指定 background,可能阻塞所有操作db.t_orders.createIndex({ customerId: 1 })// 单个创建:开启 background 模式,避免阻塞业务db.t_orders.createIndex({ customerId: 1, createTime: -1 },{ background: true })
规范二:控制索引数量
核心动作:限制单个集合的索引规模:常规建议数量 ≤ 10 个,绝对红线不超过20个。
当集合中存在过多索引时,系统将面临以下维度的性能衰退:
资源维度 | 性能损耗说明 |
写入延迟(Write I/O) | 每次执行 Insert、Update 或 Delete 时,数据库必须同步遍历并更新所有相关索引的 B-Tree 结构。索引越多,单次写入的 I/O 开销呈线性增长。 |
存储占用(Disk) | 每一个索引均是一棵独立的数据树,会占用大量的物理磁盘空间。 |
业务应用:某电商订单表为了满足各类查询,累积创建了高达25个索引。
问题表现:每产生1笔新订单,数据库需同步更新25棵索引树,导致该表的索引总容量(120GB)竟然反超了数据本身(50GB),单条订单的写入延迟从5ms持续上升至50ms。
优化动作:经排查,直接删除了15个调用次数为0的历史报表索引和前缀重复索引。写入延迟瞬间回落至5ms的正常水平,同时直接释放了约70GB的磁盘与内存空间。
确认索引数量示例:以下示例演示如何排查冗余索引并进行清理。
// 第一步:查看集合当前所有索引db.t_orders.getIndexes()// 第二步:通过 $indexStats 排查每个索引的实际使用情况db.t_orders.aggregate([{ $indexStats: {} }]).forEach(function(idx) {print("索引名称:", idx.name,"| 调用次数:", idx.accesses.ops,"| 最近使用:", idx.accesses.since)})// 第三步:查看索引与数据的存储占比var stats = db.t_orders.stats()print("数据大小:", (stats.size / 1024 / 1024).toFixed(2), "MB")print("索引大小:", (stats.totalIndexSize / 1024 / 1024).toFixed(2), "MB")print("索引/数据比:", (stats.totalIndexSize / stats.size * 100).toFixed(1), "%")// 第四步:删除调用次数为 0 的冗余索引// 操作前务必确认该索引确实无业务依赖db.t_orders.dropIndex("idx_legacy_report_1")db.t_orders.dropIndex("idx_legacy_report_2")// 注意:禁止删除 _id 默认索引// db.t_orders.dropIndex("_id") // 此操作会报错,_id 索引不可删除
规范三:冗余索引的安全清理
核心动作:建立定期巡检机制,使用 `$indexStats` 管道操作符识别零调用的未使用的索引,并结合隐藏索引(Hidden Index)特性执行安全下线。
$indexStats 可以精准捕获索引的真实调用频次,再结合 MongoDB 4.4+引入的 hideIndex 特性,可以对目标索引进行“逻辑屏蔽”(优化器不再使用,但后台仍维护更新),在确认无业务影响后再执行“物理删除”,从而实现零风险的资源回收。
业务应用:某物流平台的订单集合 t_orders 运行2年后累积了23个索引,其中多个是早期需求迭代遗留的废弃索引和功能重叠的冗余索引。每次写入操作需同步更新所有索引,导致写入延迟从初期的5ms逐步上升至15ms,且索引占用存储空间已超过数据本身。
问题表现:通过 $indexStats 聚合分析发现,23个索引中有14个近90天访问次数为0,属于完全无用索引;另有多组索引存在前缀重叠(如 {userId: 1} 与 {userId: 1, status: 1} 并存),低选择性索引可被复合索引覆盖。
优化操作:对14个零访问索引逐一执行 hideIndex 隐藏,观察7天确认无业务影响后物理删除;合并前缀重叠索引,最终索引数从23个缩减至9个。清理后写入延迟从15ms降至5ms(提升约30%),索引存储空间从18GB降至7GB(节省约40%)。
安全清理代码示例:以下示例演示冗余索引的完整清理流程——先通过 $indexStats 识别零访问索引,再用 hideIndex 逻辑隐藏并观察,确认无业务影响后再物理删除。
// 第一步:执行统计聚合,识别无用索引db.t_orders.aggregate([{ $indexStats: {} }])/* 输出示例分析:{"name": "idx_old_field","accesses": {"ops": NumberLong(0), // 关键指标:调用次数为 0"since": ISODate("2024-01-01T00:00:00Z")}}*/// 第二步:逻辑隐藏(支持快速回滚,MongoDB 4.4+)db.t_orders.hideIndex("idx_old_field")// 观察期:持续监控 1-7 天,若业务受损可立即执行 db.t_orders.unhideIndex("idx_old_field") 恢复。// 第三步:物理删除(确认无损后执行)db.t_orders.dropIndex("idx_old_field")
特殊索引使用规范
规范四:TTL 索引自动清理过期数据
核心动作:对于日志、会话等有过期时间的数据,建议使用 TTL (Time-To-Live) 索引实现自动过期清理,避免通过应用层定时脚本执行手动的海量批量删除。
在使用 TTL 索引时,需严格遵循以下机制与限制:
特性 / 限制 | 规范说明 |
单键约束 | TTL 索引必须是单键索引(Single Field Index)。若 TTL 字段被放入复合索引中,TTL 自动回收特性将直接失效。 |
数据类型 | 目标字段的数据类型必须是 Date 类型(或包含 Date 类型的数组),否则引擎不会执行清理。 |
清理延迟 | 并非到期精确秒级删除。默认情况下,TTLMonitor 线程每60秒轮询一次,因此数据的物理删除存在分钟级的延迟。 |
引擎参数控制 | 线上若存在性能波动,DBA 可通过动态调整 ttlMonitorSleepSecs(控制轮询休眠间隔)和 ttlDeleteBatch(控制单批次删除量)来进一步平抑 I/O 资源消耗。具体操作,请参见 参数配置。 |
业务应用:某日志中台每天凌晨2点通过定时任务,下发 db.logs.remove({createTime: {$lt: 某时间戳}}) 命令,集中删除30天前的数亿条历史日志。集中式的海量删除引发严重的锁等待,数据库 CPU 瞬间飙升至100%。同时,产生的巨量 Oplog 导致集群主从延迟高达数分钟,严重影响了依赖该集群的其他在线业务。改用 TTL 索引后,MongoDB 后台任务自动、平滑地淘汰过期数据,对业务完全无感知。
代码示例:根据业务灵活度,通常有两种 TTL 索引设计模式。固定时长淘汰适用于统一过期策略的场景,精准时间戳淘汰适用于需要按文档粒度动态控制存活时间的场景。
// 模式一:固定时长淘汰(例如:按 createTime 字段,30 天后过期)db.t_sessions.createIndex({ createTime: 1 },{ expireAfterSeconds: 2592000, background: true })// 模式二:精准时间戳淘汰(推荐,由业务层动态决定存活时间)// 将 expireAfterSeconds 设为 0,引擎将在 expireAt 时间点到达时进行清理db.t_sessions.createIndex({ expireAt: 1 },{ expireAfterSeconds: 0, background: true })// 业务层插入时,直接指定具体的死亡时间db.t_sessions.insertOne({sessionId: "sess_001",userId: "user_001",expireAt: new Date(Date.now() + 3600000) // 动态指定 1 小时后过期})
规范五:时序索引高效存储与查询时间序列数据
核心动作:对于 IoT 传感器、系统监控、日志采集等按时间持续写入的数据,建议使用 MongoDB 5.0+提供的时序集合(Time Series Collection)替代普通集合,通过自动按时间分桶和桶内字段列式聚合获得更高压缩率与范围查询效率,避免在普通集合上手动维护时间戳索引和数据归档逻辑。
在使用时序集合时,需严格遵循以下机制与限制:
特性 / 限制 | 规范说明 |
必选参数 timeField | 创建时序集合时必须指定 timeField,该字段的值必须是 Date 类型,用于标识每条测量值的时间戳。MongoDB 据此自动分桶存储。 |
推荐参数 metaField | metaField 用于标识数据源(如设备 ID、传感器编号),MongoDB 据此对数据进行分区。建议选择很少变化的标识符,避免使用数组类型。 |
粒度设置 granularity | 建议根据同一数据源相邻测量值的时间间隔选择粒度: seconds(桶跨度1小时)、minutes(桶跨度24小时)、hours(桶跨度30天)。粒度过粗导致单桶数据量过大查询变慢,粒度过细导致桶数量激增浪费存储。 |
自动索引 | MongoDB 6.0+自动在 timeField 和 metaField 上创建二级索引,低版本需手动创建。 |
不支持的操作 | 时序集合不支持 distinct() 高效执行(建议用 $group 聚合替代);8.0以下版本不支持区域分片(Zone Sharding);metaField 一旦定义不可更换为其他字段。 |
业务应用:某 IoT 平台每秒接收数万条传感器数据,使用普通集合存储后,3个月数据量突破10亿条。按时间范围查询最近24小时的温度趋势需要扫描数千万条文档,P99延迟超过5s,且存储空间持续膨胀。迁移至时序集合后,MongoDB 自动按
metaField(设备 ID)分区、按 timeField 分桶压缩,存储空间减少约70%,相同查询延迟降至200ms以内,同时无需额外维护数据归档脚本。代码示例:根据数据采集频率选择合适的粒度参数,是时序集合性能优化的关键。
// 推荐:创建时序集合(传感器每 5 分钟上报一次)db.createCollection("t_sensor_data", {timeseries: {timeField: "timestamp", // 必选:时间戳字段metaField: "sensorId", // 推荐:数据源标识granularity: "minutes" // 粒度匹配采集频率(5 分钟 → minutes)}})// 推荐:创建时序集合(系统监控每秒采集)db.createCollection("t_metrics", {timeseries: {timeField: "ts",metaField: "metadata", // metadata 可以是对象,如 { host: "web01", region: "bj" }granularity: "seconds" // 粒度匹配采集频率(秒级 → seconds)}})// 插入时序数据db.t_sensor_data.insertMany([{ sensorId: "sensor_001", timestamp: new Date(), temperature: 23.5, humidity: 65 },{ sensorId: "sensor_001", timestamp: new Date(), temperature: 23.6, humidity: 64 },{ sensorId: "sensor_002", timestamp: new Date(), temperature: 18.2, humidity: 72 }], { ordered: false }) // ordered: false 提升批量写入性能// 错误:在普通集合上手动管理时序数据db.createCollection("t_sensor_data_old")db.t_sensor_data_old.createIndex({ sensorId: 1, timestamp: -1 })// 需要自行处理数据归档、压缩、清理,维护成本高// 查询最近 24 小时某设备的数据(自动利用时序索引)db.t_sensor_data.find({sensorId: "sensor_001",timestamp: { $gte: new Date(Date.now() - 86400000) }}).sort({ timestamp: -1 })// 用 $group 聚合替代 distinct()(时序集合不支持高效 distinct)db.t_sensor_data.aggregate([{ $match: { timestamp: { $gte: new Date(Date.now() - 86400000) } } },{ $group: { _id: "$sensorId" } }])
排序与内存限制
规范六:排序字段建议加入索引
核心动作:所有排序操作的字段必须包含在索引中,禁止内存排序。
MongoDB 执行排序有两种方式——索引排序和内存排序。当排序字段已被索引覆盖时,数据库直接按索引的有序结构返回结果,无需额外排序计算;当排序字段未建索引时,数据库必须将所有匹配文档加载到内存中排序(即 SORT 阶段),消耗大量内存资源。
内存限制:内存排序的数据量超过版本限制(4.2及以前为32MB,4.4+为100MB)时,查询直接报错失败。
索引设计:在复合查询场景中,排序字段应与查询条件字段组合为复合索引,且排序字段的方向(升序/降序)必须与索引定义一致,从而确保数据库利用索引同时完成过滤和排序。
MongoDB 版本 | 默认内存限制 | 超限后果 |
4.2及以前 | 32MB | 报错,查询失败 |
4.4+ | 100MB | 报错,查询失败 |
业务应用:某电商平台运营后台的"按成交额倒序"报表,底层查询为
db.t_orders.find({ status: "paid" }).sort({ amount: -1 }),涉及千万级订单数据。问题表现:订单量突破800万条后,排序数据量超过32MB(MongoDB 4.2限制),查询报错
Sort operation used more than the maximum 33554432 bytes of RAM,报表功能不可用,影响运营决策2天。优化操作:通过
explain("executionStats") 确认执行计划存在 SORT 阶段,排序字段 amount 缺少索引,触发内存排序。建立 { status: 1, amount: -1 } 复合索引,status 用于过滤、amount 利用索引有序性完成排序,SORT 阶段消除,响应时间从超时降至200ms。代码示例:以下示例演示如何为排序字段建立复合索引,并通过 explain("executionStats") 验证执行计划中是否消除了 SORT 阶段。
// 错误:排序字段未建索引,可能触发内存排序db.t_orders.find({ status: "paid" }).sort({ createTime: -1 })// 正确:排序字段包含在索引中db.t_orders.createIndex({ status: 1, createTime: -1 }, { background: true })db.t_orders.find({ status: "paid" }).sort({ createTime: -1 }) // 索引排序,无内存限制// 验证是否使用索引排序db.t_orders.find({ status: "paid" }).sort({ createTime: -1 }).explain("executionStats")// 检查是否有 SORT 阶段,无 SORT 表示使用了索引排序
避免低效操作符
规范七:避免使用无法利用索引的操作符
核心动作:避免使用
$ne、$nin、无前缀 $regex、$where 等低效操作符。否定操作符的代价:$ne 和 $nin 属于否定条件,数据库无法通过索引直接定位目标文档,而是需要扫描索引中所有不匹配的条目再逐一排除,本质上退化为全索引扫描甚至全表扫描。
正则与脚本的代价:无前缀锚定的 $regex 无法利用索引的有序性进行范围查找,数据量越大性能衰减越严重。
优化原则:将否定条件转换为正向条件($ne → $in),将模糊匹配改为前缀锚定,将 $where 改写为标准查询操作符。
低效操作符及优化方案:下表列出常见的无法有效利用索引的操作符及其对应的优化替代方案。
操作符 | 问题 | 优化方案 |
$ne | 需扫描所有非匹配值 | 改用 $in 列出有效值 |
$nin | 需扫描所有不在列表中的值 | 改用 $in 正向匹配 |
$not | 通常无法利用索引 | 改用正向条件 |
$regex(无前缀) | 前缀无锚定无法利用索引 | 使用前缀锚定 /^prefix/ |
$where | JavaScript 执行,极慢 | 改用标准查询操作符 |
$exists: false | 需扫描所有文档 | 使用稀疏索引或重新设计 |
业务应用:某会员管理系统查询"非 VIP 用户"列表,底层查询为 db.t_users.find({ level: { $ne: "VIP" } }),用户总量约500万。
问题表现:$ne 操作符无法有效利用 level 字段索引,每次查询触发全表扫描,数据库 CPU 持续高位运行,P99延迟超过2s,页面加载缓慢。
优化操作:将否定条件改为正向枚举 { level: { $in: ["普通", "银卡", "金卡"] } },查询直接命中索引,P99 延迟从2s降至40ms。
代码示例:以下示例对比否定操作符和正向操作符的写法差异,以及正则表达式的前缀锚定优化。
// 低效:$ne 无法有效利用索引db.t_orders.find({ status: { $ne: "cancelled" } })// 优化:$in 列出所有有效状态db.t_orders.find({ status: { $in: ["pending", "paid", "shipped", "completed"] } })// 低效:无前缀正则,全表扫描db.t_users.find({ name: /张/ })// 优化:前缀锚定正则,可利用索引db.t_users.find({ name: /^张/ })
索引设计检查清单
上线前必查
检查项 | 验证方法 | 通过标准 |
所有查询都命中索引 | explain("executionStats") | stage 为 IXSCAN,无 COLLSCAN |
无内存排序 | explain("executionStats") | 无 SORT 阶段 |
扫描效率合理 | totalDocsExamined / nReturned | 比值接近1 |
复合索引遵循 ESR | 审查索引字段顺序 | Equality → Sort → Range |
索引数量可控 | db.collection.getIndexes() | ≤ 10个 |
建索引使用 background | 审查建索引命令 | 包含 background: true |
定期检查
检查项 | 验证方法 | 操作建议 |
无用索引清理 | $indexStats | ops: 0 的索引评估后删除 |
索引使用率 | $indexStats | 低使用率索引考虑删除 |
索引大小 | db.collection.stats().indexSizes | 异常大的索引需要分析 |
索引优化建议
当集合的索引策略难以人工判断时,建议通过数据库智能管家(DBbrain)的索引推荐功能进行辅助决策。DBbrain 基于实际慢查询日志和查询模式,自动分析并推荐最优索引方案,帮助识别缺失索引和冗余索引。具体操作,请参见 索引推荐。
常见问题
Q1:索引建好了,查询还是慢?
1. 确认查询是否命中索引。
db.t_orders.find({ status: "paid" }).explain("executionStats")
2. 检查扫描效率:通过 explain() 方法分析查询的执行计划,在返回结果中检查索引命中的效率。
// 关键指标,接近 1:索引高效,扫描即命中totalDocsExamined / nReturned ≈ 1 // 理想值
3. 检查是否存在内存排序:在 explain("executionStats") 的返回结果中,沿着 executionStages 逐层查看 stage 字段:
// 在 explain 结果中定位排序阶段executionStats.executionStages.stage// 或嵌套在 inputStage / inputStages 中
stage 值 | 含义 | 是否需要优化 |
SORT | 内存排序,未利用索引有序性 | 需优化 |
SORT_KEY_GENERATOR | 正在提取排序键,配合 SORT 出现 | 需优化 |
无 SORT 阶段 | 排序由索引天然有序性完成 | 无需优化 |
4. 检查索引字段顺序是否符合 ESR 原则。
// 不符合 ESR:范围字段在前,排序字段在后db.t_orders.createIndex({ createTime: 1, status: 1 })db.t_orders.find({ createTime: { $gte: ISODate("2024-01-01") }, status: "paid" }).sort({ amount: -1 })// 符合 ESR:等值 → 排序 → 范围db.t_orders.createIndex({ status: 1, amount: -1, createTime: 1 })
Q2:复合索引和多个单字段索引,哪个好?
绝大多数场景下,复合索引优于多个单字段索引。
对比维度 | 多个单字段索引 | 复合索引 |
索引方案 | { status: 1 } + { userId: 1 } + { createTime: 1 } | { status: 1, userId: 1, createTime: -1 } |
查询过程 | 查询一般只有一个字段走索引 | 单次 B-Tree 查找直接定位 |
排序 | 索引无法覆盖排序,可能触发内存 SORT | 索引天然有序,无需内存排序 |
覆盖查询 | 无法实现,必须回表取完整文档 | 若查询字段全部包含在索引中,可直接返回结果,无需回表 |
内存开销 | 整体内存开销更大 | 无额外开销 |
// 多个单字段索引:MongoDB 尝试索引交集,效率不稳定db.t_orders.createIndex({ status: 1 })db.t_orders.createIndex({ userId: 1 })db.t_orders.createIndex({ createTime: 1 })// 复合索引:一个索引覆盖查询 + 排序,遵循 ESR 原则db.t_orders.createIndex({ status: 1, userId: 1, createTime: -1 })// 当查询模式不固定、字段组合多变时,单字段索引更灵活:// 场景:运营后台的动态筛选,用户可能按任意字段组合查询,此时为每种组合建复合索引不现实,单字段索引 + 索引交集是合适的选择db.t_orders.find({ status: "paid" }) // 只按状态db.t_orders.find({ userId: "u_10001" }) // 只按用户db.t_orders.find({ createTime: { $gte: ISODate("...") } }) // 只按时间db.t_orders.find({ status: "paid", userId: "u_10001" }) // 状态 + 用户
Q3:大集合如何安全建索引?
1. 选择低峰期:在业务低峰期执行索引构建,降低对线上读写的影响。
2. 分片集群关闭 Balancer:MongoDB 4.4以下版本需在建索引前关闭 Balancer,避免数据迁移与索引构建并发冲突。
sh.stopBalancer()
3. 执行后台建索引:添加 { background: true } 选项(仅适用于 MongoDB 4.2及以下版本,4.2+版本索引构建默认不阻塞读写操作)。
db.orders.createIndex({ userId: 1, createTime: -1 }, { background: true })
4. 监控索引构建进度与资源:持续观察 CPU、内存、磁盘 IO 使用情况,必要时终止构建。
注意:
严禁在索引构建期间删除同集合索引:MongoDB 4.4以下版本采用串行索引构建机制,若从节点尚未完成索引构建,此时对同一集合触发删除索引操作(如 dropIndex),可能导致从节点复制中断甚至不可用。建索引期间应冻结该集合的所有索引变更操作。等待 currentOp 返回为空,确认构建完成后再操作。
// 查看当前正在执行的索引构建任务db.currentOp({ "command.createIndexes": { $exists: true } })
5. 预估时间:大集合索引构建可能需要数小时,提前评估并预留维护窗口。