【大白话说Java面试题 第138题】【05_Mybatis篇】第8题:MyBatis 的分页原理是什么?

📌 PDF :大白话说Java面试题 --- 05_Mybatis篇

第8题:MyBatis 的分页原理是什么?

📚 回答:

  • 核心考点 : MyBatis 分页是 Java 后端面试的高频考点,面试官不会满足于"逻辑分页 vs 物理分页"的表面回答,而是深入考察 RowBounds 的源码级实现 (skipRows + shouldProcessMoreRows 在 ResultSet 上的内存截取)、PageHelper 拦截器的完整链路 (@Intercepts 注解 → Plugin.wrap 动态代理 → ThreadLocal 传参 → SQL 改写 → COUNT 查询 → 结果封装),以及 深分页场景下的性能陷阱 (LIMIT offset, size 的扫描行数线性增长、COUNT(*) 的代价)。面试官真正想判断的是:你是否理解 MyBatis 插件机制的本质,以及能否在生产环境中正确选型分页方案。
1. 逻辑分页(RowBounds)------内存截取的陷阱
  • 1.1 实现原理 MyBatis 内置的 RowBounds 是一种逻辑分页 (又称内存分页)。它的核心思想是:先查询全量数据到内存,再通过 Java 代码对 ResultSet 结果集进行截取,只保留指定范围的数据返回给客户端。

    源码层面,DefaultResultSetHandler.handleRowValuesForSimpleResultMap() 方法实现了这一逻辑:

    java 复制代码
    private void handleRowValuesForSimpleResultMap(ResultSetWrapper rsw, ResultMap resultMap,
        ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws SQLException {
        DefaultResultContext<Object> resultContext = new DefaultResultContext<>();
        // Step 1: 跳过 offset 条记录
        skipRows(rsw.getResultSet(), rowBounds);
        // Step 2: 只读取 limit 条记录
        while (shouldProcessMoreRows(resultContext, rowBounds) && rsw.getResultSet().next()) {
            Object rowValue = getRowValue(rsw, resolveDiscriminatedResultMap(...));
            storeObject(resultHandler, resultContext, rowValue, parentMapping, rsw.getResultSet());
        }
    }

    skipRows() 的实现根据 ResultSet 类型不同有两种策略:

    ResultSet 类型 跳过策略 性能影响
    TYPE_SCROLL_INSENSITIVE rs.absolute(offset) 直接定位 JDBC 驱动支持时较快
    TYPE_FORWARD_ONLY(默认) 循环调用 rs.next() 逐条跳过 O(offset) 时间复杂度,offset 越大越慢
  • 1.2 致命缺陷分析 RowBounds 的本质缺陷在于:数据库层面没有减少任何数据传输 。即使只需要 10 条数据,MySQL 仍然会将全表数据通过网络传输到 MyBatis,再由 MyBatis 在内存中丢弃前 offset 条。

    数据量 传输到应用的数据量 内存占用 风险
    1 万条 1 万条 较小 可接受
    100 万条 100 万条 极大 OOM 风险
    1000 万条 1000 万条 灾难级 必 OOM

    结论 :RowBounds 仅适用于数据量极小(< 1000 条)的配置表查询,生产环境严禁使用。citation:1

  • 1.3 代码示例与反模式

    java 复制代码
    // ❌ 错误:生产环境使用 RowBounds 会导致全表数据加载到内存
    RowBounds rowBounds = new RowBounds(0, 10);
    List<User> users = sqlSession.selectList("selectAllUsers", null, rowBounds);
    xml 复制代码
    <!-- Mapper XML 中没有任何分页语法 -->
    <select id="selectAllUsers" resultType="User">
        SELECT * FROM users  <!-- 全表扫描! -->
    </select>
2. 物理分页------SQL 层面的精准控制
  • 2.1 手写 LIMIT 分页 物理分页的核心是在 SQL 执行前 就限制返回的数据量,数据库只传输目标数据。

    xml 复制代码
    <select id="selectUsersByPage" resultType="User">
        SELECT * FROM users
        WHERE status = 1
        ORDER BY create_time DESC
        LIMIT #{offset}, #{limit}
    </select>
    java 复制代码
    Map<String, Object> params = new HashMap<>();
    params.put("offset", (pageNum - 1) * pageSize);
    params.put("limit", pageSize);
    List<User> users = sqlSession.selectList("selectUsersByPage", params);

    优势 :数据库只返回 limit 条数据,网络传输和内存占用最小化。

    劣势 :需要手动计算 offset、处理总条数查询、适配不同数据库方言(MySQL 用 LIMIT,Oracle 用 ROWNUM,SQL Server 用 OFFSET FETCH)。

  • 2.2 不同数据库的分页方言对比

    数据库 分页语法 示例
    MySQL LIMIT offset, size SELECT * FROM t LIMIT 10000, 10
    Oracle ROWNUM 嵌套 SELECT * FROM (SELECT ROWNUM rn, t.* FROM t WHERE ROWNUM <= 10010) WHERE rn > 10000
    SQL Server OFFSET ... FETCH SELECT * FROM t ORDER BY id OFFSET 10000 ROWS FETCH NEXT 10 ROWS ONLY
    PostgreSQL LIMIT ... OFFSET SELECT * FROM t LIMIT 10 OFFSET 10000

    手写物理分页的痛点:跨数据库迁移时代码需要大量修改,维护成本高。citation:0

3. PageHelper 分页插件------拦截器机制的工程实践
  • 3.1 MyBatis 插件机制底层 PageHelper 基于 MyBatis 的 Interceptor 插件机制 实现,其核心是 JDK 动态代理。

    MyBatis 允许拦截四大核心组件:Executor、StatementHandler、ParameterHandler、ResultSetHandler。PageHelper 选择拦截 StatementHandler 的 prepare 方法(SQL 语句生成阶段),在 SQL 执行前进行改写。citation:2

    插件注册与代理创建流程:

    java 复制代码
    // 1. 插件通过 @Intercepts 注解声明拦截目标
    @Intercepts({
        @Signature(type = StatementHandler.class, method = "prepare",
                   args = {Connection.class, Integer.class})
    })
    public class PageInterceptor implements Interceptor { ... }
    
    // 2. MyBatis 启动时,InterceptorChain 遍历所有插件,通过 Plugin.wrap 创建代理
    public Object pluginAll(Object target) {
        for (Interceptor interceptor : interceptors) {
            target = interceptor.plugin(target);  // 层层代理
        }
        return target;
    }
    
    // 3. Plugin.wrap 使用 JDK 动态代理
    public static Object wrap(Object target, Interceptor interceptor) {
        Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
        Class<?> type = target.getClass();
        Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
        if (interfaces.length > 0) {
            return Proxy.newProxyInstance(type.getClassLoader(), interfaces,
                new Plugin(target, interceptor, signatureMap));
        }
        return target;
    }
  • 3.2 PageHelper 的完整执行链路 PageHelper 的分页流程可以拆解为 6 个关键步骤:

    Step 1: ThreadLocal 存储分页参数

    java 复制代码
    // 用户代码:设置分页参数
    PageHelper.startPage(1, 10);

    内部通过 ThreadLocal<Page> 将分页参数绑定到当前线程,确保多线程安全:

    java 复制代码
    public static <E> Page<E> startPage(int pageNum, int pageSize) {
        Page<E> page = new Page<>(pageNum, pageSize);
        LOCAL_PAGE.set(page);  // ThreadLocal 绑定
        return page;
    }

    Step 2: 拦截器拦截 SQL 生成

    当执行 userMapper.selectAll() 时,MyBatis 会创建 RoutingStatementHandler,并通过 InterceptorChain.pluginAll() 包装成代理对象。代理对象的 prepare 方法被拦截,进入 PageInterceptor.intercept()。

    Step 3: 自动 COUNT 查询

    PageHelper 会自动将原始 SQL 改写为 COUNT 查询,获取总记录数:

    java 复制代码
    // 原始 SQL: SELECT * FROM users WHERE status = 1
    // 自动改写为: SELECT COUNT(1) FROM (SELECT * FROM users WHERE status = 1) temp_count
    String countSql = dialect.getCountSql(originalSql);

    COUNT 查询使用独立的 MappedStatement 对象缓存于 msCountMap 中,避免重复解析 SQL。citation:7

    Step 4: 分页 SQL 改写

    根据数据库方言,将原始 SQL 改写为分页 SQL:

    数据库 改写后的 SQL
    MySQL SELECT * FROM (原始SQL) temp_table LIMIT ?, ?
    Oracle SELECT * FROM (SELECT TEMP.*, ROWNUM RN FROM (原始SQL) TEMP WHERE ROWNUM <= ?) WHERE RN > ?

    Step 5: 执行分页查询

    改写后的 SQL 通过 invocation.proceed() 继续执行,MyBatis 正常处理参数绑定和结果映射。

    Step 6: 结果封装与 ThreadLocal 清理

    查询结果封装到 Page 对象(继承 ArrayList),并在 finally 中清理 ThreadLocal,防止内存泄漏:

    java 复制代码
    try {
        // 执行分页逻辑...
    } finally {
        if (dialect != null) {
            dialect.afterAll();  // 清理 ThreadLocal
        }
    }
  • 3.3 PageHelper 使用示例

    java 复制代码
    // 正确用法:startPage 必须紧跟查询方法
    PageHelper.startPage(1, 10);
    List<User> users = userMapper.selectAllUsers();
    PageInfo<User> pageInfo = new PageInfo<>(users);
    // pageInfo.getTotal()      // 总记录数
    // pageInfo.getPages()      // 总页数
    // pageInfo.getPageNum()    // 当前页码
    // pageInfo.getPageSize()   // 每页大小
  • 3.4 PageHelper 的 ThreadLocal 陷阱 PageHelper 使用 ThreadLocal 传递分页参数,如果未正确清理,会导致分页参数污染后续查询:

    java 复制代码
    // ❌ 错误:startPage 后未执行查询,或异常导致未清理
    PageHelper.startPage(1, 10);
    if (someCondition) {
        return;  // 直接返回,ThreadLocal 未清理!
    }
    List<User> users = userMapper.selectAll();  // 可能被意外分页

    解决方案 :使用 try-finally 或 Page 的 Closeable 接口:

    java 复制代码
    // ✅ 正确:try-with-resources 自动清理
    try (Page<User> page = PageHelper.startPage(1, 10)) {
        List<User> users = userMapper.selectAll();
        return new PageInfo<>(users);
    }

    PageHelper 在 finally 中调用 clearPage() 清理 ThreadLocal,但异常场景下可能失效,因此建议显式处理。citation:6

4. 三种分页方案深度对比
对比维度 RowBounds(逻辑分页) 手写 LIMIT(物理分页) PageHelper(插件物理分页)
实现位置 ResultSet 内存截取 SQL 层面 SQL 层面(自动改写)
数据库传输量 全量数据 仅分页数据 仅分页数据
内存占用 高(全量数据) 低 低
跨数据库适配 无关(纯 Java) 需手动适配方言 自动适配(内置方言)
总条数获取 无法获取 需手写 COUNT 自动 COUNT
开发成本 低 高(需处理 offset/count) 极低(一行代码)
适用场景 < 1000 条配置表 简单项目、单数据库 生产环境首选
生产推荐度 ⭐ 严禁使用 ⭐⭐⭐ 可用 ⭐⭐⭐⭐⭐ 强烈推荐
5. 生产环境避坑指南
  • 5.1 深分页性能灾难 即使使用 PageHelper 物理分页,LIMIT 1000000, 10 仍然是性能杀手。MySQL 需要扫描前 100 万条记录并丢弃,扫描行数随 offset 线性增长。

    优化方案:

    1. 游标分页 :用 WHERE id > last_id LIMIT 10 替代 LIMIT offset, 10;
    2. 延迟关联:先查 ID 再 JOIN 回表;
    3. 限制最大页码:产品层面限制用户最多翻到第 1000 页。
  • 5.2 COUNT(*) 的性能陷阱 PageHelper 自动 COUNT 查询在大数据量下可能成为瓶颈:

    java 复制代码
    // ❌ 错误:百万级数据 COUNT(*) 可能耗时数秒
    PageHelper.startPage(1, 10);
    List<User> users = userMapper.selectAll();  // 自动 COUNT 慢

    优化:

    1. 使用 PageHelper.startPage(pageNum, pageSize, false) 关闭自动 COUNT;
    2. 使用缓存(Redis)存储总条数;
    3. 使用估算值替代精确 COUNT(如 SHOW TABLE STATUS 的 Rows 字段)。
  • 5.3 分页与排序的联合索引要求 ORDER BY create_time DESC LIMIT offset, size 必须建立 (create_time) 索引,否则 MySQL 会全表扫描 + filesort,性能极差。

  • 5.4 多数据源场景下的方言配置 如果项目使用多数据源(MySQL + Oracle),必须显式指定方言:

    java 复制代码
    @Configuration
    public class PageHelperConfig {
        @Bean
        public PageHelper pageHelper() {
            PageHelper pageHelper = new PageHelper();
            Properties properties = new Properties();
            properties.setProperty("helperDialect", "mysql");  // 或 "oracle"
            pageHelper.setProperties(properties);
            return pageHelper;
        }
    }
  • 5.5 分页参数的安全校验 防止恶意传入超大 pageSize(如 100000)拖垮数据库:

    java 复制代码
    // 全局配置最大分页条数
    properties.setProperty("offset-as-page-num", "true");
    properties.setProperty("row-bounds-with-count", "true");
    properties.setProperty("page-size-zero", "true");
    properties.setProperty("reasonable", "true");  // 页码合理化,<1 自动设为 1
    properties.setProperty("support-methods-arguments", "true");
6. 面试官追问与高分回答模板
  • 追问 1:"MyBatis 的分页原理是什么?"

    低分回答:"分为逻辑分页和物理分页,逻辑分页用 RowBounds,物理分页用 LIMIT 或 PageHelper。"(过于表面,没有源码级理解)

    高分回答:

    "MyBatis 的分页分为三种实现:

    1. 逻辑分页(RowBounds) :MyBatis 内置,通过 DefaultResultSetHandler 在 ResultSet 上调用 skipRows() 跳过 offset 条,再用 shouldProcessMoreRows() 限制读取 limit 条。本质是先全量查询再内存截取,数据量大时必 OOM,生产环境严禁使用。
    2. 手写物理分页 :在 SQL 中写 LIMIT offset, size,数据库只返回目标数据。缺点是需手动处理 offset 计算、总条数 COUNT、跨数据库方言适配。
    3. PageHelper 插件 :基于 MyBatis 的 Interceptor 机制,通过 JDK 动态代理拦截 StatementHandler.prepare() 方法。核心流程是:ThreadLocal 存分页参数 → 拦截 SQL 生成 → 自动 COUNT 查询 → 方言适配改写 SQL → 执行分页查询 → finally 清理 ThreadLocal。开发成本最低,生产环境首选。"
  • 追问 2:"PageHelper 的底层原理是什么?它是怎么实现自动分页的?"

    低分回答:"通过拦截器改写 SQL。"(没有讲清楚链路)

    高分回答:

    "PageHelper 基于 MyBatis 的插件机制实现,核心链路如下:

    1. 参数传递 :PageHelper.startPage() 将分页参数存入 ThreadLocal<Page>,确保线程隔离;
    2. 动态代理 :MyBatis 创建 StatementHandler 时,通过 InterceptorChain.pluginAll() 和 Plugin.wrap() 生成 JDK 动态代理;
    3. SQL 拦截 :代理对象的 prepare 方法被 PageInterceptor 拦截,获取原始 SQL;
    4. 自动 COUNT :将原始 SQL 改写为 SELECT COUNT(1) FROM (原始SQL) temp_count,使用缓存的 MappedStatement 避免重复解析;
    5. 方言改写 :根据 helperDialect 配置,将原始 SQL 改写为带分页语法的 SQL(MySQL 加 LIMIT,Oracle 加 ROWNUM 嵌套);
    6. 结果封装 :查询结果封装到 Page 对象(继承 ArrayList),通过 PageInfo 提供总条数、总页数等元数据;
    7. 资源清理 :在 finally 中调用 clearPage() 清理 ThreadLocal,防止内存泄漏和参数污染。"
  • 追问 3:"RowBounds 和 LIMIT 有什么区别?为什么生产环境不能用 RowBounds?"

    低分回答:"RowBounds 是内存分页,LIMIT 是 SQL 分页。"(没有触及本质)

    高分回答:

    "两者的本质区别在于数据过滤发生的时机:

    • RowBounds :数据库返回全量数据到应用层,MyBatis 的 DefaultResultSetHandler 通过 skipRows() 和 shouldProcessMoreRows() 在 ResultSet 上逐条跳过和截取。这意味着网络传输了全量数据,JVM 内存中缓存了全量数据,offset 越大性能越差,数据量超过 JVM 堆内存时直接 OOM。
    • LIMIT :在数据库执行阶段就限制了返回结果集的大小,只传输目标数据到应用层 ,网络和内存开销最小。
      生产环境数据量通常百万级以上,RowBounds 的全量加载是灾难性的。即使只有 1 万条数据,RowBounds 也会浪费 90% 的网络带宽和内存(取前 10 条却传了 1 万条)。"
  • 追问 4:"PageHelper 的 ThreadLocal 有什么坑?怎么避免?"

    低分回答:"记得清理就行。"(太笼统)

    高分回答:

    "PageHelper 使用 ThreadLocal 传递分页参数,主要风险是参数污染:

    1. 异常中断 :如果 startPage() 后、查询前发生异常并直接返回,ThreadLocal 未被清理,后续同线程的查询会被意外分页;
    2. 嵌套查询 :一个线程内多次调用 startPage(),旧的 Page 对象被覆盖,可能导致分页参数错乱;
    3. 线程池复用 :Tomcat 等使用线程池,线程复用时如果 ThreadLocal 未清理,新请求会继承旧分页参数。
      解决方案:
    • 使用 try-with-resources(Page 实现了 Closeable):try (Page<User> page = PageHelper.startPage(1, 10)) { ... }
    • 在 finally 中显式调用 PageHelper.clearPage();
    • 避免在 startPage() 和查询之间插入任何可能提前返回的逻辑。"
  • 追问 5:"大数据量分页时,PageHelper 自动 COUNT 查询很慢,怎么优化?"

    低分回答:"加索引。"(没有触及 COUNT 优化的本质)

    高分回答:

    "大数据量下 COUNT(*) 可能成为性能瓶颈,优化思路分四层:

    1. 关闭自动 COUNT :PageHelper.startPage(pageNum, pageSize, false),在业务层用缓存维护总条数;
    2. 索引优化 :确保 COUNT 查询能走覆盖索引(如 COUNT(1) WHERE status = 1 需 (status) 索引);
    3. 估算替代精确 :对非精确分页场景(如瀑布流),使用 SHOW TABLE STATUS 的 Rows 字段或 Redis 缓存的估算值;
    4. 深分页优化 :如果必须精确 COUNT,考虑将 COUNT 查询和分页查询分离,COUNT 走从库或缓存,分页走主库。
      另外,从产品设计层面限制最大页码(如最多 1000 页),避免恶意深分页攻击。"
  • 追问 6:"如果让你手写一个 MyBatis 分页插件,核心思路是什么?"

    高分回答:

    "手写分页插件的核心思路是模仿 PageHelper 的拦截器模式:

    1. 实现 Interceptor 接口 :用 @Intercepts 注解声明拦截 StatementHandler.prepare() 方法;
    2. ThreadLocal 传参 :定义 Page 对象,通过 ThreadLocal 将分页参数绑定到当前线程;
    3. SQL 改写 :在 intercept() 中获取 BoundSql,根据数据库方言(MySQL/Oracle/SQL Server)拼接分页语法;
    4. 自动 COUNT:将原始 SQL 包装为 COUNT 查询,通过独立 Connection 执行,结果存入 Page 对象;
    5. 动态代理 :在 plugin() 方法中调用 Plugin.wrap(target, this) 生成 JDK 代理;
    6. 资源清理 :在 finally 中清理 ThreadLocal,防止内存泄漏。
      关键难点是处理不同数据库的分页方言、COUNT 查询的参数映射、以及 ThreadLocal 的线程安全问题。"
7. 方案选型速查表
业务场景 推荐方案 核心理由
配置表查询(< 1000 条) RowBounds 简单,数据量小无性能问题
生产环境常规分页 PageHelper 自动分页、自动 COUNT、跨库适配、开发成本低
大数据量深分页 手写游标分页 WHERE id > last_id LIMIT n,避免 OFFSET 扫描
需要精确总条数 + 高性能 PageHelper + 缓存 COUNT 关闭自动 COUNT,Redis 缓存总条数
多数据源混合环境 PageHelper + 显式方言配置 helperDialect 指定数据库类型
简单项目、单数据库 手写 LIMIT 无第三方依赖,可控性高

💡 面试官想要的满分总结:

MyBatis 分页的本质是控制数据返回的时机和范围。逻辑分页(RowBounds)在内存中截取,看似简单实则致命------它把全表数据拉到应用层再丢弃,是生产环境的性能毒药,只适用于极小数据量的配置表。物理分页在 SQL 层面限制数据量,是大数据场景的唯一选择。

PageHelper 是工程实践的最佳方案,它基于 MyBatis 的 Interceptor + JDK 动态代理 机制,通过 ThreadLocal 隐式传参,在 SQL 生成阶段自动改写为带方言的分页 SQL,并自动执行 COUNT 查询。理解 PageHelper 必须抓住三个核心:ThreadLocal 的参数传递 、StatementHandler 的拦截时机 、方言适配的 SQL 改写。

生产环境中,要警惕 ThreadLocal 污染 (必须用 try-finally 清理)、深分页性能陷阱 (限制最大页码或用游标分页)、COUNT 查询瓶颈 (大数据量时关闭自动 COUNT 走缓存)。分页不是简单的 LIMIT 语法,而是涉及网络传输、内存管理、SQL 优化、线程安全的综合工程问题。


觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯