⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 http://www.iocoder.cn/Fight/mianshiguanMyBatis-shiruhejinxingfenyedefenyechajiandeyuanlishishenme/ 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

这道题面试官真正想筛什么

这题表面看是问「MyBatis 怎么分页」,字面回答 5 个字就讲完——PageHelper + LIMIT。但面试官真正想筛的是另外三个能力:

  • 能不能区分逻辑分页和物理分页——这道分水岭过不去,候选人大概率写过 RowBounds 还以为没问题;
  • 能不能讲清 PageHelper 拦截器机制 + SQL 改写 + ThreadLocal 三件套——背得出”基于 Interceptor”只算入门,能完整画出执行链路才是底子;
  • 有没有踩过 count 慢、深度分页崩、ThreadLocal 泄漏这些线上坑——光会用工具不算门槛,踩过坑能讲出修法才是 90 分线。

按这三档,下面把答案分成 30 / 60 / 90 分——可以直接拿去自我评估。

30 分答案:能说出物理 vs 逻辑分页

如果只能用 30 秒回答,就讲一件事:MyBatis 分页有两条路——逻辑分页(内存里截)和物理分页(数据库直接限)。生产环境只用物理分页。

分页方式 数据在哪截断 性能 生产能用吗
逻辑分页(RowBounds) 应用内存里截 ⭐ 灾难级 ❌ 绝对不能
物理分页(手写 LIMIT) 数据库返回时截 ⭐⭐⭐ 稳 ⚠️ 能用,但繁琐
物理分页(PageHelper) 数据库返回时截 ⭐⭐⭐ 稳 ✅ 主流
物理分页(MyBatis-Plus) 数据库返回时截 ⭐⭐⭐ 稳 ✅ 主流

一句话兜底:生产用物理分页,底层是 MyBatis 的 Interceptor 拦截器机制 + SQL 自动改写——这就是 30 分线。

RowBounds 为什么不能用:MyBatis 原生 RowBounds 是逻辑分页——100 万条数据要查第 3 页 10 条,它会先把 100 万条全捞到 JVM 内存再 skip(20).take(10)——分分钟 OOM。生产环境碰都不要碰。

一个例外:项目装了 PageHelper 之后,插件检测到 RowBounds 参数会自动改写成物理分页 SQL——这种用法是安全的。但别赖这个”补救”——直接用 PageHelper.startPage() 语义更清晰、出错更少。

60 分答案:讲清 PageHelper 拦截 + 改写 + ThreadLocal 三板斧

到了 60 分线,不能再停留在”用 PageHelper”——要把三个核心机制串起来讲清。

机制 1:ThreadLocal 传参

PageHelper.startPage(3, 10);  // 第 3 页,每页 10 条
List<User> users = userMapper.selectAll(); // 看似普通查询,自动分页

写两行代码——这两行之间为什么能传参?答案是 ThreadLocal:startPage() 把分页参数(Page 对象)塞进当前线程的 ThreadLocal,下一次查询消费完立即清掉。

这就是为什么 startPage() 只对紧跟其后第一次查询生效——拿了就丢,不会污染后面的查询。

机制 2:MyBatis Interceptor 拦截 SQL

PageHelper 的核心是 PageInterceptor,通过 @Intercepts + @Signature 注解拦截 Executor.query() 方法。

@Intercepts({
@Signature(type = Executor.class, method = "query", args = ...)
})
public class PageInterceptor implements Interceptor {
public Object intercept(Invocation invocation) throws Throwable {
// 从 ThreadLocal 拿到分页参数
// 改写 SQL,加 LIMIT
// 单独跑 COUNT 查询
// 返回 Page 对象
}
}

核心思路:MyBatis 给了一套官方的插件机制(拦截 Executor / StatementHandler / ParameterHandler / ResultSetHandler 四个对象的方法),PageHelper 就基于这个机制在 SQL 真正执行前介入改写。

机制 3:JSqlParser 自动改写 SQL

拦到查询后要做两件事:主 SQL 加 LIMIT、单独跑 COUNT 查询。

PageHelper 用 JSqlParser 把 SQL 解析成 AST(抽象语法树):

// 简化版逻辑
Statement stmt = CCJSqlParserUtil.parse(originalSql);
Select select = (Select) stmt;

Limit limitObj = new Limit();
limitObj.setOffset(offset);
limitObj.setRowCount(limit);
select.getSelectBody().setLimit(limitObj);

return stmt.toString();

多数据库方言适配也在这一层做:MySQL 拼 LIMIT、Oracle 用 ROWNUM、SQL Server 用 TOP、PostgreSQL 用 LIMIT OFFSET——同一份代码跑多种数据库,这就是它生态广的原因。

COUNT 改写有讲究:

-- 原始 SQL
SELECT u.id, u.username, d.dept_name
FROM t_user u LEFT JOIN t_dept d ON u.dept_id = d.id
WHERE u.status = 1
ORDER BY u.created_time DESC

-- 改写后的 COUNT SQL
SELECT COUNT(0) FROM t_user u WHERE u.status = 1

自动剥离的 4 类:① SELECT 列表(只要数量,列不重要)② LEFT JOIN(不影响行数时)③ ORDER BY(同样不影响行数)④ LIMIT(不能加,否则数错)。

讲清这三块就到 60 分线——拦截切入点 + ThreadLocal 传参 + JSqlParser 改写。

90 分答案:讲透 5 个关键源码细节 + 深度分页

到 90 分线,要能讲出 PageHelper 在生产里容易翻车的 5 个细节——下面按踩到概率从高到低排:

细节 1:startPage() 后必须紧跟查询(最常见)

PageHelper.startPage(3, 10);
List<User> count = userMapper.countByStatus(); // ❌ 这次查询会被分页
List<User> users = userMapper.selectAll(); // 这次反而不会

翻车点:startPage() 设的分页参数被第一次任何 SQL 查询消费掉——不管你想不想分页。

修法:① startPage() 必须紧贴目标查询,中间不能塞别的查询;② 复杂场景用 PageHelper.offsetPage() + try-finally clearPage() 显式控制。

细节 2:ThreadLocal 不清理 = 内存泄漏(常见)

异常分支没消费分页参数 → 参数一直留在 ThreadLocal → 线程被池复用 → 后续查询被错误分页 / 内存泄漏。

修法:PageHelper 在 finally 块里调 SqlUtil.clearLocalPage() 主动清——但业务代码也要兜底:

try {
PageHelper.startPage(3, 10);
return userMapper.selectAll();
} finally {
PageHelper.clearPage(); // 兜底清理
}

细节 3:count 查询慢成事故(常见)

SELECT COUNT(*) 在大表上是真慢——尤其有复杂 JOIN + WHERE。一次列表加载 200ms,count 占了 180ms 的事不少见。

修法 4 种,按场景挑:

场景 修法
列表只展示前 N 页 关 count:PageHelper.startPage(3, 10, false),前端不展示总数
大表 + 没过滤条件 用 缓存 count——5 秒级别延迟可以接受,count 走 Redis
复杂 JOIN 手写 count SQL:@SelectProvider 或 xml 里专门写一个 selectByPageCount
倾向 App 流式列表 改成游标分页——直接不要 count

细节 4:深度分页雪崩(少见但破坏力大)

SELECT * FROM t_order ORDER BY id DESC LIMIT 10 OFFSET 999990;
-- MySQL 实际扫描 100 万行,丢弃 99.999 万行,只为返回最后 10 行

生产事故案例:管理后台允许点”末页”——某用户点了第 10 万页,整库 IO 飙满、服务雪崩。

修法 3 种,按推荐度排:

方案 原理 适用场景
游标分页(强推) WHERE id > last_id LIMIT 10 App / H5 流式列表、无限滚动
延迟关联 子查询先在索引上定位 id,再回表 后台管理传统分页
限制最大页码 第 1000 页之后只能搜索 工程兜底,配合上面两个

细节 5:MyBatis 插件链顺序坑(高级场景)

如果项目里同时用 PageHelper + Sharding-JDBC + 自定义日志拦截器——插件链顺序错了会出莫名 bug。

Executor 被多个 Interceptor 包成洋葱皮,配置顺序就是包裹顺序,最后注册的最先执行。PageHelper 应该在分库分表插件之前——先改写分页,再让分库分表去拆分。

这种细节背不出来没事——但能讲出来直接打动面试官。

直接掉分的几种答法

  • ❌ “MyBatis 默认就有分页” → 自动 -30 分。MyBatis 原生只给了 RowBounds(逻辑分页,生产不能用),物理分页全靠插件。
  • ❌ 答完 PageHelper 就停了 → 顶多 40 分。没说底层 Interceptor + 改写机制 = 没讲到原理。
  • ❌ “PageHelper 自动识别分页查询” → 直接拒。它必须在查询前显式 startPage(),没调就是普通查询。
  • ❌ “ThreadLocal 不会泄漏,PageHelper 自己处理” → 半对半错。它在 finally 里清了,但异常分支 / 不同业务路径还是要兜底,光说”不会”会被追问。
  • ❌ “深度分页加索引就行” → 没踩过坑的典型答法。OFFSET 大了,索引也救不了——真正修法是游标分页或延迟关联。

高频追问怎么接

追问 1:MyBatis-Plus 分页插件和 PageHelper 啥区别?

原理一样——都是 Interceptor 拦截 + SQL 改写。差异在 API 形态:

  • PageHelper:startPage() + ThreadLocal 隐式传参,易错但兼容性好(不用改 Mapper 签名);
  • MyBatis-Plus:传 IPage 入参,更显式、不容易出错,但强绑定 MP 框架。

建议:新项目用 MyBatis-Plus——参数显式更安全;老项目用 PageHelper 不要硬迁。

追问 2:拦截器机制本质是什么?

MyBatis 用了责任链 + 动态代理:插件在 Configuration 里注册时,会用 Plugin.wrap() 把目标对象(Executor / StatementHandler 等)包成 JDK 动态代理。调用方法时先经过你的 intercept() 方法,你决定要不要继续 invocation.proceed()。

追问 3:分库分表场景下分页怎么做?

简单 LIMIT 在分库分表下是伪分页——查 5 页 50 条,假如分了 4 个库,要每个库都查 50 条,应用层归并排序后才取真正的 50 条。

主流方案:① 用 ShardingSphere 等中间件托管归并;② 改成游标分页避开 OFFSET;③ 不分库分表(参考这篇说的——很多场景其实不用分库分表,分区表 + ES 就能搞定)。

追问 4:PageHelper 怎么保证只对第一条查询生效?

startPage() 把分页参数塞进 ThreadLocal,PageInterceptor 在 intercept() 里先消费分页参数、再走完查询链路、最后在 finally 块统一清掉 ThreadLocal。后续查询拿不到参数,自然就是普通查询。

关键:只要 startPage() 后紧跟一次 MyBatis 查询,ThreadLocal 一定会被 finally 清干净——这是 PageHelper 的安全保障;真正会导致泄漏的是”调了 startPage 但没紧跟查询”的代码路径——参数留在 ThreadLocal 里、被池化线程下次拿到——所以业务代码再加一层 try-finally PageHelper.clearPage() 兜底是稳健做法。

我的判断

这道题答到 30 分容易,答到 60 分要会原理,答到 90 分靠生产里踩过坑。

面试时的 3 步走法:

  1. 先讲分水岭——逻辑 vs 物理分页,直接亮 RowBounds 不能用的结论;
  2. 再讲三板斧——Interceptor 拦截 + JSqlParser 改写 + ThreadLocal 传参,画出执行链路;
  3. 最后亮坑——count 慢 / 深度分页雪崩 / ThreadLocal 泄漏,任选 1-2 个讲清楚修法——这就是 90 分线。

就一句话:分页问题的本质不是”加 LIMIT”——是”在哪一层、用什么机制、传什么参数、改什么 SQL”——理解这条链,所有分页框架都通了。

文章目录
  1. 1. 这道题面试官真正想筛什么
  2. 2. 30 分答案:能说出物理 vs 逻辑分页
  3. 3. 60 分答案:讲清 PageHelper 拦截 + 改写 + ThreadLocal 三板斧
    1. 3.0.1. 机制 1:ThreadLocal 传参
    2. 3.0.2. 机制 2:MyBatis Interceptor 拦截 SQL
    3. 3.0.3. 机制 3:JSqlParser 自动改写 SQL
  • 4. 90 分答案:讲透 5 个关键源码细节 + 深度分页
    1. 4.0.1. 细节 1:startPage() 后必须紧跟查询(最常见)
    2. 4.0.2. 细节 2:ThreadLocal 不清理 = 内存泄漏(常见)
    3. 4.0.3. 细节 3:count 查询慢成事故(常见)
    4. 4.0.4. 细节 4:深度分页雪崩(少见但破坏力大)
    5. 4.0.5. 细节 5:MyBatis 插件链顺序坑(高级场景)
  • 5. 直接掉分的几种答法
  • 6. 高频追问怎么接
    1. 6.0.1. 追问 1:MyBatis-Plus 分页插件和 PageHelper 啥区别?
    2. 6.0.2. 追问 2:拦截器机制本质是什么?
    3. 6.0.3. 追问 3:分库分表场景下分页怎么做?
    4. 6.0.4. 追问 4:PageHelper 怎么保证只对第一条查询生效?
  • 7. 我的判断