📌 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()方法实现了这一逻辑:javaprivate 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_INSENSITIVErs.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>javaMap<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, sizeSELECT * FROM t LIMIT 10000, 10Oracle ROWNUM嵌套SELECT * FROM (SELECT ROWNUM rn, t.* FROM t WHERE ROWNUM <= 10010) WHERE rn > 10000SQL Server OFFSET ... FETCHSELECT * FROM t ORDER BY id OFFSET 10000 ROWS FETCH NEXT 10 ROWS ONLYPostgreSQL LIMIT ... OFFSETSELECT * 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>将分页参数绑定到当前线程,确保多线程安全:javapublic 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:7Step 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,防止内存泄漏:javatry { // 执行分页逻辑... } 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 线性增长。优化方案:
- 游标分页 :用
WHERE id > last_id LIMIT 10替代LIMIT offset, 10; - 延迟关联:先查 ID 再 JOIN 回表;
- 限制最大页码:产品层面限制用户最多翻到第 1000 页。
- 游标分页 :用
-
5.2 COUNT(*) 的性能陷阱 PageHelper 自动 COUNT 查询在大数据量下可能成为瓶颈:
java// ❌ 错误:百万级数据 COUNT(*) 可能耗时数秒 PageHelper.startPage(1, 10); List<User> users = userMapper.selectAll(); // 自动 COUNT 慢优化:
- 使用
PageHelper.startPage(pageNum, pageSize, false)关闭自动 COUNT; - 使用缓存(Redis)存储总条数;
- 使用估算值替代精确 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 的分页分为三种实现:
- 逻辑分页(RowBounds) :MyBatis 内置,通过
DefaultResultSetHandler在 ResultSet 上调用skipRows()跳过 offset 条,再用shouldProcessMoreRows()限制读取 limit 条。本质是先全量查询再内存截取,数据量大时必 OOM,生产环境严禁使用。 - 手写物理分页 :在 SQL 中写
LIMIT offset, size,数据库只返回目标数据。缺点是需手动处理 offset 计算、总条数 COUNT、跨数据库方言适配。 - PageHelper 插件 :基于 MyBatis 的 Interceptor 机制,通过 JDK 动态代理拦截
StatementHandler.prepare()方法。核心流程是:ThreadLocal 存分页参数 → 拦截 SQL 生成 → 自动 COUNT 查询 → 方言适配改写 SQL → 执行分页查询 → finally 清理 ThreadLocal。开发成本最低,生产环境首选。"
- 逻辑分页(RowBounds) :MyBatis 内置,通过
-
追问 2:"PageHelper 的底层原理是什么?它是怎么实现自动分页的?"
低分回答:"通过拦截器改写 SQL。"(没有讲清楚链路)
高分回答:
"PageHelper 基于 MyBatis 的插件机制实现,核心链路如下:
- 参数传递 :
PageHelper.startPage()将分页参数存入ThreadLocal<Page>,确保线程隔离; - 动态代理 :MyBatis 创建
StatementHandler时,通过InterceptorChain.pluginAll()和Plugin.wrap()生成 JDK 动态代理; - SQL 拦截 :代理对象的
prepare方法被PageInterceptor拦截,获取原始 SQL; - 自动 COUNT :将原始 SQL 改写为
SELECT COUNT(1) FROM (原始SQL) temp_count,使用缓存的MappedStatement避免重复解析; - 方言改写 :根据
helperDialect配置,将原始 SQL 改写为带分页语法的 SQL(MySQL 加 LIMIT,Oracle 加 ROWNUM 嵌套); - 结果封装 :查询结果封装到
Page对象(继承 ArrayList),通过PageInfo提供总条数、总页数等元数据; - 资源清理 :在
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 万条)。"
- RowBounds :数据库返回全量数据到应用层,MyBatis 的
-
追问 4:"PageHelper 的 ThreadLocal 有什么坑?怎么避免?"
低分回答:"记得清理就行。"(太笼统)
高分回答:
"PageHelper 使用 ThreadLocal 传递分页参数,主要风险是参数污染:
- 异常中断 :如果
startPage()后、查询前发生异常并直接返回,ThreadLocal 未被清理,后续同线程的查询会被意外分页; - 嵌套查询 :一个线程内多次调用
startPage(),旧的 Page 对象被覆盖,可能导致分页参数错乱; - 线程池复用 :Tomcat 等使用线程池,线程复用时如果 ThreadLocal 未清理,新请求会继承旧分页参数。
解决方案:
- 使用
try-with-resources(Page 实现了 Closeable):try (Page<User> page = PageHelper.startPage(1, 10)) { ... } - 在
finally中显式调用PageHelper.clearPage(); - 避免在
startPage()和查询之间插入任何可能提前返回的逻辑。"
- 异常中断 :如果
-
追问 5:"大数据量分页时,PageHelper 自动 COUNT 查询很慢,怎么优化?"
低分回答:"加索引。"(没有触及 COUNT 优化的本质)
高分回答:
"大数据量下 COUNT(*) 可能成为性能瓶颈,优化思路分四层:
- 关闭自动 COUNT :
PageHelper.startPage(pageNum, pageSize, false),在业务层用缓存维护总条数; - 索引优化 :确保 COUNT 查询能走覆盖索引(如
COUNT(1) WHERE status = 1需(status)索引); - 估算替代精确 :对非精确分页场景(如瀑布流),使用
SHOW TABLE STATUS的Rows字段或 Redis 缓存的估算值; - 深分页优化 :如果必须精确 COUNT,考虑将 COUNT 查询和分页查询分离,COUNT 走从库或缓存,分页走主库。
另外,从产品设计层面限制最大页码(如最多 1000 页),避免恶意深分页攻击。"
- 关闭自动 COUNT :
-
追问 6:"如果让你手写一个 MyBatis 分页插件,核心思路是什么?"
高分回答:
"手写分页插件的核心思路是模仿 PageHelper 的拦截器模式:
- 实现 Interceptor 接口 :用
@Intercepts注解声明拦截StatementHandler.prepare()方法; - ThreadLocal 传参 :定义
Page对象,通过 ThreadLocal 将分页参数绑定到当前线程; - SQL 改写 :在
intercept()中获取BoundSql,根据数据库方言(MySQL/Oracle/SQL Server)拼接分页语法; - 自动 COUNT:将原始 SQL 包装为 COUNT 查询,通过独立 Connection 执行,结果存入 Page 对象;
- 动态代理 :在
plugin()方法中调用Plugin.wrap(target, this)生成 JDK 代理; - 资源清理 :在
finally中清理 ThreadLocal,防止内存泄漏。
关键难点是处理不同数据库的分页方言、COUNT 查询的参数映射、以及 ThreadLocal 的线程安全问题。"
- 实现 Interceptor 接口 :用
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 优化、线程安全的综合工程问题。
觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯