分頁是業務系統最高頻的需求之一。MyBatis 通過攔截器機制實現無侵入式分頁,本文深度剖析分頁插件核心原理:Interceptor 鏈調用、Executor.query 攔截點、BoundSql SQL 改寫、Dialect 多數據庫適配、Page 對象全鏈路流轉,並提供生產級代碼結構參考。
MyBatis 插件基於責任鏈模式 + 動態代理,攔截四大核心接口的方法:
// 簽名註解定義攔截點
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class PaginationInterceptor implements Interceptor { ... }
update/query/flushStatements/commit/rollback
用戶調用 Mapper 方法
↓
Executor.query(ms, param, rowBounds, handler)
↓
[攔截器鏈] → PaginationInterceptor.intercept(invocation)
↓
1. 從 param / BoundSql 提取 Page 對象
2. 若需分頁 (pageSize > 0):
a. 原始 SQL → Count SQL → 查詢總記錄數 → page.setTotal(count)
b. 原始 SQL → Dialect.getLimitString() → 分頁 SQL
c. 構建新 BoundSql + 新 MappedStatement
d. 替換 invocation.args[0]=newMs, args[2]=RowBounds.DEFAULT
3. invocation.proceed() → 繼續執行真實查詢
↓
返回分頁結果 List → 封裝 Page 返回給調用方
public abstract class BaseInterceptor implements Interceptor {
protected Dialect dialect;
protected Page<Object> extractPage(Object param) {
if (param instanceof Page) return (Page<Object>) param;
// 支援參數對象包含 Page 字段的情況
try { return (Page<Object>) ReflectionUtils.getFieldValue(param, "page"); }
catch (Exception e) { return null; }
}
@Override
public void setProperties(Properties props) {
String dialectClass = props.getProperty("dialectClass");
String dbType = props.getProperty("dbType"); // 兼容舊配置
// 初始化 Dialect 實例...
}
}
@Intercepts(@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
public class PaginationInterceptor extends BaseInterceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
Object param = invocation.getArgs()[1];
BoundSql boundSql = ms.getBoundSql(param);
// 1. 提取分頁對象
Page<Object> page = extractPage(param);
if (page == null || page.getPageSize() <= 0) {
return invocation.proceed(); // 不分頁,直接執行
}
// 2. 查詢總數 (優化:緩存 Count SQL、異步查詢)
String originalSql = boundSql.getSql().trim();
long total = countTotal(ms, param, boundSql, originalSql);
page.setTotal(total);
// 3. 生成分頁 SQL
String pageSql = dialect.getLimitString(originalSql, page.getOffset(), page.getPageSize());
// 4. 構建新的 BoundSql (保留參數映射)
BoundSql newBoundSql = new BoundSql(
ms.getConfiguration(), pageSql,
boundSql.getParameterMappings(), param);
// 複製額外參數 (foreach 等需要)
copyMetaParameters(boundSql, newBoundSql);
// 5. 構建新 MappedStatement (不可變對象需 Builder 重建)
MappedStatement newMs = new MappedStatement.Builder(ms.getConfiguration(),
ms.getId(), new BoundSqlSqlSource(newBoundSql), ms.getSqlCommandType())
.resource(ms.getResource())
.fetchSize(ms.getFetchSize())
.timeout(ms.getTimeout())
.parameterMap(ms.getParameterMap())
.resultMaps(ms.getResultMaps())
.resultSetType(ms.getResultSetType())
.build();
// 6. 替換參數繼續執行
invocation.getArgs()[0] = newMs;
invocation.getArgs()[2] = RowBounds.DEFAULT; // 禁用 MyBatis 內存分頁
return invocation.proceed();
}
private long countTotal(MappedStatement ms, Object param, BoundSql boundSql, String sql) {
// 移除 ORDER BY (不影響計數、提升性能)
String countSql = dialect.getCountString(sql);
// 復用參數設置邏輯...
return executeCountQuery(ms, param, countSql);
}
}
public interface Dialect {
boolean supportsLimit();
String getLimitString(String sql, int offset, int limit);
String getCountString(String sql);
}
// MySQL 實現
public class MySQLDialect implements Dialect {
@Override public boolean supportsLimit() { return true; }
@Override public String getLimitString(String sql, int offset, int limit) {
return sql + " LIMIT " + offset + ", " + limit;
}
@Override public String getCountString(String sql) {
return "SELECT COUNT(1) FROM (" + removeOrderBy(sql) + ") AS tmp_count";
}
}
// Oracle 12c+ 實現
public class OracleDialect implements Dialect {
@Override public String getLimitString(String sql, int offset, int limit) {
return "SELECT * FROM (SELECT row_.*, ROWNUM rownum_ FROM (" + sql + ") row_ WHERE ROWNUM <= " + (offset + limit) + ") WHERE rownum_ > " + offset;
}
@Override public String getCountString(String sql) {
return "SELECT COUNT(1) FROM (" + removeOrderBy(sql) + ")";
}
}
// PostgreSQL 實現
public class PostgreSQLDialect implements Dialect {
@Override public String getLimitString(String sql, int offset, int limit) {
return sql + " LIMIT " + limit + " OFFSET " + offset;
}
}
// SQL Server 2012+ 實現
public class SQLServerDialect implements Dialect {
@Override public String getLimitString(String sql, int offset, int limit) {
return sql + " OFFSET " + offset + " ROWS FETCH NEXT " + limit + " ROWS ONLY";
}
}
public class Page<T> implements Serializable {
private int pageNum = 1; // 當前頁 (1-based)
private int pageSize = 10; // 每頁大小
private long total = 0; // 總記錄數
private int pages = 0; // 總頁數
private List<T> list = Collections.emptyList(); // 當前頁數據
// 計算屬性
public int getOffset() { return (pageNum - 1) * pageSize; }
public int getPages() { return (int) Math.ceil((double) total / pageSize); }
// 前端渲染輔助
public int getStartRow() { return total == 0 ? 0 : (pageNum - 1) * pageSize + 1; }
public int getEndRow() { return Math.min(pageNum * pageSize, total); }
// 鏈式設置
public Page<T> setList(List<T> list) { this.list = list; return this; }
}
public class BaseEntity<T> {
protected Page<T> page = new Page<>();
public Page<T> getPage() { return page; }
public void setPage(Page<T> page) { this.page = page; }
}
public class Product extends BaseEntity<Product> {
private String name;
private BigDecimal price;
// getter/setter...
}
public interface ProductMapper {
List<Product> selectList(Product product); // 參數攜帶 page
// XML 中正常寫 SQL,無需 LIMIT
//
}
@Service
public class ProductService {
@Autowired ProductMapper productMapper;
public Page<Product> findPage(Page<Product> page, Product query) {
query.setPage(page); // 關鍵:將 Page 傳入參數對象
List<Product> list = productMapper.selectList(query);
page.setList(list); // 攔截器已查詢總數並設置到 page
return page;
}
}
@RestController
@RequestMapping("/products")
public class ProductController {
@Autowired ProductService productService;
@GetMapping
public Page<Product> list(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
Product query) {
Page<Product> page = new Page<>();
page.setPageNum(pageNum);
page.setPageSize(pageSize);
return productService.findPage(page, query);
}
}
| 問題 | 原因 | 解決方案 |
|---|---|---|
| Count SQL 慢 | 複雜 JOIN/子查詢 | 自定義 Count SQL、緩存總數、近似計數、異步查詢總數 |
| foreach 參數失效 | 新 BoundSql 丟失 metaParameters | 反射複製 metaParameters 字段 (如代碼所示) |
| Order By 導致計數錯誤 | 某些 DB 不支援子查詢 ORDER BY | Dialect.removeOrderBy() 移除排序再計數 |
| 分頁插件不生效 | 攔截器順序、未配置、參數傳遞錯誤 | 檢查 mybatis-config.xml plugin 配置、Page 參數命名一致性 |
| 並發下總數不一致 | 查詢總數與查數據非原子 | 接受最終一致性、或使用事務隔離級別 |
| 插件 | 特點 | 適用場景 |
|---|---|---|
| PageHelper | 功能最全、ThreadLocal 傳遞 Page、支援多種用法 | 通用項目首選 |
| MyBatis-Plus | 內建分頁、AR 模式、代碼生成器 | 新項目、追求開發效率 |
| 自研 (本文方案) | 可控、輕量、無 ThreadLocal 洩漏風險 | 高性能、特殊定制需求 |
MyBatis 分頁插件本質是:在 Executor.query 攔截點 → 提取 Page 參數 → 改寫 SQL 添加 LIMIT/OFFSET → 先查 COUNT 再查數據 → 封裝結果返回。核心難點在於 BoundSql 重構參數映射、Dialect 多數據庫 SQL 方言差異、foreach 等複雜動態標籤參數傳遞。理解原理後,可根據項目需求選擇成熟開源插件或自研輕量級方案,並在 Service 層封裝統一分頁入口,實現 Controller → Service → Mapper 的無感分頁體驗。