分页查询这件事,Java后端做个两三年的人基本都会写。MyBatis 里拼一个 LIMIT、MyBatis Plus 里传一个 Page 对象,测试环境数据量小,怎么翻都秒开。可一旦线上表到了千万级,后台订单管理页翻到第几十页的时候,接口耗时从几十毫秒一路飙到两三秒,这时候再回头看分页,就不是“能不能写出来”的问题了,而是“怎么写才能扛住数据量”的问题。这篇文章我主要想聊的就是 MyBatis 和 MyBatis Plus 在分页查询大数据量场景下的性能优化技巧,包括 LIMIT 深层翻页为什么慢、分页插件怎么配置才对、键集分页怎么落地,以及我从真实项目里踩过的坑和排查思路。适合正在做后台管理系统、报表导出这类功能,被大表分页卡过的同学参考。
1. 分页慢,病根到底在哪
1.1 深挖 LIMIT 的代价
很多人一说到分页慢,第一反应是“加索引”,但加了索引之后翻到深页照样慢。问题的核心在 MySQL 执行 LIMIT offset, size 的方式上。
MySQL 的 LIMIT 逻辑其实很粗暴:比如你要查 LIMIT 100000, 20,它的执行流程是扫描出前 100020 条满足条件的记录,然后把前 100000 条直接丢掉,只把最后 20 条返回给客户端。也就是说,你翻得越深,MySQL 扫描的数据就越多,这 100000 条记录在 server 层的索引定位、回表、排序、临时表操作,全部都是真实成本。
这里有一个很多开发容易忽略的点:LIMIT 的偏移量越大,即使总数据量没涨,查询耗时会呈接近线性甚至超线性的增长。原因是 InnoDB 的索引叶子节点虽然是有序的,但每次“跳过”偏移行时,并不是理想中的顺序跳跃,中间还夹杂着回表、排序、连接等操作。尤其是 ORDER BY 非索引字段的分页,MySQL 可能还需要把符合条件的行先放到 sort buffer 里做文件排序,再执行 LIMIT,这个成本只会更高。
给一个直观的对比参考:百万级数据表,WHERE status = 1 ORDER BY id LIMIT 20 这种浅分页,通常几毫秒到几十毫秒完成;但换成 LIMIT 200000, 20,同样的条件,可能直接飙到几百毫秒甚至上秒。如果你的服务器压力再大一点,慢查询日志里基本天天能见到这种 SQL。
所以分页慢,很多时候不是 SQL 写错了,而是你要求 MySQL 在固定时间窗口内干了两件脏活:一件是扫描并丢弃大量无效行,另一件才是真正获取目标数据。优化的核心思路就是设法让 MySQL 少干第一件脏活。
1.2 慢在 SQL 执行,还是慢在数据组装
分页接口变慢,不一定都归罪于数据库执行。我在实际排查中遇到过不少案例,EXPLAIN 显示 SQL 执行只需要 80 毫秒,但接口整体 RT 却到了 800 毫秒。这种差距就需要多问自己一个问题:慢在数据库执行,还是慢在数据组装?
数据组装层面的开销主要在几个地方:
- 大字段传输:列表页习惯性写
SELECT *,一行数据里带一个 1MB 的 TEXT 或者 BLOB 字段,翻一页 20 条就是 20MB 的传输量,网络 IO 和 MyBatis 的结果映射都会被拖垮。 - 对象嵌套映射:MyBatis 的
collection标签在嵌套查询分页时会有著名的 N+1 问题,如果每行数据都再去查一次关联表,分页 20 条就是 20 次额外查询。 - 大对象加载:ORM 会把查出来的每一行包装成对象,如果字段太多、对象太大,GC 压力也会上来。
所以做分页优化,第一步不是去改 SQL,而是先看调用链耗时分布。我习惯的做法是先在数据库客户端里单独执行一遍分页 SQL,看纯 SQL 耗时;再在应用日志里打印接口各阶段耗时;最后定位是 SQL 的问题、MyBatis 映射的问题,还是网络传输的问题。分页性能优化是一个链路问题,不能只盯着数据库那一层。
1.3 两种典型的“大数据量”分页场景
先分清场景再谈优化,因为不同场景的解法完全不一样。
第一种是用户端前台列表,比如商品列表、订单历史。特点是单用户只翻前几页,但并发高、实时性要求高,一个页面的数据量不大,几十条到几百条。这种场景的核心是不要让用户“翻到很深”,同时要扛住高并发,所以 Redis 缓存前几页、缓存 count 等方案都比较适用。
第二种是后台管理系统的列表和报表导出,比如运营后台的订单列表、财务系统的账单查询。特点是翻页很深、单次查询范围大、数据条件复杂(多表 JOIN、动态条件),而且用户就是要几十上百页地翻。这种场景下靠“缓存”解决不了问题,因为查询条件太随机,反而是把 LIMIT 改造成键集分页、优化 SQL 执行计划,才是真正有效的路。
区分这两种场景很重要。我在一些项目里见过没用场景做区分,直接给后台列表套了一层 Redis 缓存,结果因为查询条件组合太多,缓存命中率不到 5%,还带来了数据一致性的麻烦,最后不得不推翻重来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MyBatis 原生分页的正确姿势
2.1 #{} 与 ${}:分页 SQL 里必须较真的地方
MyBatis 的参数绑定有两种方式,#{} 和 ${}。原理上的区别是:#{} 会生成 PreparedStatement 的占位符 ?,由 JDBC 驱动做参数转义,能够有效防止 SQL 注入;${} 是直接把参数拼接到 SQL 字符串里,相当于纯字符串模板替换,如果参数来自用户输入,注入风险很高。
在分页场景下,这两个符号的选择有讲究。LIMIT 后面的偏移量和每页条数,建议一律用 #{} 作为参数传递,例如 LIMIT #{offset}, #{size}。这个写法在不同数据库上兼容性都很好,而且能够利用 PreparedStatement 的缓存,减少 SQL 解析开销。
但 #{} 不是万能的。ORDER BY 后面的字段名和排序方向,用 #{} 是不行的,因为占位符只能替代值,不能替代列名。这时候只能用 ${},比如 ${orderByColumn}。既然用了 ${},就必须在代码层面做白名单校验,绝对不能直接把前端传的参数拼进去。我个人的做法是前端只能传预定义的枚举值,比如 orderBy=createTime,后端映射成白名单里的字段。
还有一个坑:某些数据库方言中,LIMIT 参数在 PreparedStatement 里不能用占位符。早年 MySQL 5.0 系列对 LIMIT ? 支持不好,后来版本基本都能支持了。但如果你用的是 SQL Server、Oracle 这类数据库,LIMIT 的写法完全不同,这种通用性差异也是后面选型要考虑的。
2.2 RowBounds 是假分页,别踩这个坑
MyBatis 自带一个内存分页机制叫 RowBounds,用起来很简便:
java复制List<User> users = sqlSession.selectList("getUserList", param, new RowBounds(offset, limit));
但这个“分页”有一个致命问题:它并不是在 SQL 层做 LIMIT,而是把所有满足条件的数据全部查出来放到内存里,再根据 offset 和 limit 截取你要的那一段。数据量小的时候无所谓,数据量上了百万级,一次查询就可能把整个表的数据加载到 JVM 堆里,轻则接口超时,重则直接 OOM。
我接手过一个老项目,后台某列表页就是用 RowBounds 做的“分页”,当时线上数据才 30 万条,页面已经卡到用户不得不频繁刷新。后来排查的时候发现,一次请求把 30 万条记录打包成 30 万个 User 对象放在堆里,再从中取 10 条,接口耗时稳定在 3 秒以上。后来改成手写 SQL 分页,接口直接掉到 50 毫秒左右。
所以这里要提醒一句:如果看到项目里有人用 RowBounds 做分页,特别是配合大表,趁早换掉。MyBatis 官方文档虽然写了这个类,但它的定位是极小型数据集的“内存截断”,绝不是大数据量场景的正解。
2.3 手写分页 SQL 时,动态条件别把索引带偏
MyBatis 原生分页的常见写法是自己在 XML 里拼 SQL:
xml复制<select id="selectOrderPage" resultType="com.example.Order">
SELECT id, order_no, user_id, amount, status, create_time
FROM orders
<where>
<if test="userId != null">
AND user_id = #{userId}
</if>
<if test="status != null">
AND status = #{status}
</if>
<if test="startTime != null">
AND create_time >= #{startTime}
</if>
</where>
ORDER BY create_time DESC
LIMIT #{offset}, #{size}
</select>
看起来没什么问题,但实际运行中动态条件可能引发两个典型的性能问题。
一个是在 WHERE 条件里对索引列做函数运算或隐式类型转换。比如把 create_time 写成 date_format(create_time, '%Y-%m-%d') = #{date},索引就废了;或者 user_id 是 varchar 类型,但传入的参数被当成数字,MySQL 也会放弃索引。
另一个是条件组合过多导致索引失效。如果某条 SQL 有十多个可选的查询条件,你会发现无论怎么建索引,总有一些组合覆盖不到。这时候不能指望单一索引解决所有组合,需要去分析真正高频的查询路径,把最常用的两三个条件组合设计成联合索引,其他冷门条件允许走次优路径。
另外,强烈建议分页查询不要使用 SELECT *。只查列表页真正需要用到的字段,不仅能把网络传输降下来,还能让优化器在选择索引时更从容——如果查询列都能包含在索引里,直接走覆盖索引,连回表都省了。
3. MyBatis Plus 分页插件:好用但别用歪
3.1 分页插件原理:拦截器帮你做了什么
MyBatis Plus 的物理分页核心是 PaginationInnerInterceptor,它本质上是 MyBatis 的一个 Interceptor,在 Executor 执行 SQL 之前拦截 StatementHandler 的 SQL 解析阶段,根据你传入的 Pagination 对象,自动改写原始 SQL,生成带 LIMIT 的物理分页语句。
以 MySQL 为例,你写的 SQL 是:
sql复制SELECT id, order_no, user_id, amount, status, create_time FROM orders WHERE status = 1
MP 插件会自动改写成:
sql复制SELECT id, order_no, user_id, amount, status, create_time FROM orders WHERE status = 1 LIMIT 0, 10
并且在执行查询前,会额外执行一条 COUNT 查询,用来计算总条数,方便前端渲染分页组件。
原理清楚了,就明白它和 RowBounds 的本质区别:MP 是先在 SQL 层做物理分页,再映射到对象,查询结果只包含当前页的记录,不会出现内存垃圾。
配置上也比较简单,我一般在 Spring Boot 项目里加一个配置类:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInterceptor.setMaxLimit(500L);
interceptor.addInnerInterceptor(paginationInterceptor);
return interceptor;
}
}
这里有两个点值得注意:一个是 DbType 一定要和实际数据库匹配,否则方言生成不对;另一个是 setMaxLimit,我线上环境通常会设置一个单页最大条数阈值,比如 500 条,防止有人把 pageSize 传成 10000,直接把数据库打崩。
3.2 版本与依赖:jsqlparser 的那点破事
MyBatis Plus 分页插件的底层要解析 SQL,依赖的是 JSqlParser 库。不同的 MP 版本,对 JSqlParser 的版本要求也不同。
3.4.0 之前的 MP 版本,jsqlparser 是传递依赖进来的,你只需要引入 mybatis-plus-boot-starter 即可。但从 3.4.0 开始,MP 把 jsqlparser 改成了非传递依赖,如果用的是分页插件,必须手动额外引入:
xml复制<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>4.9</version>
</dependency>
这个坑我踩过。当时项目从 3.3.2 升级到 3.5.1,分页插件直接不生效,SQL 没有被改写,所有分页查询都把全量数据查回来了,接口瞬间超时。排查了半天才在日志里看到 java.lang.NoClassDefFoundError: net/sf/jsqlparser/parser/CCJSqlParserUtil,原因就是在 3.4.0 之后 jsqlparser 不再被自动传递。
所以如果你遇到“分页插件不生效”这种问题,第一反应不要去看拦截器代码,先检查是不是 jsqlparser 没引进来。
另外,MP 3.5.x 之后,针对复杂 SQL(比如多表 JOIN、UNION)的解析能力有所增强,但在 GROUP BY、DISTINCT 这类场景下,自动生成的 COUNT 语句可能仍然不符合预期,需要手动处理。这个我在后面的章节详细展开。
3.3 自动 COUNT 是把双刃剑
MP 分页插件在分页查询时,会自动先跑一条 COUNT 语句,用来计算总条数。如果 SQL 简单,这个 COUNT 很快;但如果 SQL 复杂,比如多表 JOIN、带子查询、带 DISTINCT,COUNT 就可能变成整个分页接口最慢的部分。
针对这一点,MP 提供了一些控制参数:
java复制paginationInterceptor.setOptimizeJoin(true);
paginationInterceptor.setOptimizeCountSql(true);
optimizeCountSql:默认开启,MP 会自动去掉 ORDER BY 和普通 LIMIT,把查询改写为SELECT COUNT(*)形式。对于简单 SQL,这个优化很有效。optimizeJoin:从 3.5.3.2 版本开始支持,会尝试移除 LEFT JOIN 中无用的表,以减少 COUNT 扫描的行数。
但这两个优化都不是万能的。当 SQL 里出现 GROUP BY、UNION、嵌套子查询时,MP 往往无法优化出理想的 COUNT SQL,自动生成的 COUNT 甚至可能统计出错误的总条数。我遇到过 UNION ALL 的分页,总条数统计结果翻倍了,原因就是 MP 只是简单在外部套了一层 COUNT,导致内层 UNION 被重复统计。
遇到这种复杂分页,我建议不要用 MP 的自动计数,直接拆开写:
java复制Page<Order> page = new Page<>(current, size, false);
// false 表示不执行 count 查询
IPage<Order> orderPage = orderMapper.selectOrderPage(page, condition);
然后手动执行一条你精心优化的 COUNT SQL,或者干脆用固定值/近似值代替总数。后台管理系统里的总数往往并不需要精确到个位数,显示“约 10 万条”也能满足业务要求。
4. 大数据量分页的进阶优化手段
4.1 键集分页:深度翻页的真正解法
如果 LIMIT 的痛点在于“扫描并丢弃大量行”,那最直接的优化思路就是不扫描这些行。键集分页(也叫 Seek Method)就是这个思路。
核心写法非常简单,不关心偏移量,只关心“上一次翻到哪了”:
sql复制SELECT id, order_no, user_id, amount, status, create_time
FROM orders
WHERE status = 1
AND id > #{lastId}
ORDER BY id
LIMIT 20;
第一次请求时 lastId 传 0,翻页后把当前页最后一条记录的 id 作为下一次请求的 lastId 传进来。这样每次查询都只扫描目标页那 20 条记录附近的索引,即使翻到第 10000 页,耗时也基本和翻第 1 页一样。对比 LIMIT 的线性衰减,键集分页在深度翻页场景下几乎是常数级性能。
这个方案唯一的限制是:不能跳页,只能相邻翻页。因为你需要知道上一页最后一条记录的 id 才能翻下一页。对用户端信息流列表、后台翻页审核列表这种场景,完全够用;对必须支持跳页的数据表格,可以靠其他方案兜底,比如把跳页限制在前 100 页内,超过 100 页强制走键集分页。
多条件排序时,比如按 create_time 排序,光靠 id > lastId 是不够的。如果两条记录的 create_time 相同,只传 lastId 会导致漏数据。这时候要用联合游标条件:
sql复制WHERE (create_time > #{lastCreateTime})
OR (create_time = #{lastCreateTime} AND id > #{lastId})
ORDER BY create_time, id
LIMIT 20
这个写法利用了联合索引 (create_time, id),也能走索引扫描,不会因为 OR 条件导致全表扫描。实际落地时,我把 lastCreateTime 和 lastId 封装成一个 CursorHolder 对象传给下一次查询,前端只需要回传这两个字段即可。
4.2 覆盖索引与延迟关联:让回表次数降为零
有时候业务上必须支持跳页,键集分页用不了,那就只能在 LIMIT 的基础上做文章。既然 LIMIT 的损耗主要在大量回表,那就想办法减少回表。
覆盖索引的方案是:让索引包含查询需要的所有字段,这样 MySQL 在扫描索引时就能直接返回结果,不需要回表。比如列表页需要查 id, order_no, user_id, amount, status, create_time,且筛选条件是 status,排序是 create_time DESC,就可以加一个联合索引:
code复制INDEX idx_status_time (status, create_time, order_no, user_id, amount)
代价是索引体积增加,写入时间变长。所以实际项目上很少把所有字段都塞进索引,更常见的做法是“延迟关联”。
延迟关联的思路分两步:先用覆盖索引查出目标行的主键 id,再用主键关联回原表取完整数据:
sql复制SELECT o.id, o.order_no, o.user_id, o.amount, o.status, o.create_time
FROM orders o
INNER JOIN (
SELECT id
FROM orders
WHERE status = 1
ORDER BY create_time DESC
LIMIT 100000, 20
) t ON o.id = t.id
ORDER BY o.create_time DESC
内层查询只查 id,可以完全在索引 idx_status_time 上扫描,避免大量回表;外层查询利用主键聚簇索引快速获取完整数据。这个写法对 LIMIT 100000, 20 这种深翻页有明显改善,实测在千万级订单表上,能从两秒左右降到三四百毫秒。
注意内层临时表 t 的结果集只有 20 条 id,所以外层回表只有 20 次,这是整个方案的性能关键。
4.3 只查必要字段,别让大字段拖垮分页
这个建议听起来简单,但线上经常看到违反它的 SQL。列表页明明只需要展示订单号、金额、状态,结果 SELECT * 把 remark 这种长文本、甚至附件二进制字段全部查出来,分页 20 条可能就拉了几百 MB 数据。
大数据量分页查询的一个原则:查询列越少,越能贴近覆盖索引,网络传输也越少。对于列表接口,我强烈建议把“查详情”和“查列表”分开:
- 列表接口只查列表展示需要的字段,因为列表页的信息密度有限,很多字段用不上。
- 详情接口按主键查询完整对象,这个接口天然走主键索引,速度很快。
有几个团队在 MyBatis 里用 DTO 接收列表查询结果,而不是直接把实体类整体返回。这样在代码层面也强制约束了“只查必要字段”的习惯。如果因为历史原因实体里字段太多,可以在 XML 里手写查询列,不用 entity 自动映射。
另外,如果列表里确实需要展示一个文本字段的摘要内容,比如 remark 的前 50 个字符,可以在查询时用 SQL 截断:
sql复制SELECT id, order_no, LEFT(remark, 50) AS remark_preview
FROM orders
LIMIT #{offset}, #{size}
虽然截断还是在数据库层做了运算,但传输到应用层的数据量小了一个数量级,对网络 IO 和 JVM 内存都有显著改善。
4.4 COUNT 查询与 Redis 缓存思路
分页接口往往还有一个隐藏的慢点,就是 COUNT 查询。很多接口总耗时里,COUNT 占了 50% 以上。
COUNT 的优化有几个方向:
第一,利用覆盖索引优化 COUNT。SELECT COUNT(*) 在 InnoDB 中会走最小可用索引扫描,索引越小,扫描越快。所以如果发现 COUNT 慢,可以试试建一个小索引。但注意,COUNT 本质上还是要统计行数,数据量太大时它依然会慢。
第二,业务上降低精确度要求。后台列表只要能显示一个大概的总数,比如“共约 10 万条”,就可以把 COUNT 缓存到 Redis,设置一个 5 分钟的过期时间:
java复制String countKey = "order:page:count:" + DigestUtils.md5Hex(sqlConditionKey);
Long total = redisTemplate.opsForValue().get(countKey);
if (total == null) {
total = orderMapper.selectCountByCondition(condition);
redisTemplate.opsForValue().set(countKey, total, Duration.ofMinutes(5));
}
缓存 KEY 不要只拼页码,要把所有筛选条件拼接后做哈希,否则 A 用户查到的 count 会被 B 用户错误复用。
第三,对热门的前几页做整页缓存。用户端列表 90% 的流量都集中在前 3 页,可以对这几页的查询结果做 Redis 缓存,缓存 KEY 可以包含条件哈希和页码。数据变更时,可以通过删除相关缓存或者短 TTL 来保证一致。这个方案不适合条件组合太多的后台查询,但很适合商品列表这类热点集中的场景。
我个人的建议是:把 COUNT 缓存与页数据缓存分开设计,COUNT 可以容忍 5 分钟左右的延迟,页数据要严格控制一致性,最好在写入事务提交后主动删除对应缓存。
5. 实战复盘:一次千万级订单分页优化案例
5.1 问题现象与初步定位
去年我处理过一个后台订单管理系统的分页问题。订单表当时已经超过 2000 万行,数据量还在持续增长。运营反馈后台订单列表翻到第 100 页之后,页面转圈至少 5 秒,经常被浏览器拦截请求,甚至点击导出报表的时候服务直接 OOM。
我拿到问题后,先看慢查询日志。排在最前面的几条 SQL 基本都是同一个模板:
sql复制SELECT *
FROM orders
WHERE status = 0
AND create_time BETWEEN '2023-01-01' AND '2023-12-31'
ORDER BY create_time DESC
LIMIT 200000, 20;
在 MySQL 客户端里直接执行,耗时 3.2 秒。EXPLAIN 看了一眼,走了 idx_create_time 索引,但是扫描行数显示接近 220 万行,Extra 里有 Using filesort。也就是说 MySQL 先按时间范围取出来大量行,再做文件排序,最后丢弃了前面 20 万行。
这一步定位很典型:两个问题叠加在一起,一是范围查询筛出来的数据量本身很大,二是深偏移量导致大量无效排序和回表。
5.2 优化方案设计
解决方案我分了三步走:
第一步,给高频筛选条件加联合索引。这张表日常查询集中在 status + create_time 这个组合上,于是新建索引:
sql复制ALTER TABLE orders ADD INDEX idx_status_time (status, create_time);
这一步解决了 Using filesort 问题,因为联合索引天然按 status 过滤后按 create_time 排序,排序可以直接走索引。
第二步,把 SELECT * 改成只查列表展示字段。订单列表页面实际需要展示的是订单号、用户 ID、金额、状态、创建时间、更新时间,其他字段一律不查,SQL 里显式列出字段。
第三步,针对深翻页,把列表改造成键集分页。前端翻页时不再传页码,而是传上一页最后一条记录的 create_time 和 id。由于 idx_status_time 已经是 (status, create_time) 联合索引,我还把 id 作为第二个排序字段,把游标条件写成:
sql复制WHERE status = 0
AND (
create_time < #{lastCreateTime}
OR (create_time = #{lastCreateTime} AND id < #{lastId})
)
ORDER BY create_time DESC, id DESC
LIMIT 20
这样每次翻页都能通过联合索引精确定位到上一页末尾的位置,扫描行数从 220 万降到了 20 多条。
5.3 优化效果与兼容性处理
优化上线后,我把第 100 页的耗时从 3.2 秒降到了 60 毫秒左右。更重要的是,不管翻到第几页,耗时都稳定在几十毫秒级别,不再随着页数加深而恶化。
不过改造过程中也遇到一个业务上的阻力:后台运营已经习惯了“跳转到第 N 页”的操作。为了兼容这个习惯,我在前端做了一个折中方案:前 100 页以内保留传统页码跳转,但底层查询改为“先查出前 100 页的最后一条游标位置,再做键集分页”;超过 100 页后只允许上一页/下一页翻页,并在界面上给出提示。这个方案虽然不是最优雅的,但既能满足运营习惯,也能保证性能。
另外,导出报表功能原来的 OOM 问题,我在 6.3 小节单独说。
5.4 经验总结
这次优化给我最大的感触是:分页性能优化一定要先量化问题,再选方案。单纯说“分页慢”不行,要问清楚是第几页慢、慢在 SQL 还是慢在传输、业务是否真的需要跳页、用户允许接受多大延迟。这些答案不同,方案可能完全相反。
6. 常见问题与排查技巧实录
6.1 分页插件不生效怎么办
MP 分页插件不生效是很常见的坑。典型症状是:调用 selectPage 时返回的数据量正确,但是 SQL 里没有 LIMIT,接口一次查询返回全量数据。
排查步骤按优先级排:
- 检查是否注册了
MybatisPlusInterceptor,并且PaginationInnerInterceptor已加入。缺少@Configuration配置类是最常见原因。 - 检查是否引入了 jsqlparser 依赖。MP 3.4.0 之后没有自动传递这个依赖,分页插件解析 SQL 会失败,但错误可能被静默吞掉。
- 检查自定义 SQL 是否有多个
Executor或自定义拦截器,拦截器顺序可能影响分页插件的执行。 - 检查
@MapperScan是否扫描到对应 Mapper,如果没有扫描到,调用的是 Spring 的代理对象,也不会走分页拦截器。
6.2 COUNT 查询特别慢的排查思路
分页接口的 COUNT 慢,我先用 SHOW PROFILE 定位是处于 Sending data 还是 Sorting result 阶段。如果是全表扫描导致的,检查 COUNT 是否走了最小索引,尝试加一个小联合索引。如果是 GROUP BY、DISTINCT、JOIN 导致的 COUNT 慢,考虑改成不精确统计或者手动写更简单的计数 SQL。
还要注意一个细节:MP 分页插件默认的 optimizeCountSql 会尝试移除 ORDER BY,但它对子查询、UNION 无能为力。遇到复杂查询,直接关掉自动计数(new Page<>(current, size, false)),自己写 COUNT。
6.3 大数据量导出 OOM:流式查询的正确姿势
后台导出功能经常出现百万级数据一次全查出来导致 OOM。问题出在很多人用分页循环 + 列表查询的方式做导出,虽然是循环分页,但是每条记录被实体对象包装后累积在 List 里,最终内存还是爆炸。
正确的做法是让数据库游标边查边写:
java复制@Select("SELECT * FROM orders WHERE status = #{status}")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = Integer.MIN_VALUE)
void scanOrders(@Param("status") Integer status, ResultHandler<Order> handler);
使用 fetchSize = Integer.MIN_VALUE 配合 MySQL 的游流转发模式,可以让 JDBC 逐条从服务端读取,而不是一次性把所有结果加载到内存。然后在 ResultHandler 里逐条写入 Excel。
6.4 逻辑删除数据的查询问题
MyBatis Plus 的逻辑删除默认会在查询时自动追加 deleted = 0 条件,分页查询也一样。如果想在分页里把已逻辑删除的数据查出来,常见方案是自定义 SQL 绕过 MP 的自动拼接:
java复制@Select("SELECT * FROM orders WHERE deleted = 1")
List<Order> selectDeletedOrders();
如果希望某个查询忽略逻辑删除自动填充,可以使用 MP 的 @InterceptorIgnore 注解:
java复制@InterceptorIgnore(tenantLine = "true", dynamicTableName = "true")
@Select("SELECT * FROM orders WHERE id = #{id}")
Order selectIgnoreLogicDelete(Long id);
这个注解的版本支持情况需要根据自己项目的 MP 版本确认。需要注意,这种方式会绕过 MP 的自动逻辑删除,可能查出来敏感数据,使用前要确认业务场景。
6.5 分页与 SQL 注入的边界
最后提醒一个安全细节。分页查询里最常见的注入点是 ORDER BY 字段名和排序方向,因为这里只能用 ${}。如果直接把前端参数拼进去,恶意用户可以通过 orderBy=create_time,1 或 orderBy=id, update table set... 之类的方式注入自定义 SQL 片段。
我见过的最粗暴但有效的做法是:后端定义白名单。
java复制private static final Map<String, String> ORDER_BY_MAP = Map.of(
"createTime", "create_time",
"amount", "amount",
"status", "status"
);
前端传 orderBy=createTime,后端用 map 映射成真实列名,再拼进 SQL。映射不到的直接抛参数错误。排序方向也只接受 asc 和 desc 两个枚举值。
分页参数本身虽然用 #{} 是安全的,但 LIMIT 的 offset 和 size 还是会反射到性能上。我在线上通常会做一层参数校验,offset 最大不超过 100000,size 最大不超过 500。超过的话直接提示用户调整筛选条件,而不是放任深翻页打崩数据库。
我在实际项目中跑过很多分页优化,最后发现一个道理:分页优化的关键从来不是炫技,而是想清楚“用户真的需要在这里翻这么多页吗”。大部分后台列表翻到几十页以后,其实用户已经没有耐心看了,与其让性能无限背锅,不如在产品层面增加筛选条件、减少翻页深度。但做技术的人,还是得先把这几招用熟,至少别让数据库在翻页的时候原地爆炸。这套方法我从 MyBatis 原生 SQL 到 MyBatis Plus 分页插件都验证过,大家遇到类似场景可以直接照着排查。
