【大白话说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 允许拦截四大核心组件:ExecutorStatementHandlerParameterHandlerResultSetHandler。PageHelper 选择拦截 StatementHandlerprepare 方法(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-finallyPageCloseable 接口:

    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 STATUSRows 字段)。
  • 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. 关闭自动 COUNTPageHelper.startPage(pageNum, pageSize, false),在业务层用缓存维护总条数;
    2. 索引优化 :确保 COUNT 查询能走覆盖索引(如 COUNT(1) WHERE status = 1(status) 索引);
    3. 估算替代精确 :对非精确分页场景(如瀑布流),使用 SHOW TABLE STATUSRows 字段或 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 优化、线程安全的综合工程问题。


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