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

分页插件深度定制

分页插件是 MyBatis 最常用的拦截器,核心原理是在 SQL 执行前改写原始 SQL 追加 LIMIT/OFFSET,并自动生成 Count 查询统计总数。

分页插件架构

Java
┌─────────────────────────────────────────────────┐
│              PaginationPlugin                    │
├─────────────────┬───────────────────────────────┤
│ Executor 拦截    │   StatementHandler 拦截         │
│ - 捕获分页参数   │   - 改写 SQL 追加 LIMIT         │
│ - 存储到本地线程  │   - 执行 Count 查询             │
└─────────────────┴───────────────────────────────┘
         │                        │
    ┌────▼────┐            ┌─────▼─────┐
    │ Page对象 │            │ Dialect方言 │
    │ pageNum  │            │ MySQL     │
    │ pageSize │            │ Oracle    │
    │ total    │            │ PostgreSQL│
    │ list     │            │ SQLServer │
    └─────────┘            └───────────┘

核心实现:拦截 Executor.query

分页参数通过 RowBounds 或自定义 Page 对象传递:

Java
@Intercepts({
    @Signature(type = Executor.class, method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class PaginationPlugin implements Interceptor {

    @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 == RowBounds.DEFAULT) {
            return invocation.proceed();
        }

        // 使用自定义 Page 对象
        Page<?> page = PageHelper.getLocalPage();
        if (page == null) {
            return invocation.proceed();
        }

        // 1. 执行 Count 查询获取总数
        if (page.isSearchCount()) {
            long total = executeCountQuery(ms, parameter, rowBounds);
            page.setTotal(total);
        }

        // 2. 改写 SQL 追加分页子句
        BoundSql boundSql = ms.getBoundSql(parameter);
        String originalSql = boundSql.getSql();
        String pageSql = DialectFactory.getDialect(ms.getConfiguration())
            .getLimitString(originalSql, page.getOffset(), page.getPageSize());

        // 3. 创建新的 MappedStatement 替换 SQL
        MappedStatement newMs = buildNewMappedStatement(ms, new BoundSql(ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameter));
        args[0] = newMs;
        args[2] = RowBounds.DEFAULT;  // 清除 RowBounds,避免重复分页

        // 4. 执行查询
        List<?> result = (List<?>) invocation.proceed();
        page.setList(result);

        return result;
    }
}

分页插件拦截 Executor.query,在 SQL 执行前改写 MappedStatement 中的 BoundSql

多数据库方言支持

Dialect 接口设计

Java
public interface Dialect {

    /**
     * 将原始 SQL 改写为带分页的 SQL
     * @param sql 原始 SQL
     * @param offset 偏移量
     * @param limit  每页条数
     * @return 分页 SQL
     */
    String getLimitString(String sql, int offset, int limit);

    /**
     * 将原始 SQL 改写为 Count SQL
     */
    default String getCountString(String sql) {
        return "SELECT COUNT(0) FROM (" + removeOrders(sql) + ") table_count";
    }

    /**
     * 去除 ORDER BY(Count 查询不需要排序)
     */
    default String removeOrders(String sql) {
        Pattern pattern = Pattern.compile("order\\s+by\\s+[^)]*", Pattern.CASE_INSENSITIVE);
        return pattern.matcher(sql).replaceAll("");
    }
}

MySQL 方言

Java
public class MySQLDialect implements Dialect {

    @Override
    public String getLimitString(String sql, int offset, int limit) {
        return sql + " LIMIT " + offset + ", " + limit;
    }
}

Oracle 方言

Java
public class OracleDialect implements Dialect {

    @Override
    public String getLimitString(String sql, int offset, int limit) {
        int end = offset + limit;
        return "SELECT * FROM ("
            + "SELECT innertab.*, ROWNUM AS rn FROM ("
            + sql
            + ") innertab WHERE ROWNUM <= " + end
            + ") WHERE rn > " + offset;
    }
}

PostgreSQL 方言

Java
public class PostgreSQLDialect implements Dialect {

    @Override
    public String getLimitString(String sql, int offset, int limit) {
        return sql + " LIMIT " + limit + " OFFSET " + offset;
    }
}

SQL Server 方言(2012+)

Java
public class SQLServerDialect implements Dialect {

    @Override
    public String getLimitString(String sql, int offset, int limit) {
        if (sql.toUpperCase().contains("ORDER BY")) {
            return sql + " OFFSET " + offset + " ROWS FETCH NEXT " + limit + " ROWS ONLY";
        }
        // 无 ORDER BY 时自动添加
        return sql + " ORDER BY (SELECT 1) OFFSET " + offset + " ROWS FETCH NEXT " + limit + " ROWS ONLY";
    }
}

DialectFactory 工厂

Java
public class DialectFactory {

    private static final Map<String, Dialect> DIALECT_MAP = new HashMap<>();

    static {
        DIALECT_MAP.put("mysql", new MySQLDialect());
        DIALECT_MAP.put("oracle", new OracleDialect());
        DIALECT_MAP.put("postgresql", new PostgreSQLDialect());
        DIALECT_MAP.put("sqlserver", new SQLServerDialect());
    }

    public static Dialect getDialect(Configuration configuration) {
        Environment env = configuration.getEnvironment();
        if (env == null) return new MySQLDialect();

        String dbType = env.getDataSource().getConnection().getMetaData().getDatabaseProductName();
        return DIALECT_MAP.entrySet().stream()
            .filter(e -> dbType.toLowerCase().contains(e.getKey()))
            .map(Map.Entry::getValue)
            .findFirst()
            .orElse(new MySQLDialect());
    }
}

Count 查询优化

标准 Count 查询

Java
private long executeCountQuery(MappedStatement ms, Object parameter, RowBounds rowBounds) {
    // 1. 获取原始 SQL
    BoundSql boundSql = ms.getBoundSql(parameter);
    String originalSql = boundSql.getSql();

    // 2. 生成 Count SQL
    Dialect dialect = DialectFactory.getDialect(ms.getConfiguration());
    String countSql = dialect.getCountString(originalSql);

    // 3. 构建 Count MappedStatement
    BoundSql countBoundSql = new BoundSql(
        ms.getConfiguration(), countSql, boundSql.getParameterMappings(), parameter);

    MappedStatement countMs = buildNewMappedStatement(ms, countBoundSql);

    // 4. 执行 Count 查询
    List<CountResult> countResultList = new ArrayList<>();
    CacheKey countCacheKey = ms.getConfiguration().createCacheKey();
    ms.getConfiguration().getExecutor().query(
        countMs, parameter, RowBounds.DEFAULT, null, countCacheKey, countBoundSql);

    // 5. 提取总数
    if (!countResultList.isEmpty()) {
        return countResultList.get(0).getTotal();
    }
    return 0;
}

Count 优化策略对比

策略实现方式优点缺点适用场景
COUNT(0) 子查询SELECT COUNT(0) FROM (原SQL) t通用、精确复杂 SQL 性能差一般查询
去除 JOIN 优化解析 SQL 树去除 JOIN 仅 COUNT 主表性能高实现复杂、可能不精确简单关联
缓存 Count 结果Redis 缓存 Count 值极快数据一致性延迟数据变化少的表
近似 CountSHOW TABLE STATUSEXPLAIN 预估行数零开销不精确大数据量列表页

智能 Count 优化实现

Java
public class SmartCountDialect extends MySQLDialect {

    @Override
    public String getCountString(String sql) {
        // 如果 SQL 已包含 COUNT,直接返回
        if (sql.toUpperCase().contains("COUNT(")) {
            return "SELECT COUNT(0) FROM (" + sql + ") table_count";
        }

        // 简单查询:直接 COUNT 主表,去除 JOIN
        if (isSimpleQuery(sql)) {
            String tableName = extractMainTable(sql);
            return "SELECT COUNT(0) FROM " + tableName;
        }

        // 复杂查询:使用子查询
        return "SELECT COUNT(0) FROM (" + removeOrders(sql) + ") table_count";
    }

    private boolean isSimpleQuery(String sql) {
        // 判断是否单表查询(无 JOIN、无子查询、无 GROUP BY)
        String upper = sql.toUpperCase();
        return !upper.contains(" JOIN ")
            && !upper.contains(" GROUP BY ")
            && !upper.contains(" UNION ")
            && upper.indexOf("SELECT") == upper.lastIndexOf("SELECT");
    }

    private String extractMainTable(String sql) {
        Pattern pattern = Pattern.compile("FROM\\s+(\\w+)", Pattern.CASE_INSENSITIVE);
        Matcher matcher = pattern.matcher(sql);
        if (matcher.find()) {
            return matcher.group(1);
        }
        throw new IllegalArgumentException("Cannot extract table name from SQL");
    }
}

PageHelper 自定义定制

Page 对象

Java
public class Page<T> extends PageMethod implements Serializable {
    private int pageNum;       // 当前页码
    private int pageSize;      // 每页条数
    private long total;        // 总记录数
    private int pages;         // 总页数
    private List<T> list;      // 结果集
    private boolean searchCount = true;  // 是否执行 Count

    public int getOffset() {
        return (pageNum - 1) * pageSize;
    }

    public void calcPages() {
        this.pages = (int) (total / pageSize + (total % pageSize == 0 ? 0 : 1));
    }
}

ThreadLocal 传递分页参数

Java
public abstract class PageMethod {
    // 使用 ThreadLocal 跨方法传递分页参数
    private static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<>();

    public static void setLocalPage(Page page) {
        LOCAL_PAGE.set(page);
    }

    @SuppressWarnings("unchecked")
    public static <T> Page<T> getLocalPage() {
        return (Page<T>) LOCAL_PAGE.get();
    }

    public static void clearLocalPage() {
        LOCAL_PAGE.remove();
    }
}

使用方式

Java
// 方式1:手动设置
PageHelper.startPage(1, 10);
List<User> users = userMapper.selectAll();
PageInfo<User> pageInfo = new PageInfo<>(users);

// 方式2:Page 对象直接调用
Page<User> page = new Page<>(1, 10);
PageHelper.setLocalPage(page);
List<User> users = userMapper.selectAll();
XML
public class PageHelper {

    public static <E> Page<E> startPage(int pageNum, int pageSize) {
        return startPage(pageNum, pageSize, true);
    }

    public static <E> Page<E> startPage(int pageNum, int pageSize, boolean searchCount) {
        Page<E> page = new Page<>(pageNum, pageSize);
        page.setSearchCount(searchCount);
        PageMethod.setLocalPage(page);
        return page;
    }
}

ThreadLocal 确保分页参数在同一个线程内的方法调用链中传递,插件拦截后自动清理。

插件配置

Java
<plugins>
  <plugin interceptor="com.example.PaginationPlugin">
    <property name="dialect" value="mysql"/>
    <property name="optimizeCountSql" value="true"/>
    <property name="reasonable" value="true"/>
  </plugin>
</plugins>
text
@Override
public void setProperties(Properties properties) {
    String dialect = properties.getProperty("dialect", "mysql");
    String optimizeCount = properties.getProperty("optimizeCountSql", "true");
    String reasonable = properties.getProperty("reasonable", "true");
    // 应用配置
}

要点总结

  • 分页插件拦截 Executor.query,通过改写 BoundSql 中的 SQL 追加 LIMIT/OFFSET 实现分页
  • 多数据库方言通过 Dialect 接口统一抽象,MySQL/Oracle/PostgreSQL/SQLServer 各自实现 getLimitString
  • Count 查询通过子查询 SELECT COUNT(0) FROM (原SQL) t 实现,复杂 SQL 可优化去除 JOIN 提升性能
  • PageHelper.startPage() 通过 ThreadLocal 传递分页参数,插件拦截后自动清理避免泄漏
  • 智能 Count 优化:简单查询直接 COUNT 主表,复杂查询使用子查询,极端场景可走缓存或近似 Count
  • 分页插件应在其他 SQL 改写插件之后注册,确保分页 LIMIT 追加在最后
  • 必须清理 ThreadLocal 防止线程池复用时分页参数污染

存放路径:D:\git2\jwdev\articles\MYBATIS\专家\插件开发高级应用\分页插件深度定制.md

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

← 上一篇 SQL 审计日志插件
下一篇 → 多插件执行顺序
想查看更多题目和详细解析?
小程序提供完整的题库、模拟考试和详细解析
马上就来

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

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