分页插件原理
MyBatis 分页插件通过拦截 Executor 或 StatementHandler,在 SQL 执行前动态改写 SQL,实现物理分页,避免内存中全量加载。
拦截核心对象
分页插件通常拦截以下两个对象之一:
| 拦截对象 | 拦截方法 | 时机 | 优缺点 |
|---|---|---|---|
Executor | query(MappedStatement, Object, RowBounds, ResultHandler) | 执行前,拿到 MappedStatement | 可修改 MappedStatement,适合改写 SQL |
StatementHandler | prepare(Connection, Integer) | SQL 已生成,创建 Statement 前 | 直接操作原始 SQL 字符串,改写更灵活 |
主流分页插件(如 PageHelper)选择拦截
Executor.query(),在 SQL 生成阶段介入,可灵活改写 COUNT 和分页 SQL。
SQL 改写原理
分页 SQL 结构
物理分页需要在原始 SQL 基础上追加 LIMIT/OFFSET 子句,同时生成对应的 COUNT 查询:
Java
// 原始 SQL
SELECT * FROM user WHERE status = 1 ORDER BY create_time DESC
// 改写后(MySQL 方言)
SELECT * FROM user WHERE status = 1 ORDER BY create_time DESC LIMIT 10 OFFSET 20
// 配套 COUNT SQL
SELECT COUNT(0) FROM user WHERE status = 1
改写流程
Java
原始 SQL → 解析 SQL 结构 → 生成 COUNT SQL → 执行 COUNT 获取总数 → 追加 LIMIT/OFFSET → 执行分页 SQL
关键步骤:
- SQL 解析:使用 JSqlParser 等工具解析 SQL AST,提取 SELECT、FROM、WHERE、ORDER BY 等子句
- COUNT 改写:去除 ORDER BY(COUNT 不需要排序),替换 SELECT 列为
COUNT(0) - 分页追加:在 SQL 末尾追加方言特定的分页语法
多数据库方言支持
不同数据库分页语法差异大,插件需支持多 dialect:
| 数据库 | 分页语法 | 示例 |
|---|---|---|
| MySQL | LIMIT #{limit} OFFSET #{offset} | LIMIT 10 OFFSET 20 |
| PostgreSQL | LIMIT #{limit} OFFSET #{offset} | LIMIT 10 OFFSET 20 |
| Oracle (12c+) | OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY | OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY |
| Oracle (旧版) | ROWNUM 嵌套 | 三层 SELECT 嵌套 ROWNUM |
| SQL Server | OFFSET ... ROWS FETCH NEXT ... ROWS ONLY 或 ROW_NUMBER() | 需 ORDER BY 配合 |
| SQLite | LIMIT #{limit} OFFSET #{offset} | LIMIT 10 OFFSET 20 |
方言接口设计
Java
public interface Dialect {
/**
* 改写为 COUNT SQL
*/
String getCountSql(String sql);
/**
* 改写为分页 SQL
*/
String getPageSql(String sql, int offset, int limit);
/**
* 是否支持该数据库 URL
*/
boolean supports(String url);
}
MySQL 方言实现
Java
public class MySqlDialect implements Dialect {
@Override
public String getCountSql(String sql) {
// 去除 ORDER BY,COUNT 不需要排序
return sql.replaceAll("(?i)\\s+ORDER\\s+BY\\s+[^,]+(\\s+(ASC|DESC))?(\\s*,\\s*[^,]+(\\s+(ASC|DESC))?)*", "");
}
@Override
public String getPageSql(String sql, int offset, int limit) {
return sql + " LIMIT " + limit + " OFFSET " + offset;
}
@Override
public boolean supports(String url) {
return url.contains("mysql") || url.contains("mariadb");
}
}
COUNT 查询优化
优化策略
| 场景 | 优化方式 | 说明 |
|---|---|---|
| 简单单表查询 | SELECT COUNT(*) | 直接替换 SELECT 列 |
| 含 DISTINCT | SELECT COUNT(DISTINCT col) | 保留 DISTINCT 语义 |
| 含 GROUP BY | 外层包裹 COUNT | SELECT COUNT(0) FROM (原始 SQL) tmp |
| 含 ORDER BY | 移除 ORDER BY | COUNT 不依赖排序,移除提升性能 |
COUNT 改写示例
Java
// 简单场景:直接替换
SELECT * FROM user WHERE status = 1
→ SELECT COUNT(0) FROM user WHERE status = 1
// GROUP BY 场景:外层包裹
SELECT dept, COUNT(*) FROM user GROUP BY dept
→ SELECT COUNT(0) FROM (SELECT dept, COUNT(*) FROM user GROUP BY dept) tmp
// JOIN 场景:保留 JOIN
SELECT u.*, d.name FROM user u JOIN dept d ON u.dept_id = d.id
→ SELECT COUNT(0) FROM user u JOIN dept d ON u.dept_id = d.id
完整拦截器实现
XML
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class PaginationInterceptor implements Interceptor {
private Dialect dialect;
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
RowBounds rowBounds = (RowBounds) args[2];
ResultHandler resultHandler = (ResultHandler) args[3];
// 无分页参数则跳过
if (rowBounds.getLimit() == RowBounds.NO_ROW_LIMIT) {
return invocation.proceed();
}
BoundSql boundSql = ms.getBoundSql(parameter);
String originalSql = boundSql.getSql();
// 1. 执行 COUNT 查询 获取总数
String countSql = dialect.getCountSql(originalSql);
Long total = executeCount(ms, countSql, parameter);
// 2. 改写分页 SQL
int offset = rowBounds.getOffset();
int limit = rowBounds.getLimit();
String pageSql = dialect.getPageSql(originalSql, offset, limit);
// 3. 替换 BoundSql 中的 SQL
BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), pageSql,
boundSql.getParameterMappings(), parameter);
// 4. 创建新的 MappedStatement 执行
MappedStatement newMs = copyMappedStatement(ms, newBoundSql);
args[0] = newMs;
args[2] = RowBounds.DEFAULT; // 清空 RowBounds,避免重复分页
return new PaginationResult<>(total, (List<?>) invocation.proceed());
}
private Long executeCount(MappedStatement ms, String countSql, Object parameter) {
// 通过新建 MappedStatement 执行 COUNT 查询
Configuration config = ms.getConfiguration();
MappedStatement countMs = new MappedStatement.Builder(config, ms.getId() + "_COUNT",
new StaticSqlSource(config, countSql), SqlCommandType.SELECT)
.resultMaps(ms.getResultMaps()).build();
List<?> result = ms.getConfiguration().getExecutor()
.query(countMs, parameter, RowBounds.DEFAULT, null);
return result.isEmpty() ? 0L : ((Number) result.get(0)).longValue();
}
private MappedStatement copyMappedStatement(MappedStatement ms, BoundSql newBoundSql) {
Configuration config = ms.getConfiguration();
return new MappedStatement.Builder(config, ms.getId(),
new BoundSqlSqlSource(newBoundSql), SqlCommandType.SELECT)
.resultMaps(ms.getResultMaps())
.fetchSize(ms.getFetchSize())
.timeout(ms.getTimeout())
.statementType(ms.getStatementType())
.resultSetType(ms.getResultSetType())
.build();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
String dialectClass = properties.getProperty("dialect", "MySqlDialect");
try {
this.dialect = (Dialect) Class.forName(dialectClass).newInstance();
} catch (Exception e) {
throw new RuntimeException("Failed to load dialect: " + dialectClass, e);
}
}
}
配置与使用
Java
<!-- mybatis-config.xml -->
<plugins>
<plugin interceptor="com.example.PaginationInterceptor">
<property name="dialect" value="com.example.MySqlDialect"/>
</plugin>
</plugins>
text
// Java 调用
RowBounds rowBounds = new RowBounds(20, 10); // offset=20, limit=10
List<User> users = sqlSession.selectList("selectUsers", null, rowBounds);
注意事项
- 避免重复分页:拦截后需将
RowBounds重置为DEFAULT,否则 MyBatis 可能再次应用逻辑分页- SQL 注入防护:改写 SQL 时参数化 LIMIT/OFFSET 值,不直接字符串拼接
- 复杂 SQL 解析:含子查询、UNION、GROUP BY 的 SQL 需特殊处理 COUNT 逻辑
- 性能权衡:每次分页查询执行 2 次 SQL(COUNT + DATA),大数据量可考虑异步 COUNT 或估算总数
要点总结
- 分页插件拦截
Executor.query()或StatementHandler.prepare(),在 SQL 执行前改写 - 核心流程:解析 SQL → 生成 COUNT SQL → 执行 COUNT → 追加 LIMIT/OFFSET → 执行分页 SQL
- 多数据库方言通过
Dialect接口抽象,各数据库分页语法差异大需分别实现 - COUNT 查询需优化:移除 ORDER BY、GROUP BY 需外层包裹、保留 DISTINCT 语义
- 拦截后需重置 RowBounds 避免重复分页,复杂 SQL 需特殊处理 COUNT 逻辑
存放路径:D:\git2\jwdev\articles\MYBATIS\进阶\插件机制\分页插件原理.md
📝 发现内容有误?点击此处直接编辑