ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MongoDB分页性能优化:用游标分页替代skip的实践指南

MongoDB分页性能优化:用游标分页替代skip的实践指南 做后端这些年分页需求几乎每天都在写。业务方一句数据量大不少同学下意识就会写下db.collection.find().skip(offset).limit(size)刚开始线上几万条数据时跑得飞快等数据涨到几百万、几千万接口开始变慢慢查询日志刷屏DBA找上门。这篇文章想系统聊聊MongoDB里skip()在大数据量下为什么会成为性能陷阱以及我用过的几种替代方案包括具体代码、索引设计和踩坑记录。无论你用的是社区版还是企业版这套优化思路都适用。1. 先看现象skip()在大数据量下的性能衰减1.1 一个典型场景翻到第500页接口超时先说一个我实际遇到过的场景。某内部运营系统的用户列表页MongoDB里存了大约800万条用户文档结构大概是{ _id: ObjectId(...), name: ..., email: ..., created_at: ISODate(...) }业务方要求按注册时间倒序每页显示20条。刚上线的时候文档只有几万条分页接口稳定在30到50ms谁也没在意。等数据涨到几百万之后问题开始出现翻到第100页以后接口响应时间从几百毫秒一路涨到3秒甚至更久慢查询日志里全是同一条模式db.users.find({}).sort({created_at: -1}).skip(10000).limit(20)这个写法几乎成了所有MongoDB分页需求的默认答案。包括我自己早期写项目时也一样find()拿集合sort()排序skip()跳过前面若干条limit()截断条数。这四个方法一行代码就能拼出一个分页接口语法简单、逻辑清晰看起来完全没问题。可问题恰恰出在这个看起来没问题上。1.2 数据库到底做了多少无用功要理解skip()为什么慢最直接的办法是打开执行计划看看数据库为了返回那20条数据实际扫了多少文档。在mongosh里执行db.users.find({}).sort({created_at: -1}).skip(10000).limit(20).explain(executionStats)返回结果里有一个关键字段totalDocsExamined。在一个800万条数据、created_at上没有索引的集合里这个值通常是800万而nReturned只有20。换句话说数据库为了给你20条数据从头到尾遍历了整个集合把满足条件的所有文档都取出来做了排序然后丢掉前面10000条只留下最后20条。如果给created_at加了索引情况会好一点totalDocsExamined可能降到10020左右因为数据库可以通过索引顺序遍历但仍然要读取并丢弃前面10000条文档。这个行为用翻书的例子最好理解skip(10000) 不是直接翻到第10001条而是从第1条开始数数到10000扔掉再取20条。每次翻页都要从头数一遍。这个从头数一遍的成本就是skip()在大数据量下变成性能陷阱的根本原因。offset越大需要跳过的文档越多耗时越长。跳过一万条还好跳过百万条时哪怕每条文档只是读取一下索引键累计起来的磁盘IO和CPU消耗也非常可观。1.3 实测数据offset与耗时的关系我在本地用8GB内存的虚拟机做过一次简单压测集合里塞了500万条文档字段只有_id、name、created_at给created_at建了普通索引每次取20条结果大致如下skip值平均耗时扫描文档数05ms20100030ms102010000180ms100201000001.2s1000205000005.8s500020注意这个测试里已经建了索引。如果索引缺失最后一档的耗时可以轻松超过20秒甚至触发MongoDB的Sort stage exceeded 32MB memory limit报错。更麻烦的是用户在页面上连续翻页每翻一页就重复做一次这样的全量扫描数据库负载像滚雪球一样越滚越大。所以只要分页深度超过一定阈值skip()就是一个写起来爽、跑起来哭的方案。2. 避开skip()的底层逻辑从跳过到定位在动手改代码之前我建议先把优化思路捋清楚。skip()的问题不在于跳过这个动作本身而在于它每次都要从集合的起点开始重复执行前面的扫描。有没有可能让数据库直接定位到上一页最后一条数据所在的位置然后从那里继续往下读这就是分页优化的核心思想把跳过N条变成从已知位置继续读N条。2.1 替代方案的整体思路MongoDB官方文档里也提到过一种经典分页方式range-based pagination基于范围的分页。它的基本形式是db.users.find({_id: {$gt: lastId}}).sort({_id: 1}).limit(20)每次查询带上一个上一页最后一条数据的标记字段通常是_id用范围查询条件代替skip()。数据库不再需要从头扫描而是直接通过唯一索引跳到lastId之后的位置沿着索引顺序读取20条。这个操作的时间复杂度从O(n)变成了O(log n)数据量越大收益越明显。听起来很美好但落地没这么简单。因为位置标记选哪个字段直接决定方案能不能成立。这个字段必须满足三个条件唯一、有序、有索引。_id是天然满足的所以基于_id的分页是最省事、最不容易出错的做法。如果你的排序字段是业务字段比如created_at还需要额外处理重复值问题后面我会详细讲。2.2 分页场景先分类顺序翻页、跳页、导出不是所有的分页都能用范围查询替代。我习惯把分页需求先分成三类顺序翻页用户从第1页慢慢往后翻或者点击下一页。这是最常见的交互模式也是范围分页最擅长的场景。任意跳页用户直接输入跳转到第500页或者点击页码列表中间某一页。这种场景下页面之间没有连续关系无法用上一页最后一条定位skip()仍然是唯一选择但可以通过一些手段限制跳页深度。数据导出/批量任务一次性取走大量数据不展示给用户比如报表导出、数据清洗。这种场景可以接受较大的延迟没必要纠结单次查询性能更应该关注的是内存和带宽。分清场景后就不会陷入所有分页都必须干掉skip的误区。需要重点优化的是第一种和第三种的高频、深层分页。2.3 为什么查询条件比偏移量更可靠从数据库原理来看skip()本质是一个丢弃操作它先找到那些要被跳过的文档再把他们丢掉。而范围分页本质是一个剪枝操作直接通过索引B树找到起点不扫描起点之前的任何数据。两者对索引的利用效率完全不同。举个例子。假设_id是递增的ObjectId你想取第N页每页20条。skip()方案的执行过程是遍历索引从第1个键开始数数到第(N-1)*20个键全部丢弃继续读取20个。范围分页方案的执行过程是拿着上一页最后一个_id在索引里做一次二分查找定位到目标位置直接读后面的20个键。数据量翻到千万级别时前者每次都要做上百万次索引遍历后者每次只需要几次二分查找加20次顺序读。差距是三个数量级以上的。这也是为什么很多MongoDB优化文章会把use range queries instead of skip当作金科玉律。并不是skip()这个API写得差而是它面对的定位问题本质上不适合用丢弃来解决。3. 实操四种常用替代方案的落地理论说再多不如直接看代码。下面我从一个模拟的订单列表场景出发把几种我实际用过的方案完整走一遍。集合名叫orders字段有_id、order_no、user_id、amount、created_at目标是按创建时间倒序分页展示。3.1 准备测试数据集与索引先用mongosh插入一批模拟数据并为后续方案准备索引db.orders.insertMany([ {order_no: A001, user_id: 1001, amount: 99.5, created_at: new Date()}, // 实际数据量建议至少几十万条可以循环insertMany ]) db.orders.createIndex({created_at: -1, _id: -1}) db.orders.createIndex({user_id: 1, created_at: -1, _id: -1})第一个索引{created_at: -1, _id: -1}用于按创建时间倒序 按_id兜底排序的分页第二个索引{user_id: 1, created_at: -1, _id: -1}用于按某个用户查他名下订单的分页。注意我把_id放到了复合索引的最后一位这是为了利用ObjectId的单调递增特性来打破created_at重复时的平局。插入数据时不必太纠结数量有个几十万条就足以看出差异。如果你手头已经有一套MongoDB环境也可以直接用真实业务表来做explain对比。3.2 方案一基于_id游标分页最推荐这个方案的前提是你愿意按_id顺序展示数据或者你的业务排序字段恰好和_id一致。比如注册时间与ObjectId生成顺序基本一致时按_id倒序展示就等同于最新注册的用户排在前面这种情况下最省事。第一页查询// 第一页 let docs db.orders.find({}).sort({_id: -1}).limit(20).toArray() let lastId docs[docs.length - 1]._id从第二页开始每次带上lastIdlet nextDocs db.orders.find({_id: {$lt: lastId}}).sort({_id: -1}).limit(20).toArray()实际项目中我会让接口返回两个字段nextCursor和hasMore。nextCursor就是字符串化的lastId前端点击下一页时把它传回来后端用ObjectId(cursor)还原成ObjectId类型再查询。这样比页码 每页条数更利于做深分页。这套方案的优点是实现简单、性能稳定缺点是用户无法直接跳转到第500页只能一页页往下翻。但对绝大多数移动端和Web列表页来说加载更多本来就是比页码翻页更自然的交互所以这个缺点在很多场景下算不上问题。3.3 方案二基于复合排序字段分页很多业务要求最新发布的排前面也就是按created_at倒序。这时候直接用_id就不合适了因为你不能保证_id的顺序和created_at的顺序完全一致。我们需要把排序字段和_id组合起来做游标。第一页let docs db.orders.find({}) .sort({created_at: -1, _id: -1}) .limit(20) .toArray() let last docs[docs.length - 1] // 把 last.created_at 和 last._id 作为游标下一页的条件怎么写如果只写created_at: {$lt: last.created_at}会丢数据。因为同一秒钟可能创建了多笔订单它们的created_at相同而下一页应该从小于当前时间戳的所有订单之前补上等于当前时间戳但_id更小的订单。正确的条件是一个或db.orders.find({ $or: [ {created_at: {$lt: last.created_at}}, {created_at: last.created_at, _id: {$lt: last._id}} ] }).sort({created_at: -1, _id: -1}).limit(20)这里$or的两个分支正好覆盖了严格更早和同一秒但更靠后两种情况。为了避免$or导致索引失效我会顺手检查执行计划db.orders.find({...}).explain(executionStats)只要winningPlan里出现IXSCAN并且totalDocsExamined约等于20就说明索引命中没问题。如果出现COLLSCAN多半是索引顺序写反了或者$or语法与索引不匹配。更稳妥的另一种写法是提前把时间戳转成毫秒级数值字段比如created_at_ms这样几乎不会出现同毫秒重复。但业务上如果允许同一毫秒撞车仍然建议保留_id作为二级排序键。我强烈推荐一个习惯任何游标方案都加一个_id兜底排序否则排序不稳定翻页时容易出现重复或遗漏。3.4 方案三聚合管道与后台导出任务的优化某些需求无法用find()表达比如需要分组统计后再分页。这时候aggregate()里同样有$skip性能陷阱一模一样。我的建议是能用$match缩小范围就先缩小把$skip尽量往后放并且优先让$sort走索引。举一个报表场景统计每个用户最近一年的订单总金额按金额降序分页。直接写db.orders.aggregate([ {$match: {created_at: {$gte: start, $lte: end}}}, {$group: {_id: $user_id, totalAmount: {$sum: $amount}}}, {$sort: {totalAmount: -1}}, {$skip: 5000}, {$limit: 20} ])这个管道里$sort是在$group之后对全部用户分组结果排序通常无法走索引内存排序可能吃掉大量内存。如果只是导出前N名根本不需要分页直接$sort后接一个足够大的$limit让数据库只保留前N个结果。MongoDB对$sort $limit有优化会采用top-k排序算法内存消耗小很多。如果确实需要翻页取数可以用两次查询思路先把符合条件的_id取出来存到临时集合或Redis再按游标方式查询_id对应的完整文档。分页时只针对_id做范围查询后续翻页非常轻量。这种方案适合数据导出、数据迁移任务因为这类任务本身就要遍历全量数据多一次_id查询的成本可以接受。3.5 方案四手动维护分页游标状态如果你的入口是HTTP接口接口层天然是无状态的每次请求都要重新构造查询条件。但如果是长连接、消息队列消费等有状态场景可以把游标直接存在内存或缓存里连上一次的最后一条都不用回传。举个例子用Node.js写一个简单的翻页工具class MongoPaginator { constructor(collection, query, sort) { this.collection collection; this.query query; this.sort sort; this.lastKey null; this.hasMore true; } async nextPage(limit 20) { if (!this.hasMore) return []; const query {...this.query}; if (this.lastKey) { query[this.sort[0]] {$lt: this.lastKey}; } const docs await this.collection.find(query) .sort(this.sort) .limit(limit) .toArray(); if (docs.length limit) this.hasMore false; if (docs.length) { this.lastKey docs[docs.length - 1][this.sort[0]]; } return docs; } }这只是一个简化版实际业务里要注意sort字段的唯一性否则依然会有重复数据导致的漏页问题。同时使用游标模式的接口设计上建议后端把上一页游标作为一个不透明字符串返回不要让前端解析或拼接这样可以随时改动底层查询逻辑而不影响客户端。如果你习惯用NoSQLBooster这类图形工具也可以在GUI里直接查看执行计划确认游标查询的扫描行数开发调试会方便很多。4. 常见问题与排查经验这部分是实战中踩过的坑比上面的原理更值得收藏。4.1 排序字段重复导致的数据错乱用created_at做游标但created_at不唯一会出现翻页时漏数据。原因在于相同时间戳的文档跨页了第一页包含了created_at t的10条中的5条下一页条件写created_at t把另外5条丢了。排查时可以先统计一下排序字段的重复情况db.orders.aggregate([ {$group: {_id: $created_at, count: {$sum: 1}}}, {$match: {count: {$gt: 1}}}, {$sort: {count: -1}}, {$limit: 10} ])如果重复太多最简单的修复就是给排序条件加上_id按我上面写的$or条件处理。这个坑非常隐蔽接口测试翻前几页可能没问题翻到中间页才暴露所以上线前一定要做深层翻页的验证。4.2 排序内存超限问题很多人在优化分页时遇到Sort stage exceeded memory limit of 33554432 bytes报错第一反应是MongoDB怎么连排序都不行。其实这是保护机制MongoDB默认最多用32MB内存做一次排序超过就会报错。报错通常发生在sort字段没有索引的情况下此时数据库只能把全量文档取到内存里排序。排查步骤不难。先看集合里排序字段的索引db.orders.getIndexes()没有索引就补一个比如{created_at: -1}。然后重新explain()确认winningPlan里出现IXSCAN而不是SORT或SORT_MERGE。对于聚合管道里的$sort如果前面有$group通常无法完全避免内存排序可以考虑加allowDiskUse: truedb.orders.aggregate([...], {allowDiskUse: true})但这只是治标磁盘排序依然慢还是要从业务上减少参与排序的中间结果。4.3 skip在什么场景下仍然够用不要看了这篇文章就立刻把代码里所有skip()都改掉。有些场景skip()依然是最合适的。我最常遇到的两种情况浅分页offset小于1000数据量几十万以内skip()的耗时完全在可接受范围强行改成游标反而增加复杂度。跳页后台管理查看第300页这种需求前后页之间没有关联无法用游标只能skip()。这时候可以通过禁止过大offset或增加缓存来兜底没必要在数据库层面死磕。经验法则如果skip()的值不超过总数据量的5%左右优先保留旧逻辑。优化是要解决真实痛点不是为了炫技。4.4 分页接口设计的一些建议优化完数据库查询接口层的一些习惯也应该同步调整。不要把页码计数器暴露给用户。建议用cursor limit替代page size让前端只能下一页不能随意跳页。返回hasMore标识。前端拿到这个字段再决定是否渲染加载更多按钮避免多查一页才知道没有数据。对游标参数做校验。服务端收到的游标要能转换为合法的ObjectId或时间戳转换失败直接返回参数错误防止恶意请求用超大游标做深度扫描。用maxTimeMS给查询设置上限。分页查询再优化也怕极端情况设置50ms或100ms超时至少不会拖死整个数据库连接。最后再说两句我在实际项目里反复试过各种分页写法最大的体会是分页优化的本质不是换一个API而是改变每次从头数的思维。skip()就像一本没有目录的书每次翻页都从第一页开始数到目标页游标分页则是给这本书加了一个书签翻页时直接跳到书签位置。前两年我接手的一个运营系统就是用基于_id的游标方案把深分页接口从秒级压回了毫秒级之后很长一段时间都没再被分页慢查询骚扰过。如果你在改造过程中遇到奇怪的重复数据问题先确认排序字段是否唯一遇到报错先打开explain()看扫描行数。只要养成先看totalDocsExamined、再看winningPlan的习惯分页性能问题都不难定位。这套方法的成本很低收益却非常明显值得在每个用到MongoDB分页的项目里都过一遍。
返回列表