Mybatis17- 分页原理

发布时间:2026/7/24 15:45:12
Mybatis17- 分页原理 一、分页原理第一步没有分页时系统会怎么死假设你有一张用户表初期只有100条数据ListUser list session.selectList(selectAll);这没问题100条数据内存轻松装下。但业务增长了表里有100万条数据。此时selectAll会发生什么数据库端全表扫描磁盘IO爆炸数据库连接长时间被占用网络层100万条数据在网络上传输带宽被打满应用内存JVM堆内存被撑爆直接OutOfMemoryError用户体验页面卡死请求超时所以分页不是优化项而是大数据场景下的生存项。第二步MyBatis 原生提供的 RowBounds逻辑分页MyBatis 内置了一个RowBounds类试图解决这个问题public class RowBounds { private int offset; private int limit; }// 查询第1页每页10条 RowBounds rowBounds new RowBounds(0, 10); ListUser list session.selectList(selectAll, null, rowBounds);它底层是怎么工作的RowBounds只有两个属性offset跳过多少行和limit取多少行。当这个参数被传到BaseExecutor后MyBatis 会把它一路带到ResultSetHandler。在解析ResultSet时// 伪代码在 DefaultResultSetHandler 中 while (rs.next()) { // 先跳过 offset 行 if (currentRow rowBounds.getOffset()) { currentRow; continue; } // 再取 limit 行 if (currentRow rowBounds.getOffset() rowBounds.getLimit()) { break; } // 映射成对象 Object row mapRow(rs); resultList.add(row); currentRow; }关键问题SQL 本身没有被修改数据库执行的仍然是SELECT * FROM user数据库返回了全部100万条数据到内存MyBatis 只是在内存里帮你跳过了前offset行只把后面的limit行封装成对象。这就是逻辑分页——在内存中做筛选。弊端治标不治本数据库仍然全表扫描网络仍然传输了100万条数据JVM 仍然加载了100万条结果集只是最后封装成的 List 只有10个元素对于大数据量逻辑分页等于没有分页。第三步为什么 MyBatis 不直接做物理分页物理分页的意思是修改 SQL 本身让数据库只返回10条数据。比如 MySQLSELECT * FROM user LIMIT 0, 10OracleSELECT * FROM ( SELECT t.*, ROWNUM rn FROM user t WHERE ROWNUM 10 ) WHERE rn 0SQL ServerSELECT TOP 10 * FROM user问题来了不同数据库的分页 SQL 语法完全不同。MyBatis 是一个通用 ORM 框架它不知道你用的是 MySQL、Oracle 还是 SQL Server。如果它在底层硬编码某种分页语法就丧失了跨数据库能力。所以 MyBatis 的设计选择是提供一个通用的RowBounds逻辑分页保证跨数据库兼容性物理分页交给插件或开发者自己实现第四步分页插件的解决思路——拦截并改写 SQL既然 MyBatis 不改 SQL那能不能在执行 SQL 之前动态地把 SQL 改成带分页语法的这就是MyBatis 插件Interceptor机制的设计来源。MyBatis 插件的本质MyBatis 允许你拦截四大核心对象Executor执行器StatementHandlerSQL 语句处理器ParameterHandler参数处理器ResultSetHandler结果集处理器分页插件通常拦截Executor.query()方法在 SQL 执行前偷梁换柱。分页插件的核心逻辑// 伪代码分页插件的拦截逻辑 public Object intercept(Invocation invocation) { // 1. 获取当前 SQL MappedStatement ms (MappedStatement) invocation.getArgs()[0]; BoundSql boundSql ms.getBoundSql(parameter); String originalSql boundSql.getSql(); // 2. 判断是否需要分页比如检查 ThreadLocal 里有没有分页参数 Page page getPageFromThreadLocal(); if (page null) { // 不分页直接执行原 SQL return invocation.proceed(); } // 3. 获取数据库方言MySQL? Oracle? Dialect dialect getDialect(); // 4. 改写 SQL 为分页 SQL String pageSql dialect.getPageSql(originalSql, page.getOffset(), page.getLimit()); // 5. 创建新的 MappedStatement把 SQL 替换掉 MappedStatement newMs createNewMappedStatement(ms, pageSql); // 6. 执行分页查询 return executor.query(newMs, parameter, ...); }关键插件在 SQL 到达数据库之前把它改成了带LIMIT或ROWNUM的物理分页 SQL。第五步PageHelper 的完整工作流程最主流的实现PageHelper是 MyBatis 生态中最流行的分页插件。它把上面的思路封装得非常易用但内部逻辑很严谨1. 注册插件plugins plugin interceptorcom.github.pagehelper.PageInterceptor !-- 指定数据库方言或让插件自动检测 -- property namehelperDialect valuemysql/ !-- 是否自动执行 count 查询 -- property nameoffset-as-page-num valuetrue/ property namerow-bounds-with-count valuetrue/ /plugin /plugins2. 使用方式// 开启分页把分页参数绑定到当前线程 PageHelper.startPage(1, 10); // 紧跟的这条查询会被分页 ListUser list mapper.selectAll(); // 结果强转成 Page获取分页信息 PageInfoUser pageInfo new PageInfo(list); System.out.println(总条数 pageInfo.getTotal()); System.out.println(总页数 pageInfo.getPages());3. 为什么必须紧跟查询因为PageHelper.startPage()内部用了ThreadLocal存储分页参数// PageHelper 源码逻辑 protected static final ThreadLocalPage LOCAL_PAGE new ThreadLocal(); public static E PageE startPage(int pageNum, int pageSize) { PageE page new Page(pageNum, pageSize); LOCAL_PAGE.set(page); // 绑定到当前线程 return page; }ThreadLocal 的特性是同一个线程内共享不同线程隔离。当你调用mapper.selectAll()时插件从ThreadLocal里取出Page对象知道哦这条 SQL 需要分页。如果你 startPage 之后做了别的操作再执行查询PageHelper.startPage(1, 10); // 中间插了别的逻辑甚至别的查询 otherMapper.doSomething(); // 糟糕这条 SQL 也会被分页 ListUser list mapper.selectAll(); // 可能分页参数已经被清掉了所以 PageHelper 的设计约束是startPage必须和需要分页的查询紧密相邻且中间不能有其他查询。4. 它到底执行了几次 SQLPageHelper 默认会执行两次SQL第一次COUNT 查询插件会自动把你的 SQL 改写成COUNT(*)-- 你的原 SQL SELECT * FROM user WHERE status 1 -- 被改写成 SELECT COUNT(0) FROM user WHERE status 1这是为了计算total总条数这样前端才能显示共XX页。第二次分页查询根据方言改写-- MySQL SELECT * FROM user WHERE status 1 LIMIT 0, 10 -- Oracle SELECT * FROM ( SELECT TMP.*, ROWNUM ROW_ID FROM ( SELECT * FROM user WHERE status 1 ) TMP WHERE ROWNUM 10 ) WHERE ROW_ID 05.执行完后清理 ThreadLocal// 在 finally 块中 LOCAL_PAGE.remove(); // 防止内存泄漏防止影响下一个查询为什么必须 removeThreadLocal如果不清理线程归还线程池后下一个请求复用这个线程时会拿到脏的分页参数这会导致诡异的 bug某个请求突然分页了某个请求突然没分页第六步PageHelper 拦截的是哪个环节完整调用链你的代码: mapper.selectAll() │ ▼ MapperProxy.invoke() → 组装参数 │ ▼ SqlSession.selectList() → 进入 MyBatis 内核 │ ▼ CachingExecutor.query() → 查二级缓存如果有 │ ▼ BaseExecutor.query() → 查一级缓存 │ ▼ SimpleExecutor.doQuery() → 创建 Statement │ ▼ RoutingStatementHandler → 路由到 PreparedStatementHandler │ ▼ 【PageHelper 拦截这里】→ 改写 SQL 文本 │ ▼ JDBC: PreparedStatement.execute() → 数据库执行物理分页 SQL │ ▼ 数据库只返回10条数据 → 网络传输大幅减少 │ ▼ ResultSetHandler → 映射成10个对象 │ ▼ 返回 List实际是 Page 对象继承 ArrayList第七步对比总结特性RowBounds原生逻辑分页PageHelper插件物理分页SQL 是否被修改否是数据库返回数据量全部仅当前页内存占用大加载全部结果小只加载当前页网络传输全部数据当前页数据是否支持 count不支持自动支持跨数据库完全兼容需配置方言适用场景数据量极小1000条生产环境大数据量总结逻辑链条因为数据量增大后全量查询会导致数据库IO、网络带宽、应用内存全面崩溃所以必须做分页只查当前页的数据因为不同数据库的分页 SQL 语法完全不同MySQL 用LIMITOracle 用ROWNUMMyBatis 作为通用框架无法在底层硬编码某一种方言所以MyBatis 只提供了RowBounds做逻辑分页内存筛选保证跨数据库兼容但无法解决大数据量的性能问题因为生产环境必须使用物理分页修改 SQL让数据库只返回少量数据而 MyBatis 提供了拦截器机制允许第三方扩展所以分页插件如 PageHelper通过拦截Executor或StatementHandler在执行前动态改写 SQL添加对应数据库的分页语法因为分页参数需要跨方法传递从startPage传到拦截器但又不能污染方法签名所以PageHelper 使用ThreadLocal隐式传递分页参数这也导致了必须紧跟查询和必须清理 ThreadLocal的约束因为前端需要知道总页数才能渲染分页组件所以PageHelper 会自动执行一次COUNT查询再执行分页查询两次 SQL 共同封装成PageInfo。一句话MyBatis 原生只负责统一接口物理分页这种方言相关且重逻辑的能力通过插件机制交给你按需选择。二、MyBatis 提供了插件机制MyBatis 允许开发者编写插件对内部组件进行拦截。通过Intercepts Signature指定拦截哪个对象拦截哪个方法MyBatis 中可以被拦截的对象主要有ExecutorStatementHandlerParameterHandlerResultSetHandler分页插件一般选择拦截StatementHandler原因StatementHandler 负责处理 SQL。