MongoDB 电商实战 03|订单上百万后,查询为什么变慢?

客服反馈:“查询某个用户的已支付订单,最近越来越慢。”不要立刻回答“数据多了,应该分片”。先把实际查询、排序、返回字段和耗时拿到手。
db.orders.find({ userId: "u_1001", status: "paid" })
.sort({ createdAt: -1, _id: -1 })
.limit(20)
.explain("executionStats")读执行计划,先读工作量
重点观察 nReturned、totalKeysExamined、totalDocsExamined,再看计划里是否有集合扫描、索引扫描或额外排序。不同版本的执行计划结构可能不同,不能机械依赖固定 JSON 路径。
如果只返回 20 条,却检查了大量文档,就要进一步分析扫描范围是否过大。对于选择性较低的条件,或者必须检查大量候选记录的查询,较高的检查量不一定意味着索引设计有问题。关键是判断数据库为完成这条查询付出的工作量是否合理。IXSCAN 也不等于高效:索引范围太宽,仍可能扫描大量键。
为这个读路径建立候选索引:
db.orders.createIndex({
userId: 1, status: 1, createdAt: -1, _id: -1
})等值字段收紧范围,后面的字段支持排序。ESR 是常用起点;范围条件选择性很高时,另一种排列也可能更合适,最终由实际计划和业务负载决定。
为什么不能每个字段都建索引?
每次插入订单或修改状态,都要维护相关索引。更多索引意味着更大的磁盘占用、缓存压力和写入成本。为一个很少使用的组合增加索引,可能让所有下单请求承担成本。
应该给索引记录对应接口、查询形态和使用频率。删除索引前,至少覆盖业务周期观察;月底报表不一定会出现在今天的访问记录里。
怎样证明改动有效?
在隔离环境准备不同规模数据,并让订单状态、用户分布接近真实情况。分别记录索引前后扫描量、查询延迟、写入延迟和索引大小。测试需要说明并发、机器配置、缓存状态及样本数量,并保证前后使用相同的查询条件、返回字段和数据集。
| 指标 | 对比时关注什么 |
|---|---|
nReturned | 确认前后返回结果数量一致,避免比较不同工作量的查询 |
totalKeysExamined | 比较索引扫描量是否下降 |
totalDocsExamined | 比较文档检查量是否下降 |
| 查询延迟 | 在相近的数据、缓存和并发条件下比较 |
| 写入延迟 | 检查新增索引对订单创建、状态更新等写操作的影响 |
| 索引大小 | 评估新增索引带来的存储与缓存成本 |
这里不提供“百万数据优化到 5 毫秒”的虚构成绩。实际验收应要求:查询扫描范围显著收敛,服务 P95 延迟达到业务目标,写入成本仍可接受。
单次 explain 能解释一次执行,却不能代表线上全部请求。扫描范围减少后,仍要继续检查排序、文档大小与并发成本。
一次有代表性的排查过程
假设客服常用两个入口:按订单号找订单,按用户查最近订单。订单号查询一直正常,用户列表却变慢。这个对比非常有用:如果同一数据库里一个入口稳定、另一个退化,不能先把原因统一归结为“服务器不行”。先比较两条读路径的扫描范围、排序和结果大小。
拿到慢请求后,还要保存实际参数。一个只有两张订单的用户与一个有几千张订单的批发用户,不一定有同样的执行成本。某些状态很稀少,另一些状态覆盖大部分集合;同一查询形态下,数据分布也会影响扫描量和计划选择。
三个数字,比一个阶段名称更有解释力
设想查询返回 20 条:方案 A 检查了大量文档,方案 B 使用索引却检查了大量键,方案 C 只检查了接近结果规模的一小段键。这里不提供实测数值,但三者的工作量差异可以通过执行统计直接观察。需要注意,这只是为了说明扫描工作量的差异,并非 MongoDB 固定的三种执行阶段。实际排查时,还要结合过滤条件的选择性、数据分布、排序要求和执行计划判断,不能只凭某一个指标决定是否更换索引。
totalDocsExamined / nReturned 可以帮助理解文档检查效率,totalKeysExamined / nReturned 可以理解索引扫描效率。零结果查询不能直接做这种除法,要看它为确认“没有匹配”究竟扫描了多少。低比例不是绝对目标,范围聚合等任务本来就可能需要读取大量数据。
不要看到 FETCH 就认为索引失败。很多查询需要回表读取返回字段,这很正常;也不要为了追求覆盖查询,把长文本和各种展示字段都塞进索引。索引体积与写维护成本同样需要衡量。
为什么同一条联合索引不能服务所有入口?
我们有 {userId:1,status:1,createdAt:-1,_id:-1}。它适合限定用户和状态后的时间排序,但后台全局按状态查询不带 userId,不能期待它天然成为同样高效的读路径。另一个入口可能需要 {status:1,createdAt:-1,_id:-1}。
还有一种常见变体:只按用户查全部状态,并按创建时间排序。因为索引中间的 status 没有固定等值条件,时间顺序可能不再满足这个排序要求。必须用实际查询验证,而不是只看“所需字段都在索引里”。
// 在隔离测试中对比候选索引,不作为永久强制计划的默认写法。
db.orders.find({ userId: "u_1001", status: "paid" })
.sort({ createdAt: -1, _id: -1 })
.hint({ userId: 1, status: 1, createdAt: -1, _id: -1 })
.limit(20).explain("executionStats")hint 适合验证候选方案,不意味着应用上线后都应强制指定索引。数据分布与查询需求可能变化,长期强制计划也会成为维护负担。是否使用,需要证据与监控支持。
范围筛选出现以后,ESR 不是一句口诀就够了
例如查询某用户最近七天、已支付订单,并按金额降序。你同时有等值条件、时间范围和金额排序。先利用时间缩小范围再排序,可能适合少量匹配;先按金额有序扫描再筛时间,可能避免排序却检查更多键。二者需要用代表性日期范围和用户数据比较。
设计时先确定查询最常见的形态。七天、三个月、全部历史不是同一规模;排行榜与客服查询也不应共用一个看起来很大的索引方案。把最频繁的读路径优化好,再为低频查询限制范围,通常比试图让一个索引解决所有问题更可控。
高频问题:为什么本地 explain 很快,线上还是慢?
本地数据可能全部在缓存里,线上索引工作集超过内存;本地只有一个请求,线上多个报表与订单查询竞争 CPU 和磁盘;本地计时只包含数据库执行,线上还包含连接等待、网络传输和应用映射。这些条件不一致时,单次耗时不能直接比较。
应该同时观察数据库耗时与接口总耗时。对于一次返回大量明细的接口,减小字段和响应体积可能比再增加索引更有效。对于连接池排队,先检查操作次数和下游等待,而不是立刻增加池大小。
高频问题:索引建好后,为什么写入变慢?
订单创建需要维护所有相关索引,状态转换也可能更新包含状态的索引。活动期间读路径变快了,但下单写入延迟变差,是完全可能的。建立索引的验收应该同时覆盖读与写,不能只截图一个漂亮的查询计划。
在线建索引仍会占用资源。生产变更需要评估集合体积、磁盘余量、复制状态和变更窗口。如果要新增唯一索引,先检查历史数据是否存在重复值,并控制迁移期间的并发写入,避免检查完成后、索引建成前又写入重复数据。索引构建行为可能随 MongoDB 版本变化,不要照搬未经验证的“后台参数经验”,应以目标版本的官方文档为准。
高频问题:订单百万条,是否应该分片?
条数只是一个粗糙信息。百万条很小的文档与百万条大型文档不同;低频点查与持续聚合也不同。分片会增加路由、分片键选择、跨分片操作与运维复杂度,不能用来掩盖明显的集合扫描或深分页问题。
优先检查查询、索引、投影、模型和工作集。如果单副本集在合理优化后仍受到明确容量约束,再讨论分片和分片键。否则可能把一条低效查询分发到多个分片,一起扫描,成本反而扩大。
建立一份能复现的优化记录
记录集合规模、平均文档大小、状态占比、热点用户分布、索引列表、查询样本、版本与机器配置。测试前后保持这些条件一致,分别测冷启动和稳态,并说明它们代表什么。至少加入空结果、热门用户、稀有状态与宽范围查询。
最后写清改动解决了哪条访问路径,以及没有解决哪些需求。若这次优化仅覆盖用户订单列表,就不要宣传为“订单系统整体提速”。读者真正需要学会的是把耗时拆成工作量、建立候选方案、验证收益与成本,而不是收集一个固定的索引配方。
一个反例:返回字段越少,查询就一定越快吗?
投影能减少传输与应用处理,但若过滤仍需检查大量文档,单纯少返回几个字段并不能消除扫描。覆盖查询有机会从索引直接返回必要值,但这要符合具体索引与查询条件。不能把“投影很少”当作“完全不读取文档”的保证。
例如订单列表只显示订单号、状态、金额和时间,可以评估是否值得加入覆盖所需字段;如果订单状态频繁变化、金额字段又使索引变大,读收益未必抵过写成本。常见情况下,一条紧凑且匹配排序的索引,加少量文档读取,已经是合理方案。
还有数组字段的索引问题。订单明细里的 SKU 适合某些追踪查询,但数组会影响索引结构与可用组合。尤其要注意,复合多键索引对数组字段有约束,不能想当然地把多个数组路径放进同一个索引。不要为了同时搜索 SKU、地址标签和商品属性,机械建立包含多个数组路径的大索引。先定义具体查询,检查目标版本的索引限制,再用真实文档验证。
排除“索引问题”以后,还要看文档本身
如果订单嵌入了大量操作历史、物流轨迹或日志,列表回表时会接触更大的文档。无限增长的数据可以拆成独立集合,订单保留当前摘要与必要事实。这样既控制对象大小,也使历史追加不必不断修改同一张订单。
拆分前仍应检查读取需求。客服详情页需要少量最近事件,可以保留有上限的摘要;完整历史按需分页查询。不是所有数组都应该拆,也不是所有大文档都能只靠索引解决。模型与查询优化在这里相互影响。
最后给每次优化设一个停止条件:达到接口目标、扫描量符合范围、写入和资源成本可接受,就进入交付与观察。不断为了一个更漂亮的 explain 增加索引,可能让系统更难维护。优化的终点是业务目标,而不是计划中没有任何看起来昂贵的阶段。
参考: