全部学科
Python全栈
python
NodeJS全栈
nodejs
小程序首页
📅 2026-05-20 8 分钟 ✍️ juanwangdev

分页插件原理

MyBatis 分页插件通过拦截 Executor 或 StatementHandler,在 SQL 执行前动态改写 SQL,实现物理分页,避免内存中全量加载。

拦截核心对象

分页插件通常拦截以下两个对象之一:

拦截对象拦截方法时机优缺点
Executorquery(MappedStatement, Object, RowBounds, ResultHandler)执行前,拿到 MappedStatement可修改 MappedStatement,适合改写 SQL
StatementHandlerprepare(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

关键步骤:

  1. SQL 解析:使用 JSqlParser 等工具解析 SQL AST,提取 SELECT、FROM、WHERE、ORDER BY 等子句
  2. COUNT 改写:去除 ORDER BY(COUNT 不需要排序),替换 SELECT 列为 COUNT(0)
  3. 分页追加:在 SQL 末尾追加方言特定的分页语法

多数据库方言支持

不同数据库分页语法差异大,插件需支持多 dialect:

数据库分页语法示例
MySQLLIMIT #{limit} OFFSET #{offset}LIMIT 10 OFFSET 20
PostgreSQLLIMIT #{limit} OFFSET #{offset}LIMIT 10 OFFSET 20
Oracle (12c+)OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLYOFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY
Oracle (旧版)ROWNUM 嵌套三层 SELECT 嵌套 ROWNUM
SQL ServerOFFSET ... ROWS FETCH NEXT ... ROWS ONLYROW_NUMBER()需 ORDER BY 配合
SQLiteLIMIT #{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 列
含 DISTINCTSELECT COUNT(DISTINCT col)保留 DISTINCT 语义
含 GROUP BY外层包裹 COUNTSELECT COUNT(0) FROM (原始 SQL) tmp
含 ORDER BY移除 ORDER BYCOUNT 不依赖排序,移除提升性能

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);

注意事项

  1. 避免重复分页:拦截后需将 RowBounds 重置为 DEFAULT,否则 MyBatis 可能再次应用逻辑分页
  2. SQL 注入防护:改写 SQL 时参数化 LIMIT/OFFSET 值,不直接字符串拼接
  3. 复杂 SQL 解析:含子查询、UNION、GROUP BY 的 SQL 需特殊处理 COUNT 逻辑
  4. 性能权衡:每次分页查询执行 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

📝 发现内容有误?点击此处直接编辑

← 上一篇 Interceptor 接口实现
下一篇 → 性能监控插件
想查看更多题目和详细解析?
小程序提供完整的题库、模拟考试和详细解析
马上就来

长按或扫描二维码,立即体验

扫码体验小程序
马上就来
使用微信扫描二维码
立即体验完整题库