商品列表翻页翻到OOM?分页调优全场景指南

发布时间:2026/8/1 0:45:28
商品列表翻页翻到OOM?分页调优全场景指南 商品列表翻页翻到OOM分页调优全场景指南去年双11压测的时候我们压商品列表接口压到QPS200的时候商品库从库直接OOM挂了查了半天发现是模拟用户翻页到第200页的时候触发的。开发写的分页SQL是标准的SELECT * FROM goods ORDER BY create_time DESC LIMIT 20000,20压测的时候几百个并发翻深页每个SQL都要回表扫2万行数据内存里堆了上千万条记录直接把从库内存撑爆了。我们当时紧急改了分页逻辑压到QPS2000数据库CPU才20%顺顺利利扛过了双11。分页可以说是所有业务系统用的最多的功能从C端的信息流、商品列表到后台的订单管理、数据导出都要用到分页但90%的开发者写分页只会写个LIMIT offset,size根本没意识到深分页的性能风险直到数据量涨到百万、千万级翻页翻慢了、把库搞挂了才想着优化。今天我把所有分页场景的优化方案、踩坑经验全部分享给你从C端信息流到后台管理系统从MySQL到ES所有场景的最优解都给你整理好了。分页查询设计与深度性能调优实战‌一、先搞懂你的分页为什么越翻越慢很多人觉得分页不就是LIMIT一下吗能有多慢但你肯定遇到过列表第一页秒开翻到第10页要等1秒翻到第100页要等5秒翻到几百页直接超时。要搞懂这个问题你得先明白MySQL执行LIMIT offset,size的时候到底做了什么。InnoDB的聚簇索引和二级索引结构我们之前讲过当你执行SELECT * FROM goods ORDER BY create_time DESC LIMIT 20000,20的时候MySQL并不是直接跳过20000条拿到后面的20条它会先沿着create_time的二级索引扫描20020条记录每一条都做回表操作去聚簇索引把完整的行数据查出来然后把前20000条记录全部扔掉只返回最后20条。offset越大MySQL需要扫描和回表的记录就越多性能自然线性下降。如果你的SQL是SELECT *需要回表所有扫描到的行offset到10万的时候要回表10万次随机IO的开销会大到不可接受。我在1000万行的商品表上做过测试同样是查20条数据不同offset下的性能差距非常夸张表格offset偏移量 SQL执行时间 扫描行数 回表次数0第一页 3毫秒 20 201000 12毫秒 1020 102010000 85毫秒 10020 10020100000 920毫秒 100020 1000201000000 8.7秒 1000020 1000020你看offset到100万的时候执行时间从3毫秒涨到了8.7秒性能差了近3000倍要是有几个并发同时翻深页数据库直接就被打满了这就是为什么很多系统翻页翻深了就超时、甚至OOM的根本原因。更坑的是大部分后台系统的分页都带多条件筛选、排序索引用不好的话扫描行数会更多性能会更差。二、分页最常踩的5个坑每个都能搞出线上故障我前前后后见过几十次分页相关的线上故障90%都是下面这5个坑很多坑甚至是资深开发也会踩的。1、没有固定ORDER BY字段分页数据跳页、重复很多人写分页的时候不写ORDER BY觉得MySQL默认会按主键顺序返回省事儿。实际上InnoDB在有删除、插入、页分裂的时候主键顺序并不一定是你预期的尤其是并发写入的时候两次查询之间有数据插入/删除会导致同一页的数据重复出现或者跳页第一页看到的商品翻到第二页又出现一次或者有的商品直接消失了运营和用户都会来找bug。写分页SQL的第一铁则‌必须有明确的、全局唯一的排序字段‌哪怕你就是要按主键排序也得显式写ORDER BY id而且排序字段必须有索引不然会触发全表文件排序慢到离谱。sql-- 反例无order by分页可能重复/跳页SELECT * FROM goods LIMIT 0,20;-- 正确写法显式按主键/唯一字段排序保证顺序稳定SELECT * FROM goods ORDER BY id DESC LIMIT 0,20;2、ORDER BY字段不唯一导致分页数据错乱很多人写分页按create_time排序觉得按创建时间排序很合理但是create_time不是唯一字段同一秒可能有几十个商品同时创建排序的时候相同create_time的数据顺序是不确定的并发插入数据的时候翻页同样会出现数据重复、丢失的问题。正确的做法是排序字段加上主键作为兜底排序键比如ORDER BY create_time DESC, id DESC这样哪怕create_time一样也会按id二次排序保证全局顺序唯一不会出现翻页错乱的问题。3、深分页SELECT *回表开销爆炸就是我们开头讲的OOM事故的根源SELECT 会查所有字段导致二级索引扫描完必须回表查聚簇索引offset越大回表越多性能直线下降。如果只查索引上有的字段走覆盖索引不需要回表哪怕offset到10万也会快很多但业务场景一般都需要查完整字段这个时候就必须用其他分页方案替代大offset。4、先查列表再count两次查询都慢几乎所有分页接口都会返回总条数total给前端做分页器很多人的实现是先查一页列表数据再跑一遍一样的WHERE条件做COUNT()算总条数相当于同样的过滤条件扫了两遍表列表本身就慢count还慢接口响应时间直接翻倍。而且很多人写count的时候也带ORDER BY完全没必要count根本不需要排序白白浪费排序开销。sql-- 反例count带order by多余的排序开销SELECT COUNT(*) FROM goods WHERE category_id 1 ORDER BY create_time DESC;-- 正确写法count去掉order by不需要排序SELECT COUNT(*) FROM goods WHERE category_id 1;5、一次性查全量数据在内存里分页还有人为了避免深分页问题一次性把所有符合条件的数据ID全查出来放到内存里然后在代码里做分页觉得这样数据库压力小。数据量小的时候没问题如果符合条件的数据有几十万、上百万条一次全查出来放到JVM内存里分分钟把应用服务器的内存打满引发Full GC甚至OOM比数据库深分页还危险。三、不同场景下的分页最优解直接抄作业分页没有万能方案不同的业务场景要用不同的实现方式我把所有常见场景的最优解法都整理好了你直接对照自己的业务场景用就行。1、C端信息流/上拉加载场景游标分页书签分页C端的商品列表、朋友圈、短视频信息流这类场景用户只会一直往下滑加载更多根本不需要跳转到指定页码也不需要看总页数这种场景最优解就是游标分页也叫seek method、书签分页性能比offset分页好几百倍不管滑多少页都是毫秒级。游标分页的逻辑非常简单第一次查询第一页的时候拿到本页最后一条记录的排序字段值比如最后一条的id和create_time下一页查询的时候把这个值当游标传过来WHERE条件过滤掉游标之前的所有数据直接取后面20条完全不需要offset也就没有扫描大量数据的问题。sql-- 第一页查询拿最后一条数据的create_time1719800000, id123456作为下一页游标SELECT id, goods_name, price, create_timeFROM goodsWHERE category_id 1ORDER BY create_time DESC, id DESCLIMIT 20;-- 下一页查询用上一页的游标过滤不需要offsetSELECT id, goods_name, price, create_timeFROM goodsWHERE category_id 1 AND (create_time 上一页最后create_time OR (create_time 上一页最后create_time AND id 上一页最后id))ORDER BY create_time DESC, id DESCLIMIT 20;这种写法能完美利用联合索引(category_id, create_time, id)每次查询都是从索引的游标位置开始往后扫20条不管翻多少页扫描行数永远是20行不需要回表多余的数据哪怕翻到第一百万页执行时间也和第一页一样是几毫秒性能非常稳定完全不会有深分页问题。抖音、淘宝的信息流全是用的这种分页方案。游标分页的唯一缺点就是不支持跳转到指定页码只能一页一页往后翻刚好完美匹配C端上拉加载的场景是C端分页的首选方案。2、后台管理系统跳页场景延迟关联分页后台管理系统一般需要支持跳转到任意页码显示总页数没法用游标分页这种场景的最优方案是延迟关联也叫书签分页先通过覆盖索引查到当前页需要的主键ID再通过主键ID关联查完整的行数据把回表的范围从2000020条缩小到20条性能提升几十上百倍。sql-- 反例深分页回表20020行执行时间920毫秒SELECT * FROM goods WHERE category_id 1 ORDER BY create_time DESC LIMIT 20000,20;-- 优化后延迟关联先查20个ID再关联查详情执行时间28毫秒SELECT g.*FROM goods gINNER JOIN (-- 子查询走覆盖索引不需要回表只查主键ID哪怕offset2万也很快SELECT id FROM goodsWHERE category_id 1ORDER BY create_time DESC, id DESCLIMIT 20000, 20) t ON g.id t.id;为什么能快这么多因为子查询只查id走联合索引(category_id, create_time, id)的覆盖索引不需要回表索引树本身很小扫描20020个id非常快拿到20个主键id之后再去聚簇索引查完整行只需要回表20次随机IO的开销减少了1000倍性能提升非常明显。我们压测的时候offset到10万条这种写法的执行时间也才100毫秒左右完全能满足后台系统的需求。如果需要算总条数可以单独做count优化如果筛选条件固定可以把count结果缓存到Redis或者维护到计数表里不用每次都实时count或者给MySQL加上并行查询参数count的速度也能提升好几倍。3、多条件复杂筛选场景ES搜索引擎分页如果是商品搜索、订单搜索这类支持多维度筛选、关键词搜索、任意维度排序的场景MySQL本身就不擅长做这件事不管你怎么优化分页都快不起来直接把数据同步到Elasticsearch用ES做分页就好。ES有两种分页方案深度翻页fromsize适合100页以内的浅分页和MySQL的offset逻辑类似如果要支持很深的翻页或者上拉加载用ES的search_after游标分页原理和MySQL的游标分页一样性能非常稳定千万级数据分页都是毫秒级返回。我们当时把所有商品搜索、订单搜索的分页从MySQL移到ES之后接口响应时间从平均300毫秒降到了20毫秒还支持了全文检索、多维度筛选体验好了很多。4、超大数据量导出/遍历场景游标分批遍历如果是做数据导出、全量数据同步这类需要遍历整张表所有数据的场景不要用offset分页越往后越慢还会产生长事务。最优方案是用主键游标分批查每次从上一次查到的最大id开始往后查1000条直到查完为止不管表有多大每一批查询都是毫秒级也不会有深分页问题。java// 全量遍历导出数据示例long lastId 0;int batchSize 1000;while (true) {// 每次查id大于lastId的1000条走主键索引永远只扫1000条List list goodsMapper.selectList(SELECT * FROM goods WHERE id #{lastId} ORDER BY id LIMIT #{batchSize},lastId, batchSize);if (CollectionUtils.isEmpty(list)) break;// 处理这批数据写文件/同步到其他系统exportData(list);// 更新游标为当前批最后一条的idlastId list.get(list.size() - 1).getId();}这种方案我们用来导过上亿行的订单表每批1000条全程数据库CPU不超过20%比offset分页快几十倍也不会出现OOM、长事务的问题。四、真实优化案例商品列表从3秒到20毫秒全流程当时双11压测出问题的商品列表接口SQL就是最基础的深分页写法sql-- 优化前SQLoffset20000的时候执行时间3.2秒并发高了就OOMSELECT * FROM goodsWHERE category_id 1 AND is_online 1ORDER BY create_time DESCLIMIT 20000, 20;我们按照场景优化C端用户用游标分页后台管理系统用延迟关联分页同时建了对应的联合索引(is_online, category_id, create_time, id)覆盖子查询C端接口直接改成游标分页后台接口用延迟关联。sql-- C端优化后游标分页执行时间5毫秒SELECT id, goods_name, price, original_price, pic_urlFROM goodsWHERE is_online 1 AND category_id 1AND (create_time ? OR (create_time ? AND id ?))ORDER BY create_time DESC, id DESCLIMIT 20;-- 后台优化后延迟关联分页执行时间22毫秒SELECT g.* FROM goods gINNER JOIN (SELECT id FROM goodsWHERE is_online 1 AND category_id 1ORDER BY create_time DESC, id DESCLIMIT 20000, 20) t ON g.id t.id;同时我们把总条数count做了缓存商品上下架的时候更新缓存里的总数不用每次列表查询都count。改完之后压测QPS从200打挂升到2000的时候数据库CPU才20%双11峰值的时候接口平均响应时间18毫秒没有一次超时也再没出现过OOM的问题。五、分页设计的6条红线执行了就不会出故障这些年踩了无数分页的坑之后我们组定了6条分页的强制规范所有分页代码必须遵守之后再也没出过分页相关的线上故障1、所有分页必须带ORDER BY且排序字段必须加全局唯一的主键兜底禁止无排序分页避免数据重复、跳页。2、C端信息流、上拉加载场景必须用游标分页禁止用大offset分页保证任意翻页性能稳定。3、后台跳页场景用延迟关联分页单页size不超过100条禁止一次查上千条数据放到内存。4、分页查询禁止SELECT *列表页只查需要展示的字段详情查单独接口减少回表和网络传输开销。5、多条件搜索、全模糊查询场景直接走ES禁止在MySQL上做复杂搜索分页。6、全量数据遍历、导出场景必须用主键游标分批查询每批不超过1000条禁止用深分页一次性拉取大量数据。7、分页count尽量做缓存或者预计算避免每次查询实时count大表不需要显示精确总条数的场景可以不返回total减少数据库开销。很多人觉得分页是个特别简单的功能不就是个LIMIT吗没什么技术含量实际上分页是最考验数据库优化功底的细节之一用错了方案在数据量和并发上来之后会成为整个系统最大的性能瓶颈。分页优化的本质逻辑其实非常简单永远不要扫描多余的数据不要回表多余的行让数据库每次只查需要返回的那几十条数据自然就快了。不需要什么高深的技术也不需要升级配置把这些细节做到位哪怕是千万级数据表的分页也能做到毫秒级响应。注意本文所介绍的软件及功能均基于公开信息整理仅供用户参考。在使用任何软件时请务必遵守相关法律法规及软件使用协议。同时本文不涉及任何商业推广或引流行为仅为用户提供一个了解和使用该工具的渠道。你在生活中时遇到了哪些问题你是如何解决的欢迎在评论区分享你的经验和心得希望这篇文章能够满足您的需求如果您有任何修改意见或需要进一步的帮助请随时告诉我感谢各位支持可以关注我的个人主页找到你所需要的宝贝。博文入口山峰哥-CSDN博客复制到【浏览器】打开即可,宝贝入口常用软件宝贝精品文件作者郑重声明本文内容为本人原创文章纯净无利益纠葛如有不妥之处请及时联系修改或删除。诚邀各位读者秉持理性态度交流共筑和谐讨论氛围