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 接口
// 方法参数中添加 RowBounds(无需 @Param,MyBatis 自动识别)
List<Student> selectByClassId(@Param("classId") int classId, RowBounds rowBounds);
(2)Mapper XML
<select id="selectByClassId" resultType="Student">
    SELECT id, name FROM student WHERE class_id = #{classId}
</select>
(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)代码逐行解析
@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 配置中注册插件,确保拦截器生效:

<!-- 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)核心代码解析(总条数查询部分)
// 拦截 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)引入依赖
<!-- 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)
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 静态方法(最常用)
@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 对象)
// 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:分页 + 排序
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,建议封装为分页参数类:

/**
 * 统一分页参数类
 */
@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();
    }
}

使用示例:

// 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(*)开销:

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

    <!-- 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% 的生产场景,建议优先使用;仅当存在特殊业务逻辑(如自定义分页语法、复杂总条数计算)时,才考虑自定义插件