分页插件深度定制
分页插件是 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 值 | 极快 | 数据一致性延迟 | 数据变化少的表 |
| 近似 Count | SHOW TABLE STATUS 或 EXPLAIN 预估行数 | 零开销 | 不精确 | 大数据量列表页 |
智能 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
📝 发现内容有误?点击此处直接编辑