0%

mybatis实现分页

MyBatis 分页深度解析:从原理到工程实践(含自定义插件与 PageHelper 实战)

MyBatis 分页是企业级开发中高频需求,核心分为逻辑分页(内存分页)和物理分页(数据库层面分页)两类。系统梳理 MyBatis 分页的实现方式,包括逻辑分页原理、自定义物理分页插件深度解析、第三方插件(PageHelper)整合,以及工程化最佳实践,帮助你彻底掌握分页逻辑并规避性能陷阱。

MyBatis 分页的两种核心类型

在深入代码前,需先明确两种分页的本质区别,这是选择分页方案的基础:

分页类型 实现原理 优点 缺点 适用场景
逻辑分页 先查询全表数据到内存,再通过 RowBounds 截取指定范围数据 无需改写 SQL,MyBatis 原生支持 大数据量下全表查询导致 OOM,性能极差 小数据量(如 < 1000 条)、简单测试场景
物理分页 在 SQL 中添加分页语法(如 MySQL LIMIT、Oracle ROWNUM),数据库仅返回指定范围数据 性能优(仅查询所需数据),支持大数据量 需改写 SQL,需适配不同数据库语法 生产环境、大数据量分页(如列表查询)

逻辑分页:MyBatis 原生 RowBounds

RowBounds 是 MyBatis 内置的逻辑分页工具,无需额外配置,但仅适用于小数据量场景。

原理剖析

  • 核心属性offset(起始索引,默认 0)、limit(每页条数,默认 Integer.MAX_VALUE);
  • 执行流程:
    1. MyBatis 执行 SQL 时,先查询全表数据ResultSet
    2. 通过 RowBounds 跳过 offset 条数据,读取 limit 条数据到内存;
    3. 丢弃剩余数据,返回截取后的结果。

使用示例

(1)Mapper 接口
1
2
// 方法参数中添加 RowBounds(无需 @Param,MyBatis 自动识别)
List<Student> selectByClassId(@Param("classId") int classId, RowBounds rowBounds);
(2)Mapper XML
1
2
3
<select id="selectByClassId" resultType="Student">
SELECT id, name FROM student WHERE class_id = #{classId}
</select>
(3)调用代码
1
2
3
// 分页参数:第 1 页(offset = (1-1)*10 = 0),每页 10 条
RowBounds rowBounds = new RowBounds(0, 10);
List<Student> students = studentMapper.selectByClassId(1, rowBounds);

致命缺陷(必须规避)

  • 性能问题:无论 limit 多大,都会查询全表数据,若表数据达 10 万 + 条,会导致内存溢出(OOM);
  • 无总条数:仅返回当前页数据,无法获取总条数和总页数,需单独查询(如 SELECT COUNT(*) FROM student);
  • 不支持复杂 SQL:若 SQL 含 GROUP BYORDER BY,全表查询后内存排序性能极差。

结论:生产环境严禁使用 RowBounds 进行分页,仅用于本地测试或极小数据量场景。

物理分页:自定义插件实现(深度解析你的代码)

物理分页的核心是在 SQL 中动态添加分页语法,MyBatis 插件(Interceptor)通过拦截 SQL 执行流程实现这一目标。

1. 自定义分页插件(仅分页,无总条数)

插件拦截 StatementHandler#prepare 方法,在创建 PreparedStatement 前改写 SQL,添加 LIMIT 语法。

(1)核心原理
  • 拦截目标StatementHandler#prepare(Connection, Integer)—— 该方法负责创建 Statement 对象,是改写 SQL 的最佳时机;
  • 关键步骤:
    1. 分离代理链:MyBatis 插件会生成多层代理对象(如 Plugin 代理),需通过 MetaObject 获取最原始的 StatementHandler 目标对象;
    2. 获取配置与参数:从 MappedStatement 中获取 SQL 语句,从 ParameterHandler 中获取分页参数(pagesize);
    3. 改写 SQL:拼接 LIMIT (page-1)*size, size 到原始 SQL 末尾;
    4. 执行原方法:将改写后的 SQL 注入 BoundSql,继续执行 SQL。
(2)代码逐行解析
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Intercepts({@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}
)})
public class MyPageInterceptor implements Interceptor {
private int page;
private int size;

@Override
public Object intercept(Invocation invocation) throws Throwable {
// 1. 获取被拦截的 StatementHandler 代理对象
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();

// 2. 使用 MetaObject 操作 MyBatis 内部对象(规避私有属性访问限制)
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);

// 3. 分离代理链:获取最原始的目标对象(避免多层代理导致的属性获取失败)
while (metaObject.hasGetter("h")) { // 处理 Plugin 代理的 "h" 属性
metaObject = SystemMetaObject.forObject(metaObject.getValue("h"));
}
while (metaObject.hasGetter("target")) { // 处理其他代理的 "target" 属性
metaObject = SystemMetaObject.forObject(metaObject.getValue("target"));
}

// 4. 获取 MappedStatement(包含 SQL 配置信息)
MappedStatement ms = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
String mapperId = ms.getId();

// 5. 仅对后缀为 "ByPager" 的方法进行分页(约定优于配置,避免所有 SQL 都被拦截)
if (mapperId.matches(".+ByPager$")) {
// 6. 获取分页参数(从 ParameterHandler 中获取 @Param 传递的 page 和 size)
ParameterHandler paramHandler = (ParameterHandler) metaObject.getValue("delegate.parameterHandler");
Map<String, Object> params = (Map<String, Object>) paramHandler.getParameterObject();
this.page = (int) params.get("page");
this.size = (int) params.get("size");

// 7. 改写 SQL:添加 LIMIT 语法
String originalSql = (String) metaObject.getValue("delegate.boundSql.sql");
String paginatedSql = originalSql + " LIMIT " + (page - 1) * size + "," + size;
metaObject.setValue("delegate.boundSql.sql", paginatedSql); // 注入改写后的 SQL
}

// 8. 执行原方法:继续创建 Statement 并执行 SQL
return invocation.proceed();
}

@Override
public Object plugin(Object target) {
// 生成代理对象:仅对 StatementHandler 类型的对象进行代理
return Plugin.wrap(target, this);
}

@Override
public void setProperties(Properties properties) {
// 从配置中读取默认参数(如默认每页条数)
this.page = Integer.parseInt(properties.getProperty("limit", "10"));
}
}
(3)插件注册(Spring 配置)

需在 MyBatis 配置中注册插件,确保拦截器生效:

1
2
3
4
5
6
7
<!-- mybatis-config.xml -->
<plugins>
<plugin interceptor="com.example.interceptor.MyPageInterceptor">
<!-- 配置默认每页条数 -->
<property name="limit" value="20"/>
</plugin>
</plugins>

2. 自定义分页插件(含总条数查询)

插件拦截 Executor#query 方法,先执行 COUNT(*) 查询获取总条数,再执行分页 SQL,最终返回包含总条数的 Page 对象,解决了 “仅分页无总条数” 的问题。

(1)核心改进点
  • 拦截目标Executor#query(MappedStatement, Object, RowBounds, ResultHandler)—— 该方法负责执行查询,可在分页前先查总条数;
  • 关键新增步骤:
    1. 构造 COUNT SQL:基于原始 SQL 生成 SELECT COUNT(*) FROM (原始SQL) temp
    2. 创建 COUNT 专用 MappedStatement:复制原始 MappedStatement,修改返回类型为 Integer,用于执行总条数查询;
    3. 传递参数:处理 BoundSqladditionalParameters(如 Criteria 动态参数),确保 COUNT SQL 参数正确;
    4. 封装分页结果:将分页数据和总条数封装到自定义 Page 对象,返回给业务层。
(2)核心代码解析(总条数查询部分)
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 拦截 Executor 的 query 方法
@Intercepts({@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
)})
public class OffsetLimitInterceptor implements Interceptor {

@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
RowBounds rowBounds = (RowBounds) args[2];
PageParam pageParam = new PageParam(rowBounds);

// 1. 非分页请求:直接执行原查询
if (pageParam.getOffset() == 0 && pageParam.getLimit() == Integer.MAX_VALUE) {
return invocation.proceed();
}

Executor executor = (Executor) invocation.getTarget();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
BoundSql boundSql = ms.getBoundSql(parameter);

// 2. 处理动态参数(如 MyBatis Generator 的 Criteria 动态 SQL 参数)
Field additionalParamsField = BoundSql.class.getDeclaredField("additionalParameters");
additionalParamsField.setAccessible(true);
Map<String, Object> additionalParams = (Map<String, Object>) additionalParamsField.get(boundSql);

// 3. 执行总条数查询
if (rowBounds instanceof PageParam) {
// 3.1 创建 COUNT 专用的 MappedStatement(返回类型为 Integer)
MappedStatement countMs = newMappedStatement(ms, Integer.class);
// 3.2 生成 COUNT SQL
String countSql = "SELECT COUNT(*) FROM (" + boundSql.getSql() + ") temp";
// 3.3 构建 COUNT 对应的 BoundSql(传递动态参数)
BoundSql countBoundSql = new BoundSql(ms.getConfiguration(), countSql, boundSql.getParameterMappings(), parameter);
for (String key : additionalParams.keySet()) {
countBoundSql.setAdditionalParameter(key, additionalParams.get(key));
}
// 3.4 执行 COUNT 查询,获取总条数
CacheKey countCacheKey = executor.createCacheKey(countMs, parameter, RowBounds.DEFAULT, countBoundSql);
List<Object> countResult = executor.query(countMs, parameter, RowBounds.DEFAULT, (ResultHandler) args[3], countCacheKey, countBoundSql);
int totalCount = (int) countResult.get(0);
pageParam.setTotalCount(totalCount); // 存入分页参数
}

// 4. 执行分页查询(改写 SQL 加 LIMIT)
String pageSql = boundSql.getSql() + " LIMIT " + pageParam.getOffset() + "," + pageParam.getLimit();
BoundSql pageBoundSql = new BoundSql(ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameter);
for (String key : additionalParams.keySet()) {
pageBoundSql.setAdditionalParameter(key, additionalParams.get(key));
}
CacheKey pageCacheKey = executor.createCacheKey(ms, parameter, rowBounds, pageBoundSql);
List<?> pageResult = executor.query(ms, parameter, RowBounds.DEFAULT, (ResultHandler) args[3], pageCacheKey, pageBoundSql);

// 5. 封装分页结果(包含数据和总条数)
return new Page<>(pageResult, pageParam);
}

// 构建 COUNT 专用的 MappedStatement(复制原始 MS,修改返回类型为 Integer)
private MappedStatement newMappedStatement(MappedStatement ms, Class<Integer> resultType) {
MappedStatement.Builder builder = new MappedStatement.Builder(
ms.getConfiguration(), ms.getId() + "_count", ms.getSqlSource(), ms.getSqlCommandType()
);
// 构建 COUNT 查询的 ResultMap(返回 Integer 类型)
ResultMap countResultMap = new ResultMap.Builder(
ms.getConfiguration(), ms.getId() + "_count_result", resultType, new ArrayList<>()
).build();
builder.resource(ms.getResource())
.parameterMap(ms.getParameterMap())
.resultMaps(Arrays.asList(countResultMap)) // 设置返回类型为 Integer
.useCache(ms.isUseCache())
.cache(ms.getCache());
return builder.build();
}

// 其他方法(plugin、setProperties)略...
}

3. 自定义插件的优缺点

优点 缺点
完全自定义,可适配特殊分页逻辑(如多表联查分页) 需手动处理不同数据库语法(MySQL 用 LIMIT,Oracle 用 ROWNUM)
无第三方依赖,轻量 维护成本高(需处理参数传递、动态 SQL、缓存等问题)
可灵活控制拦截范围(如按方法名匹配) 无分页合理化(需手动处理页码越界,如 page <1 或 page> 总页数)

物理分页:第三方插件 PageHelper(生产首选)

自定义插件虽灵活,但实际项目中99% 的场景会选择 PageHelper—— 国内最流行的 MyBatis 分页插件,支持自动适配数据库方言、分页合理化、总条数查询等功能,无需手动写 SQL 和拦截器。

1. PageHelper 核心优势

  • 自动方言适配:支持 MySQL、Oracle、SQL Server、PostgreSQL 等主流数据库,无需手动写 LIMIT/ROWNUM
  • 分页合理化:自动处理页码越界(如页码 <1 时默认 1,页码> 总页数时默认最后一页);
  • 零侵入:无需修改 Mapper 接口和 XML,仅需在查询前设置分页参数;
  • 支持多种分页方式:基本分页、带总条数分页、分页 + 排序、嵌套查询分页。

2. 整合步骤(Spring Boot 为例)

(1)引入依赖
1
2
3
4
5
6
<!-- PageHelper 核心依赖 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version> <!-- 稳定版,适配 Spring Boot 2.x -->
</dependency>
(2)配置分页参数(application.yml)
1
2
3
4
5
6
pagehelper:
helper-dialect: mysql # 数据库方言(自动检测可省略,建议显式配置)
reasonable: true # 分页合理化:页码越界时自动调整(如 page=0 → page=1)
support-methods-arguments: true # 支持通过 Mapper 方法参数传递分页参数
params: count=countSql # 总条数查询的 SQL 标识
page-size-zero: true # 允许 pageSize=0(查询所有数据,相当于不分页)
(3)基本使用(三种方式)
方式 1:使用 PageHelper.startPage 静态方法(最常用)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class StudentService {
@Autowired
private StudentMapper studentMapper;

public PageInfo<Student> getByClassId(int classId, int pageNum, int pageSize) {
// 1. 设置分页参数:pageNum(页码)、pageSize(每页条数)
PageHelper.startPage(pageNum, pageSize);
// 2. 执行普通查询(PageHelper 会自动拦截 SQL 加分页语法)
List<Student> students = studentMapper.selectByClassId(classId);
// 3. 封装分页结果(包含总条数、总页数、当前页数据等)
return new PageInfo<>(students);
}
}

// Mapper 接口(无需任何分页参数,保持普通查询)
public interface StudentMapper {
List<Student> selectByClassId(@Param("classId") int classId);
}

// Mapper XML(普通 SQL,无需加 LIMIT)
<select id="selectByClassId" resultType="Student">
SELECT id, name FROM student WHERE class_id = #{classId}
</select>
方式 2:通过 Mapper 方法参数传递分页参数(Page 对象)
1
2
3
4
5
6
7
8
9
10
11
// Mapper 接口(参数为 Page 对象)
public interface StudentMapper {
List<Student> selectByClassIdWithPage(@Param("classId") int classId, Page<Student> page);
}

// Service 调用(直接传递 Page 对象)
public PageInfo<Student> getByClassIdWithPage(int classId, int pageNum, int pageSize) {
Page<Student> page = new Page<>(pageNum, pageSize);
List<Student> students = studentMapper.selectByClassIdWithPage(classId, page);
return new PageInfo<>(students);
}
方式 3:分页 + 排序
1
2
3
4
5
6
public PageInfo<Student> getByClassIdOrder(int classId, int pageNum, int pageSize) {
// 添加排序:ORDER BY id DESC
PageHelper.startPage(pageNum, pageSize).setOrderBy("id DESC");
List<Student> students = studentMapper.selectByClassId(classId);
return new PageInfo<>(students);
}

3. PageHelper 原理简析

PageHelper 本质也是 MyBatis 插件,核心拦截 Executor#query 方法,流程与你的自定义插件类似,但做了更完善的封装:

  1. 方言适配:通过 Dialect 接口实现不同数据库的分页语法(如 MySqlDialect 生成 LIMITOracleDialect 生成 ROWNUM 子查询);
  2. 分页参数解析:通过 PageHelper.startPage 存储分页参数到 ThreadLocal,确保多线程安全;
  3. SQL 改写:拦截 BoundSql,自动添加分页语法和排序语法;
  4. 结果封装:将查询结果封装为 Page 对象,包含 total(总条数)、pages(总页数)、list(当前页数据)等属性。

工程化最佳实践

1. 分页参数封装(统一规范)

避免方法参数中传递零散的 pageNumpageSize,建议封装为分页参数类:

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
/**
* 统一分页参数类
*/
@Data
public class PageQuery {
@Min(value = 1, message = "页码不能小于1")
private Integer pageNum = 1; // 默认第1页

@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 100, message = "每页条数不能大于100(避免一次性查询过多数据)")
private Integer pageSize = 10; // 默认每页10条

// 排序字段(可选)
private String sortField;
// 排序方向(ASC/DESC,可选)
private String sortDir = "ASC";

// 生成排序字符串(如 "id DESC")
public String getOrderBy() {
if (StrUtil.isBlank(sortField)) {
return null;
}
// 防 SQL 注入:仅允许字母、数字、下划线的排序字段
if (!sortField.matches("^[a-zA-Z0-9_]+$")) {
throw new IllegalArgumentException("排序字段非法");
}
return sortField + " " + sortDir.toUpperCase();
}
}

使用示例:

1
2
3
4
5
6
7
// Service 方法
public PageInfo<Student> getByClassId(PageQuery pageQuery, int classId) {
PageHelper.startPage(pageQuery.getPageNum(), pageQuery.getPageSize())
.setOrderBy(pageQuery.getOrderBy());
List<Student> students = studentMapper.selectByClassId(classId);
return new PageInfo<>(students);
}

2. 总条数查询优化

  • 避免不必要的总条数:若仅需 “下一页是否存在”(如滚动加载),可关闭总条数查询,减少一次COUNT(*)开销:

    1
    2
    3
    // PageHelper 关闭总条数查询
    Page<Object> page = PageHelper.startPage(pageNum, pageSize);
    page.setCount(false); // 不查询总条数
  • 复杂 SQL 的 COUNT 优化:若原始 SQL 含GROUP BY、DISTINCT,直接COUNT(*)性能差,可手动写优化的 COUNT SQL:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <!-- Mapper XML:手动定义 COUNT SQL -->
    <select id="selectByClassIdCount" resultType="java.lang.Long">
    SELECT COUNT(DISTINCT s.id) FROM student s
    LEFT JOIN classes c ON s.class_id = c.id
    WHERE s.class_id = #{classId}
    </select>

    // Service 中手动查询总条数
    public PageInfo<Student> getByClassId(PageQuery pageQuery, int classId) {
    long total = studentMapper.selectByClassIdCount(classId); // 手动查总条数
    PageHelper.startPage(pageQuery.getPageNum(), pageQuery.getPageSize());
    List<Student> students = studentMapper.selectByClassId(classId);
    PageInfo<Student> pageInfo = new PageInfo<>(students);
    pageInfo.setTotal(total); // 手动设置总条数
    return pageInfo;
    }

3. 规避常见陷阱

  • 逻辑分页的 OOM 风险:生产环境绝对禁止使用 RowBounds,尤其是表数据量 > 1 万条的场景;
  • PageHelper 的线程安全问题PageHelper.startPage 基于 ThreadLocal,无需担心多线程问题,但需确保 “设置分页参数” 和 “执行查询” 在同一个线程;
  • 分页合理化配置:务必开启 reasonable: true,避免用户传入 pageNum=10000(远超总页数)导致返回空数据,影响用户体验;
  • SQL 注入风险:排序字段需校验(如前文 PageQuery 中的 matches 校验),避免用户传入 sortField="id; DROP TABLE student;" 导致注入。

总结:分页方案选择建议

场景 推荐方案 理由
本地测试、小数据量(< 1000 条) RowBounds 逻辑分页 无需配置,快速测试
生产环境、标准分页需求 PageHelper 插件 自动适配方言、分页合理化、低维护成本
特殊分页逻辑(如多表联查、自定义 COUNT) 自定义插件 灵活可控,适配特殊业务场景
分布式场景(分库分表) 分库分表中间件(如 Sharding-JDBC) 中间件自动处理跨库分页,避免手动拼接 SQL

MyBatis 分页的核心是 “优先选择物理分页,避免逻辑分页”,而 PageHelper 作为成熟插件,能覆盖 99% 的生产场景,建议优先使用;仅当存在特殊业务逻辑(如自定义分页语法、复杂总条数计算)时,才考虑自定义插件

欢迎关注我的其它发布渠道

表情 | 预览
快来做第一个评论的人吧~
Powered By Valine
v1.3.10