【大白话说Java面试题 第138题】【05_Mybatis篇】第8题:MyBatis 的分页原理是什么? PDF大白话说Java面试题 — 05_Mybatis篇第8题MyBatis 的分页原理是什么回答核心考点 MyBatis 分页是 Java 后端面试的高频考点面试官不会满足于逻辑分页 vs 物理分页的表面回答而是深入考察RowBounds 的源码级实现skipRowsshouldProcessMoreRows在 ResultSet 上的内存截取、PageHelper 拦截器的完整链路Intercepts注解 →Plugin.wrap动态代理 →ThreadLocal传参 → SQL 改写 → COUNT 查询 → 结果封装以及深分页场景下的性能陷阱LIMIT offset, size的扫描行数线性增长、COUNT(*) 的代价。面试官真正想判断的是你是否理解 MyBatis 插件机制的本质以及能否在生产环境中正确选型分页方案。1. 逻辑分页RowBounds——内存截取的陷阱1.1 实现原理MyBatis 内置的RowBounds是一种逻辑分页又称内存分页。它的核心思想是先查询全量数据到内存再通过 Java 代码对 ResultSet 结果集进行截取只保留指定范围的数据返回给客户端。源码层面DefaultResultSetHandler.handleRowValuesForSimpleResultMap()方法实现了这一逻辑privatevoidhandleRowValuesForSimpleResultMap(ResultSetWrapperrsw,ResultMapresultMap,ResultHandler?resultHandler,RowBoundsrowBounds,ResultMappingparentMapping)throwsSQLException{DefaultResultContextObjectresultContextnewDefaultResultContext();// Step 1: 跳过 offset 条记录skipRows(rsw.getResultSet(),rowBounds);// Step 2: 只读取 limit 条记录while(shouldProcessMoreRows(resultContext,rowBounds)rsw.getResultSet().next()){ObjectrowValuegetRowValue(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 代码示例与反模式// ❌ 错误生产环境使用 RowBounds 会导致全表数据加载到内存RowBoundsrowBoundsnewRowBounds(0,10);ListUseruserssqlSession.selectList(selectAllUsers,null,rowBounds);!-- Mapper XML 中没有任何分页语法 --selectidselectAllUsersresultTypeUserSELECT * FROM users!-- 全表扫描 --/select2. 物理分页——SQL 层面的精准控制2.1 手写 LIMIT 分页物理分页的核心是在SQL 执行前就限制返回的数据量数据库只传输目标数据。selectidselectUsersByPageresultTypeUserSELECT * FROM users WHERE status 1 ORDER BY create_time DESC LIMIT #{offset}, #{limit}/selectMapString,ObjectparamsnewHashMap();params.put(offset,(pageNum-1)*pageSize);params.put(limit,pageSize);ListUseruserssqlSession.selectList(selectUsersByPage,params);优势数据库只返回limit条数据网络传输和内存占用最小化。劣势需要手动计算 offset、处理总条数查询、适配不同数据库方言MySQL 用LIMITOracle 用ROWNUMSQL Server 用OFFSET FETCH。2.2 不同数据库的分页方言对比数据库分页语法示例MySQLLIMIT offset, sizeSELECT * FROM t LIMIT 10000, 10OracleROWNUM嵌套SELECT * FROM (SELECT ROWNUM rn, t.* FROM t WHERE ROWNUM 10010) WHERE rn 10000SQL ServerOFFSET ... FETCHSELECT * FROM t ORDER BY id OFFSET 10000 ROWS FETCH NEXT 10 ROWS ONLYPostgreSQLLIMIT ... 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]插件注册与代理创建流程// 1. 插件通过 Intercepts 注解声明拦截目标Intercepts({Signature(typeStatementHandler.class,methodprepare,args{Connection.class,Integer.class})})publicclassPageInterceptorimplementsInterceptor{...}// 2. MyBatis 启动时InterceptorChain 遍历所有插件通过 Plugin.wrap 创建代理publicObjectpluginAll(Objecttarget){for(Interceptorinterceptor:interceptors){targetinterceptor.plugin(target);// 层层代理}returntarget;}// 3. Plugin.wrap 使用 JDK 动态代理publicstaticObjectwrap(Objecttarget,Interceptorinterceptor){MapClass?,SetMethodsignatureMapgetSignatureMap(interceptor);Class?typetarget.getClass();Class?[]interfacesgetAllInterfaces(type,signatureMap);if(interfaces.length0){returnProxy.newProxyInstance(type.getClassLoader(),interfaces,newPlugin(target,interceptor,signatureMap));}returntarget;}3.2 PageHelper 的完整执行链路PageHelper 的分页流程可以拆解为6 个关键步骤Step 1: ThreadLocal 存储分页参数// 用户代码设置分页参数PageHelper.startPage(1,10);内部通过ThreadLocalPage将分页参数绑定到当前线程确保多线程安全publicstaticEPageEstartPage(intpageNum,intpageSize){PageEpagenewPage(pageNum,pageSize);LOCAL_PAGE.set(page);// ThreadLocal 绑定returnpage;}Step 2: 拦截器拦截 SQL 生成当执行userMapper.selectAll()时MyBatis 会创建RoutingStatementHandler并通过InterceptorChain.pluginAll()包装成代理对象。代理对象的prepare方法被拦截进入PageInterceptor.intercept()。Step 3: 自动 COUNT 查询PageHelper 会自动将原始 SQL 改写为 COUNT 查询获取总记录数// 原始 SQL: SELECT * FROM users WHERE status 1// 自动改写为: SELECT COUNT(1) FROM (SELECT * FROM users WHERE status 1) temp_countStringcountSqldialect.getCountSql(originalSql);COUNT 查询使用独立的MappedStatement对象缓存于msCountMap中避免重复解析 SQL。[citation:7]Step 4: 分页 SQL 改写根据数据库方言将原始 SQL 改写为分页 SQL数据库改写后的 SQLMySQLSELECT * FROM (原始SQL) temp_table LIMIT ?, ?OracleSELECT * 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防止内存泄漏try{// 执行分页逻辑...}finally{if(dialect!null){dialect.afterAll();// 清理 ThreadLocal}}3.3 PageHelper 使用示例// 正确用法startPage 必须紧跟查询方法PageHelper.startPage(1,10);ListUserusersuserMapper.selectAllUsers();PageInfoUserpageInfonewPageInfo(users);// pageInfo.getTotal() // 总记录数// pageInfo.getPages() // 总页数// pageInfo.getPageNum() // 当前页码// pageInfo.getPageSize() // 每页大小3.4 PageHelper 的 ThreadLocal 陷阱PageHelper 使用ThreadLocal传递分页参数如果未正确清理会导致分页参数污染后续查询// ❌ 错误startPage 后未执行查询或异常导致未清理PageHelper.startPage(1,10);if(someCondition){return;// 直接返回ThreadLocal 未清理}ListUserusersuserMapper.selectAll();// 可能被意外分页解决方案使用try-finally或Page的Closeable接口// ✅ 正确try-with-resources 自动清理try(PageUserpagePageHelper.startPage(1,10)){ListUserusersuserMapper.selectAll();returnnewPageInfo(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 查询在大数据量下可能成为瓶颈// ❌ 错误百万级数据 COUNT(*) 可能耗时数秒PageHelper.startPage(1,10);ListUserusersuserMapper.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必须显式指定方言ConfigurationpublicclassPageHelperConfig{BeanpublicPageHelperpageHelper(){PageHelperpageHelpernewPageHelper();PropertiespropertiesnewProperties();properties.setProperty(helperDialect,mysql);// 或 oraclepageHelper.setProperties(properties);returnpageHelper;}}5.5 分页参数的安全校验防止恶意传入超大pageSize如 100000拖垮数据库// 全局配置最大分页条数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 自动设为 1properties.setProperty(support-methods-arguments,true);6. 面试官追问与高分回答模板追问 1“MyBatis 的分页原理是什么”低分回答“分为逻辑分页和物理分页逻辑分页用 RowBounds物理分页用 LIMIT 或 PageHelper。”过于表面没有源码级理解高分回答MyBatis 的分页分为三种实现逻辑分页RowBoundsMyBatis 内置通过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。开发成本最低生产环境首选。追问 2“PageHelper 的底层原理是什么它是怎么实现自动分页的”低分回答“通过拦截器改写 SQL。”没有讲清楚链路高分回答PageHelper 基于 MyBatis 的插件机制实现核心链路如下参数传递PageHelper.startPage()将分页参数存入ThreadLocalPage确保线程隔离动态代理MyBatis 创建StatementHandler时通过InterceptorChain.pluginAll()和Plugin.wrap()生成 JDK 动态代理SQL 拦截代理对象的prepare方法被PageInterceptor拦截获取原始 SQL自动 COUNT将原始 SQL 改写为SELECT COUNT(1) FROM (原始SQL) temp_count使用缓存的MappedStatement避免重复解析方言改写根据helperDialect配置将原始 SQL 改写为带分页语法的 SQLMySQL 加 LIMITOracle 加 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 万条。追问 4“PageHelper 的 ThreadLocal 有什么坑怎么避免”低分回答“记得清理就行。”太笼统高分回答PageHelper 使用 ThreadLocal 传递分页参数主要风险是参数污染异常中断如果startPage()后、查询前发生异常并直接返回ThreadLocal 未被清理后续同线程的查询会被意外分页嵌套查询一个线程内多次调用startPage()旧的 Page 对象被覆盖可能导致分页参数错乱线程池复用Tomcat 等使用线程池线程复用时如果 ThreadLocal 未清理新请求会继承旧分页参数。解决方案使用try-with-resourcesPage 实现了 Closeabletry (PageUser page PageHelper.startPage(1, 10)) { ... }在finally中显式调用PageHelper.clearPage()避免在startPage()和查询之间插入任何可能提前返回的逻辑。追问 5“大数据量分页时PageHelper 自动 COUNT 查询很慢怎么优化”低分回答“加索引。”没有触及 COUNT 优化的本质高分回答大数据量下 COUNT(*) 可能成为性能瓶颈优化思路分四层关闭自动 COUNTPageHelper.startPage(pageNum, pageSize, false)在业务层用缓存维护总条数索引优化确保 COUNT 查询能走覆盖索引如COUNT(1) WHERE status 1需(status)索引估算替代精确对非精确分页场景如瀑布流使用SHOW TABLE STATUS的Rows字段或 Redis 缓存的估算值深分页优化如果必须精确 COUNT考虑将 COUNT 查询和分页查询分离COUNT 走从库或缓存分页走主库。另外从产品设计层面限制最大页码如最多 1000 页避免恶意深分页攻击。追问 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 的线程安全问题。7. 方案选型速查表业务场景推荐方案核心理由配置表查询 1000 条RowBounds简单数据量小无性能问题生产环境常规分页PageHelper自动分页、自动 COUNT、跨库适配、开发成本低大数据量深分页手写游标分页WHERE id last_id LIMIT n避免 OFFSET 扫描需要精确总条数 高性能PageHelper 缓存 COUNT关闭自动 COUNTRedis 缓存总条数多数据源混合环境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 优化、线程安全的综合工程问题。觉得对您有帮助麻烦点点关注啦您的关注是我创作的最大动力~