MyBatis-Plus分页查询底层原理

一、分页查询的核心痛点与 MP 的解决方案

在传统 MyBatis 开发中,分页实现往往需要手动拼接LIMIT语句(MySQL)或ROWNUM(Oracle),不仅繁琐且易出错,还存在两个核心问题:

  1. SQL 侵入性强:业务 SQL 与分页语法耦合,切换数据库时需批量修改;

  2. 总条数统计冗余:需手动编写 count 查询,且需处理复杂查询(如多表关联、分组统计)的 count 适配。

MyBatis-Plus(以下简称 MP)的分页插件通过拦截器机制+数据库方言适配,实现了 “无侵入式分页”,其核心设计思路是:在 SQL 执行前动态改写 SQL,自动添加分页语法和 count 查询,同时封装分页结果

二、MP 分页的核心组件与依赖关系

MP 分页功能的实现依赖 3 个核心组件,它们的协作流程决定了分页的底层逻辑:

组件名称作用说明
PaginationInnerInterceptor核心拦截器,负责拦截 SQL 执行、改写 SQL、统计总条数(MP 3.4.0 + 推荐使用)
Page分页参数载体,封装页码、每页条数、总条数、分页结果集等信息
Dialect数据库方言接口,适配不同数据库的分页语法(如 MySQL 的 LIMIT、PostgreSQL 的 OFFSET)

依赖关系Page传递分页参数 → PaginationInnerInterceptor拦截 SQL → 调用Dialect生成适配 SQL → 执行查询并封装结果。

三、底层核心流程:从 SQL 拦截到结果返回

1. 拦截器触发条件

MP 的分页插件本质是 MyBatis 的Interceptor接口实现类,通过@Intercepts注解指定拦截StatementHandlerprepare方法(SQL 预处理阶段)和query方法(结果查询阶段):

1
2
3
4
5
6
7
8
9
10
11
12
13
@Intercepts({

@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}),

@Signature(type = StatementHandler.class, method = "query", args = {Statement.class, ResultHandler.class})

})

public class PaginationInnerInterceptor implements InnerInterceptor {

// 核心逻辑实现

}

当执行mapper.selectPage(page, queryWrapper)时,MyBatis 的插件链会触发该拦截器。

2. SQL 动态改写原理

这是分页功能的核心步骤,MP 会根据数据库方言,将原始查询 SQL 改写成 “分页查询 SQL” 和 “总条数查询 SQL”:

(1)原始 SQL 示例(MySQL)
1
SELECT id, name, age FROM user WHERE age > 18
(2)分页 SQL 改写(添加 LIMIT)

MP 通过BoundSql解析原始 SQL,拼接分页语法:

1
SELECT id, name, age FROM user WHERE age > 18 LIMIT 0, 10  -- 第1页,每页10条

关键逻辑:在prepare方法中,通过Dialect.buildPaginationSql生成适配 SQL,不同数据库的方言实现不同:

  • MySQL:LIMIT offset, size

  • Oracle:SELECT * FROM (SELECT t.*, ROWNUM rn FROM (...) t WHERE ROWNUM ?) WHERE rn > ?

  • PostgreSQL:OFFSET ? LIMIT ?

(3)总条数 SQL 自动生成

MP 会自动剥离原始 SQL 的SELECT字段和ORDER BY(避免 count 统计错误),生成 count 查询:

1
SELECT COUNT(1) FROM user WHERE age > 18

优化点

  • 若原始 SQL 含GROUP BY,count 会改为COUNT(DISTINCT 分组字段)

  • 可通过page.setSearchCount(false)关闭总条数统计(适用于无需总页数的场景)。

3. 分页参数传递与结果封装

  • 参数传递Page对象中的current(当前页)和size(每页条数)会被转换为offset = (current - 1) * size,作为分页参数注入改写后的 SQL;

  • 结果封装:执行分页 SQL 后,查询结果会被设置到page.getRecords();执行 countSQL 后,总条数会设置到page.setTotal(total),并自动计算pages = (total + size - 1) / size(总页数)。

四、物理分页 vs 逻辑分页:MP 为何选择物理分页?

很多人会疑惑:MP 为何采用 “物理分页”(依赖数据库 SQL 语法)而非 “逻辑分页”(查询全量数据后内存截取)?

对比维度物理分页(MP 实现)逻辑分页(内存截取)
性能只查询当前页数据,数据库压力小查询全量数据,大数据量下内存溢出
适用场景大数据量、跨库兼容小数据量、本地测试
依赖数据库方言支持无依赖,纯 Java 逻辑

MP 选择物理分页的核心原因是性能优化—— 对于百万级数据,逻辑分页会导致全表扫描,而物理分页通过数据库的索引优化,仅返回当前页数据,大幅降低 IO 开销。

五、源码关键片段解析(MP 3.5.3 版本)

1. 分页 SQL 改写核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// PaginationInnerInterceptor.java

@Override

public void beforePrepare(StatementHandler sh, Connection conn, Integer transactionTimeout) {

// 1. 获取原始BoundSql(封装SQL和参数)

BoundSql boundSql = sh.getBoundSql();

String sql = boundSql.getSql();

// 2. 判断是否需要分页(是否包含Page参数)

Page page = getPage(sh);

if (page != null && page.isNeedPagination()) {

// 3. 获取数据库方言(根据Connection自动识别)

Dialect dialect = getDialect(conn);

// 4. 改写分页SQL

String paginationSql = dialect.buildPaginationSql(sql, page.getOffset(), page.getSize());

// 5. 替换原始SQL

ReflectUtil.setFieldValue(boundSql, "sql", paginationSql);
}

}

2. 总条数统计核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// PaginationInnerInterceptor.java

private long count(Connection conn, String sql, List Class

// 1. 生成count SQL

String countSql = DialectBuilder.buildCountSql(sql);

// 2. 执行count查询

return SqlHelper.executeQuery(conn, countSql, paramList, rs -> {

if (rs.next()) {

return rs.getLong(1);

}

return 0L;

});

}

六、常见问题与底层原理关联分析

1. 分页失效?—— 拦截器未注册

若分页 SQL 未添加 LIMIT,大概率是分页插件未注入 Spring 容器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 正确配置(Spring Boot)

@Configuration

public class MyBatisPlusConfig {

@Bean

public MybatisPlusInterceptor mybatisPlusInterceptor() {

MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

// 添加分页拦截器,并指定数据库类型

interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));

return interceptor;
}

}

原理:未注册拦截器时,SQL 不会被改写,自然无法实现分页。

2. 总条数统计错误?—— SQL 解析异常

若 count 结果与实际不符,可能是原始 SQL 含复杂语法(如UNION子查询),导致 MP 的 SQL 解析失败。此时可手动指定 countSQL:

1
page.setCountSql("SELECT COUNT(1) FROM (原始复杂SQL) t");

原理:MP 的自动 countSQL 生成依赖 SQL 解析器,复杂语法可能导致解析偏差,手动指定可绕过解析逻辑。

3. 跨库分页兼容问题?—— 方言未适配

切换数据库后分页失效,需确保DbType与实际数据库一致:

1
2
3
// 适配PostgreSQL

interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE\_SQL));

原理:不同数据库的分页语法差异,方言是连接 SQL 改写与数据库的桥梁。

七、总结与思考

MyBatis-Plus 的分页功能之所以强大,核心在于 **“无侵入式设计” 和 “数据库方言适配”**:

  1. 基于 MyBatis 拦截器机制,无需修改业务 SQL,实现分页逻辑与业务逻辑解耦;

  2. 通过方言接口封装不同数据库的分页语法,解决跨库兼容问题;

  3. 自动生成 countSQL,减少重复编码,同时支持灵活配置(关闭统计、手动指定 countSQL)。

从底层原理来看,MP 的分页插件本质是 “SQL 解析与改写的自动化工具”,其设计思路值得借鉴:将通用逻辑(分页)抽离为插件,通过拦截器嵌入框架流程,既不破坏原有架构,又能提升开发效率

在实际开发中,需注意:

  • 复杂 SQL 场景下,建议手动验证分页 SQL 和 countSQL 的正确性;

  • 大数据量分页(如千万级数据),可结合PageHelper的 “合理化分页” 或 MP 的page.setReasonable(true)避免页码越界;

  • 无需总条数时,关闭searchCount可提升查询性能。