任何一个用Java做后端开发的人,迟早都会碰到分页查询慢到让人抓狂的时刻。尤其是数据量上了百万、千万级之后,原本秒开的列表页突然变成几秒甚至几十秒才出数据,用户一顿催,你一顿查,最后发现SQL写得没问题、索引也建了,可就是慢。这期内容我就专门聊聊MyBatis和MyBatis Plus在分页查询大数据量时的性能优化技巧,结合我这些年实际踩过的坑和调优经验,把能直接用的方案整理出来。
先说一下这篇文章适合谁看:如果你正在维护一个数据量增长很快的业务系统,或者做报表、管理后台这类需要频繁分页查询的功能,又或者你只是想知道LIMIT offset, size这行SQL到底是怎么拖垮你的数据库的,那么这篇文章非常对你的口味。我会从原理讲到实操,再讲到架构层面的优化思路,最后附上问题排查清单,保证你看完能直接上手改代码。
1. 项目概述与核心痛点
1.1 这条SQL为什么会慢:LIMIT的陷阱
先说一个最简单的场景,你写了一条分页查询:
sql复制SELECT * FROM user_order ORDER BY create_time DESC LIMIT 100000, 20;
在数据量只有几万条的时候,这条SQL根本看不出毛病。但一旦表里有了几百万行,你会发现offset越大,查询越慢。原因其实并不复杂:MySQL在执行这条SQL时,不是只读20条数据就完事,而是要先扫描出前100020条行,然后丢弃掉前100000条,只返回最后20条。这意味着,你翻到第5000页的时候,数据库要做一次耗费巨大的全表或全索引扫描,然后大部分工作都白费了。
我见过最夸张的一个案例,是某后台系统的订单管理页,翻到第200页左右就已经需要5秒多才能出数据。当时的表大概有800万行,索引也建了,但就是这样慢。后来我把SQL改成JOIN分页和子查询分页之后,响应时间直接降到了100毫秒以内。
这里要记住一个概念:LIMIT offset, size这种写法在offset很小的时候没问题,但offset大到一定程度后,性能会急剧下降,而且这种下降不是线性的,几乎是指数级的。因为数据库要定位偏移量,而不是直接跳到目标行。除非你的表真的只有几千条数据,否则不要指望这条SQL能一直撑得住。
1.2 使用MyBatis和MyBatis Plus分页时的常见误区
很多人用MyBatis写分页,最常见的方式就是自己拼SQL,手动传offset和size。这种方式本身没问题,问题在于很多人只关注了SQL本身,却忽略了整个查询链路。比如:
- 没有对排序字段建立合适的索引;
- 查询了几百个字段,包括一些超大字段,比如TEXT、BLOB;
- 在循环里调用分页查询,导致数据库被重复打爆;
- 用MyBatis Plus的Page对象时,没有关闭自动count查询,导致每次分页都要额外执行一条count查询,而这条count查询在大表上往往也非常慢。
MyBatis Plus的IPage和PageHelper这类插件确实为我们省了很多事,但同时也容易让人放松警惕。你只要new一个Page传进去,它就会自动帮你生成limit和count两种SQL。你看到的那条分页SQL只是表面,真正要命的可能是那个隐藏在背后的SELECT COUNT(*) FROM your_table WHERE ...。如果这条count查询没有走到合适的索引上,那就算你的分页SQL再快,也会被count拖死。
所以在讲具体优化方案之前,我希望你能先建立起一个意识:分页查询的性能问题,绝对不只是SELECT那一行SQL的问题,它涉及索引、字段选择、分页策略、缓存甚至数据库架构,得整体来看。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分页查询性能问题的根源剖析
2.1 为什么offset越深越慢:回表和扫描的成本
我们讲一个底层原理,也就是为什么深分页会慢。大部分业务表用的都是InnoDB引擎,索引结构是B+树。当你执行一条带ORDER BY和LIMIT的查询时,如果排序字段没有索引,MySQL需要先使用filesort,也就是把查询结果集放入sort buffer中进行排序,然后丢到临时表里,最后再取limit那部分。
假如排序字段有索引,那么MySQL可以通过索引顺序来扫描,这看起来快一些。但问题在于,如果查询的字段超过了索引覆盖的范围,也就是需要回表取完整行数据,那么扫描每一行索引节点都要发生一次随机磁盘I/O。offset越深,需要回表的行越多,累计下来的随机I/O时间就非常恐怖。
举个例子,一张订单表有500万行数据,你要查询第100000行到第100020行。假如你使用的是二级索引排序,那么数据库需要扫描前100020个索引记录,然后对每一行执行回表操作,到主键索引里取完整数据。就算每一行回表只需要0.1毫秒,100020行就是10秒。这还没算上网络传输和并发场景下的CPU开销。
所以,如果你发现翻到后面几页特别慢,不用怀疑,多半就是深分页导致的回表过多。解决思路也就清晰了:要么减少回表次数,要么避免深分页,要么让数据库更快地定位到目标偏移量。
2.2 count查询为什么是性能杀手
我刚才提到MyBatis Plus会自动执行count查询,这里要单独讲一下。分页通常需要知道总页数,所以必须查总记录数。这个需求本身没问题,但问题是很多分页场景里用户根本不在乎绝对准确的总数,他只想翻数据而已。
count查询在大表上慢的另一个原因是,它可能要扫描大量行。如果你的WHERE条件命中率很低,哪怕你有索引,MySQL也需要扫描大量索引条目来计数。更糟糕的是,如果你用了LEFT JOIN或者DISTINCT,count会变得格外复杂。
我个人建议是:如果总条数不是业务硬性要求,就尽量别查。用MyBatis Plus时,你可以设置分页插件不执行count,或者用一个估算值代替。如果是后台管理系统的列表,通常只需要显示“共有XXX条”或者“我们总共查询到了XXX条”,这个数字差一点用户根本看不出来。
另外还有一个优化点,就是count查询的SQL本身。MyBatis Plus生成的count是SELECT COUNT(*) FROM (SELECT ...) TOTAL,这种方式会引入一个子查询,性能极差。优化方法是手动指定count查询的SQL,或者直接关闭自动count,自己写一个轻量的SELECT COUNT(1) FROM table WHERE ...,只统计必要的条件。
2.3 排序字段和索引的错位
还有一个非常普遍的问题,就是排序字段和索引字段对不上。比如你的SQL是ORDER BY create_time DESC,但你的索引是(idx_user_id, idx_status),那么MySQL就没办法利用索引来排序,只能走filesort。一旦走filesort,又叠加回表,深分页的代价成倍增加。
解决办法无非两种:一是把排序字段加入索引,比如设计一个复合索引(user_id, create_time);二是让查询的WHERE条件直接命中排序索引,避免filesort。但这里有个坑,就是你不能盲目加太多索引,因为索引也会拖慢写入性能。所以要根据业务访问模式,挑选最核心的查询路径来建索引。
我自己的习惯是,在分页查询的SQL上使用EXPLAIN命令先查看执行计划。重点看type是不是range或ref,看Extra里面有没有Using filesort和Using temporary,有的话就说明SQL还有优化空间。这个习惯几乎能帮我提前发现80%的分页慢查询问题。
3. 核心优化方案:从SQL层面改写分页查询
3.1 延迟关联优化:先取主键,再回表
既然深分页的主要代价是回表,那我们就想办法最大化减少回表次数。常见方案就是延迟关联,也叫延迟join。思路是先用覆盖索引定位到目标行的主键ID,然后再用这些ID去关联完整数据。
举个例子,原来这样写:
sql复制SELECT * FROM order_info ORDER BY create_time DESC LIMIT 100000, 20;
可以改成:
sql复制SELECT o.*
FROM order_info o
INNER JOIN (
SELECT id FROM order_info
ORDER BY create_time DESC
LIMIT 100000, 20
) t ON o.id = t.id;
这个改写的核心思想是:子查询里只查id字段,二级索引基本上能完全覆盖,不需要回表,速度极快。然后拿到20个id之后,再用主键去查完整行,只回表20次。这种方法在offset达到几十万上百万的时候,效果非常明显,响应时间往往能缩短一个数量级。
我在实际项目中用这种方法优化过一个管理后台的流水查询,原来需要2秒多,优化后稳定在200毫秒以内,代价只是SQL变得稍微复杂一点。如果你用的是MyBatis,完全可以把这段SQL写在Mapper XML里,不需要改业务代码。
3.2 基于游标的分页:记住位置而不是偏移量
延迟关联能解决一部分问题,但如果你要翻到特别深的页码,比如第10000页,那就算用延迟关联,也要在子查询里扫描几十万行。这个时候就要考虑换一种分页策略,也就是基于游标的分页。
所谓基于游标的分页,就是不用offset和page number,而是用上一页最后一条数据的某个唯一字段作为游标,比如create_time和id组合,然后下一页查询直接WHERE (create_time < '上一页最后时间' OR (create_time = '上一页最后时间' AND id < '上一页最后ID')) ORDER BY create_time DESC LIMIT 20。
这样查询的每次翻页,都是基于一个已知位置往后取,数据库可以走索引快速定位,然后顺序扫描20条,效率极高。无论是深翻页还是大数据量,都能保持稳定的性能。缺点就是失去随机跳页的能力,只能一页一页翻。
在MyBatis Plus里,你不需要依赖分页插件,完全可以自己动态拼接SQL。我可以给你一个XML示例:
xml复制<select id="selectPageByCursor" resultType="YourEntity">
SELECT * FROM order_info
WHERE 1=1
<if test="lastCreateTime != null">
AND (create_time < #{lastCreateTime}
OR (create_time = #{lastCreateTime} AND id < #{lastId}))
</if>
ORDER BY create_time DESC, id DESC
LIMIT #{size}
</select>
在业务层,你只需要把上一页返回的结果中最后一条记录的create_time和id传进来,就能查询下一页。这种方式非常适合移动端列表或者App端的沉浸式下拉刷新,因为用户不需要跳页,只需要不停地往下滑。
另外我要提醒一下,基于游标的分页有一个隐含要求,就是排序字段必须是唯一且有序的,否则会出现数据重复或遗漏。比如只有create_time一个排序字段,那同一条秒内创建的多条数据就很容易出问题。所以最好的做法是使用复合条件:一个业务时间字段加一个主键ID,这样能确保排序是完全确定的。
3.3 减少查询字段和覆盖索引的使用
另一个经常被忽略的优化点就是查询字段。大多数人写分页SQL习惯了SELECT *,但星号真的是性能毒瘤。你想想,一张订单表可能有几十个字段,其中还包括大型的说明文本,那数据库每一行都要把完整数据取出来,再通过网络发出去。即使你只是做列表展示,可能只需要三五个字段,但数据库白干了很多活。
优化建议是:分页列表查询时,只查需要展示的字段。如果你确实需要完整对象,那就让列表查询返回一个精简的DTO,然后根据用户点进详情的时候再查完整数据。这样做的好处是,一方面减少了回表成本,因为部分字段可以直接从二级索引中取得;另一方面也减少了网络传输和内存开销,对高并发场景特别有价值。
更进一步,如果某个分页查询只需要id、name、status、create_time这几个字段,那就给它们建立一个覆盖索引。比如:
sql复制ALTER TABLE order_info ADD INDEX idx_status_time_id (status, create_time, id);
这样SQL在走索引扫描的时候,直接从索引数据页里就能拿到需要的字段,完全不需要回表。配合延迟关联和基于游标的分页,可以说性能直接拉满。
不过索引也不是越多越好。每个索引都会占用磁盘空间,而且写入时需要维护,所以在业务可接受的范围内控制索引数量。一般一个表建不超过五六个索引就够了,多建的代价在写入高频场景下会暴露得很快。
4. MyBatis和MyBatis Plus的分页插件配置与实战
4.1 MyBatis原生分页的写法与分页插件原理
在MyBatis里,分页实现方式大概有两种。第一种是自己在Mapper XML里写LIMIT #{offset}, #{size},这在数据量小的时候完全可行。第二种是用分页插件,比如PageHelper或MyBatis Plus自带的分页插件。插件原理也不复杂,就是通过拦截器改写SQL,在原有SQL基础上来一层分页和count包装。
对于这种插件机制,我想说两点。第一,插件确实方便,但也意味着你对自己执行的SQL失去了部分控制权,尤其是在SQL比较复杂、多表关联的时候,插件生成的count SQL可能会扫描大量数据,反而拖慢整体性能。第二,插件生成的limit SQL,同样逃不过我上文讲的offset深分页问题。所以即使你用插件,还是要关注底层SQL的索引和写法。
在原生MyBatis环境下,我个人的建议是:如果项目已经定了PageHelper,那就尽量用,但务必测试深分页场景。你可以通过查看打印出来的SQL日志,看看count查询到底长什么样,然后针对你的核心大表,手动优化count查询的SQL,用自定义的Mapper方法替代插件的自动count,这样既能保证兼容性,又能解决性能瓶颈。
4.2 MyBatis Plus分页插件的高级配置
MyBatis Plus的分页插件使用频率很高,几乎成了Java开发标配。但大多数人都只是依赖默认配置,从来没有关心过内部的优化参数。这里我列几个关键点。
第一个就是关闭自动count。在MyBatis Plus里,你写IPage page = new Page<>(current, size)之后,如果你不想马上执行count,可以在查询时调用page.setSearchCount(false)。如果不做设置,默认会执行count查询。而在大数据量下,count查询往往比分页SELECT还慢。
第二个是合理设置Page的参数。MyBatis Plus允许你在Page对象上设置optimizeCountSql,这个参数会尝试优化count SQL,去掉多余的ORDER BY和JOIN。但这不总能命中最优路径。更可靠的做法是你自己写一个专用的count Mapper方法,统计字段更少、条件更贴近索引设计,然后自己赋值给page.setTotal()。
第三个是分页插件本身有个限制,就是当你有JOIN查询时,分页插件生成的count可能不太准确,特别是涉及多表时。它用的方式通常是包一层子查询做count,这个子查询在大表上性能很差。所以我建议:如果是简单的单表分页,用插件没问题;如果是复杂统计或报表查询,还是自己手写分页SQL更可控。
我实际项目中用MyBatis Plus分页时,会专门设置一条自定义的count查询,比如只查主键的最小值是否存在,或者用一个近似值来表示总数。尤其在数据量超过百万且用户只关心前几页的场景下,我甚至建议直接禁用count,只返回数据,让前端展示“加载更多”而不是“页码”。
4.3 分页插件搭配批量插入与逻辑删除的注意事项
这里顺便聊两个和分页非常相关的MyBatis Plus使用细节,因为很多人在踩坑后才会碰见。
第一个是批量插入。你可以用MyBatis Plus提供的saveBatch方法,也可以自己在XML里写forEach来做批量insert。但需要注意,批量插入的量如果太大,比如一次几万条,也可能导致内存和SQL超长问题。更常见的做法是每500或1000条一批,分批插入,既保证事务大小合理,又避免数据库端binlog过大。分页查询和批量插入看似无关,但如果你频繁在循环中插入再分页查询,会造成性能互相干扰,尤其是数据库连接池和锁等待。
第二个是逻辑删除字段。MyBatis Plus的逻辑删除会在查询时自动追加WHERE deleted = 0,这本身是好事。但如果你不小心用了普通查询而没有用到逻辑删除注解,那可能把所有已删除数据也查出来了。我之前遇到过一个情况,某张表的索引是建立在deleted + create_time上的,但MyBatis Plus自动追加deleted条件后,排序和索引匹配得很好。可一旦有人在SQL里手写了deleted = 1,那分页查询就会把索引搞乱。所以建议你的分页查询SQL里始终显式带上逻辑删除条件,不要依赖自动追加,避免出现不可预测的查询计划。
另外,如果你希望查询出逻辑删除的数据,MyBatis Plus也提供了专门的方法,比如@SqlParser和自定义Interceptor,或者用自定义SQL绕过自动过滤。但这不是本期重点,等以后有机会我再细聊。
5. 大数据量分页的架构级优化思路
5.1 用Redis缓存热门分页结果
如果你的应用场景是用户频繁查看前几页数据,比如排行榜、标题列表、最新动态,那可以考虑把前面几十页的查询结果缓存到Redis里。缓存思路非常简单:第一次查询时,将一页数据序列化后存入Redis,key可以设计为分页特有的标识,比如page:user_order:1:20,然后设置合理的过期时间。下一次用户再查同一页,直接走缓存,完全不打数据库。
但缓存也有坑,就是在数据更新频繁的场景下,缓存会变得不准确。为了缓解这个问题,你可以根据业务容忍度,设置缓存时间,并在数据写入时主动删除相关缓存,或者用key前缀加版本号方式失效。比如每次有新订单写入,把订单列表缓存key统一加一个版本号,查询旧缓存直接穿透到数据库。
Redis缓存分页的核心价值在于降低数据库压力,而不是彻底消灭慢查询。深分页即使命中不了缓存,我们还是得靠SQL优化来兜底。所以最佳实践是:前几页用缓存,后面的页面直接走游标分页,完全不要给用户翻到上万页的机会。
5.2 使用搜索引擎或宽表存储绕过数据库分页瓶颈
当数据量大到一张普通MySQL表已经撑不住常用查询时,就需要考虑架构级调整。比如用Elasticsearch代替常规列表查询,把所有需要筛选和分页的字段都同步到ES里。ES的分页查询机制比较特殊,它默认支持from+size,但深分页同样有性能问题,所以它更推荐用search_after的方式来做游标查询。这和我们上一节讲的基于游标的分页思路完全一致。
另一种方案是宽表存储,也就是把多个业务表的数据提前聚合到一张大宽表里,查询时只查一张表。虽然宽表会有数据冗余和更新一致性成本,但查询性能极其稳定,特别适合报表和后台管理场景。比如我把订单表、用户表、商品表的信息冗余到一张order_search表,这样列表查询就不需要JOIN任何表,完全避免了多表JOIN对深分页的影响。
选择架构方案时,我觉得关键不是哪种方案绝对更好,而是你的业务更适合哪种。比如查询条件组合特别多,筛选维度很多,用ES会更灵活;如果查询模式相对固定,就几张表不断JOIN,那宽表更直接;如果系统还有并发压力,那加一层Redis缓存也能起到很大作用。
5.3 数据库层优化:索引、分区和读写分离
最后聊一下数据库层的优化。这里有一个容易被忽视的点,就是你的数据库连接池是否足够,如果连接数不够,那么即使SQL性能再好,也容易因为等待连接而变慢。分页查询属于读操作,非常适合放到只读副本上执行。比如你有一个主库负责写,几个从库负责读,那所有列表查询都可以路由到从库。MyBatis Plus或Spring框架都有多数据源解决方案,可以根据方法名或者自定义注解来切换数据源。
另外,如果你使用MySQL 8.0以上,可以检查一下是否用了新的优化器特性,比如不可见索引、降序索引。降序索引对ORDER BY FIELD DESC特别友好,能避免filesort,这在大数据量分页中能省不少事。
分区表也是值得考虑的方向之一。比如订单表按月份分区,那么查询最近三个月的数据时,就可以直接限定分区,数据库只需要扫三个分区,而不是全表。不过分区表在管理上有一定复杂度,比如跨分区查询可能会变慢,分区键选择不当也会带来反效果。所以我的态度是:先做SQL优化和索引优化,等都不够了再考虑分区,而不是一上来就上分区。
6. 常见问题与排查技巧实录
6.1 分页查询时数据重复或丢失怎么办
很多人在使用分页插件后,会遇到分页数据重复或丢失的情况。尤其是多表JOIN查询时,由于JOIN产生的结果集行数可能大于主表行数,而插件是直接对JOIN结果做一个LIMIT,很容易出现错乱。更典型的是你在排序字段上没有唯一性约束,比如两条记录的create_time完全相同,那在深分页时,底层扫描偏移量不一致,就会出现重复。
我的建议是:保证ORDER BY字段唯一,用主键ID或唯一键作为第二排序条件。如果只是分页列表,你可以ORDER BY create_time DESC, id DESC,这样排序稳定,不容易出问题。对于复杂的JOIN分页,我建议先查询主表ID,再用ID去关联子表数据,避免JOIN结果集膨胀。
6.2 查询慢但SQL看起来没问题,如何快速定位
定位分页慢查询的步骤,我一般是这样操作的:
- 第一步,打印SQL日志,拿到实际执行的分页SQL和count SQL;
- 第二步,在数据库客户端直接执行这条SQL,看看执行时间;
- 第三步,执行EXPLAIN,检查type、key、rows、Extra等关键字段,看是否走了全表扫描或filesort;
- 第四步,确认表的数据量,如果只有几万条却慢,大概率是索引没建好;如果几百万条,则重点检查深分页和排序;
- 第五步,去掉ORDER BY试试,看排序是不是罪魁祸首;
- 第六步,去掉WHERE条件试试,看筛选条件是不是无法命中索引。
我曾经排查过一个案例,SQL明明用到了status索引,但因为ORDER BY create_time没在索引里,MySQL最后还是选择了filesort,导致深分页慢。解决办法就是建立一个(status, create_time, id)复合索引,让数据库能通过索引直接排序扫描。
6.3 分页插件与开发框架集成时的常见坑
开发框架集成MyBatis或MyBatis Plus时,也容易遇到一些问题。比如在Spring Boot里,启动时一直下载MyBatis相关依赖,或者报错找不到SQL配置,这些多半是版本兼容问题。建议使用Spring Boot官方推荐的MyBatis Starter版本,不要随意混拼不同大版本。
另一个常见问题是Mapper XML中的SQL语句明明没问题,但运行时提示无效的列类型或映射错误。多半是resultMap配置不对,或者查询字段与实体属性对不上。调试时打开MyBatis的SQL日志,用控制台查看具体SQL和绑定参数,几乎所有问题都能一眼看出来。
还有一个细节是MyBatis Plus的逻辑删除与分页插件同时使用时,假如你在实体类上加了@TableLogic注解,自动注入的查询SQL会带上del_flag字段,但如果你自己写的XML SQL没有加这个条件,那分页插件生成的count可能也不带,导致总数不对。解决办法是在你的自定义SQL中显式加上逻辑删除条件。
6.4 常用SQL改造与调优速查表
为了方便你对照操作,我整理了一张分页SQL优化速查表,把常见问题、原因和不推荐的写法放在一起。
| 问题现象 | 核心原因 | 推荐优化方案 |
|---|---|---|
| 深分页越来越慢 | 大offset导致大量回表 | 使用延迟关联或游标分页 |
| 分页总数查询慢 | 自动count复杂且命中率低 | 关闭自动count或手动简化count |
| ORDER BY字段无索引 | filesort导致排序开销大 | 建立复合索引覆盖排序字段 |
| 查询字段过多 | 大量TEXT/BLOB字段拖慢传输 | 只查询必要字段,拆分详情查询 |
| JOIN结果集膨胀 | 分页对JOIN结果做LIMIT导致错乱 | 先分页主表ID,再关联子表数据 |
| 数据重复或缺失 | 排序字段不唯一 | 增加ID作为第二排序条件 |
| 缓存命中率低 | 数据频繁变更或缓存key不合理 | 增加版本号key,缩短缓存有效期 |
这张表你可以贴在你的开发文档里,遇到分页慢就直接对照。
7. 聊聊我的实操体会
做了这么多年Java后端,我对分页查询的体会是:很多性能问题在设计初期就已经埋下了,而不是上线之后才突然爆发的。你建表的时候有没有考虑过索引设计,有没有预估数据量增长,有没有想过用户可能会翻到第几百页,这些都会直接影响后续的维护成本。
如果让我给一个最实用的建议,那就是分页查询的SQL一定要做压力测试,尤其是深分页场景。别只测前五页,要测试最后一页,或者用limit 100000, 20这种极端条件来压测。你会发现很多平时看不出来的问题,在深分页下都会原形毕露。
另外,不要把分页优化和业务逻辑隔离开来想。有时候产品经理说“我要显示总条数”,你不一定非要执行一次准确count,通过缓存或估算值也能满足需求。有时候用户根本不关心跳页,那你就该直接换成游标分页和加载更多模式,省去深分页的烦恼。
最后再分享一个小技巧:升级MySQL版本或调整优化器参数,有时候也能带来意外惊喜。比如MySQL 8.0对子查询和索引条件下推的优化更强了,同样的SQL在5.7和8.0上性能差距可能非常大。写分页SQL时,一定要做版本验证,不要默认“老库能跑就万事大吉”。
分页查询看着简单,里面学问一点都不少。希望这篇文章能帮你少走一些弯路,让你下次再遇到“线上分页接口突然变慢”这种问题的时候,心里能有一个清晰的排查步骤和优化思路。
