首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 相關知識

MyBatis 分頁插件原理深度解析:攔截器鏈、SQL 改寫與多數據庫方言適配

     編輯:Java 相關知識

分頁是業務系統最高頻的需求之一。MyBatis 通過攔截器機制實現無侵入式分頁,本文深度剖析分頁插件核心原理:Interceptor 鏈調用、Executor.query 攔截點、BoundSql SQL 改寫、Dialect 多數據庫適配、Page 對象全鏈路流轉,並提供生產級代碼結構參考。

MyBatis 插件機制核心

MyBatis 插件基於責任鏈模式 + 動態代理,攔截四大核心接口的方法:

// 簽名註解定義攔截點
@Intercepts({
    @Signature(type = Executor.class, method = "query", 
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class PaginationInterceptor implements Interceptor { ... }
  • Executor:update/query/flushStatements/commit/rollback
  • ParameterHandler:參數處理
  • ResultSetHandler:結果集處理
  • StatementHandler:SQL 語句構建

分頁插件執行流程圖解


用戶調用 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 返回給調用方

核心代碼結構

1. 基礎攔截器

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 實例...
    }
}

2. 分頁攔截器核心邏輯

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

3. 多數據庫方言適配體系

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";
    }
}

4. Page 對象設計 (核心數據載體)

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

三層架構整合最佳實踐

Entity 層:繼承基類自動擁有 Page

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...
}

DAO 層:Mapper 接口無需分頁參數

public interface ProductMapper {
    List<Product> selectList(Product product); // 參數攜帶 page
    // XML 中正常寫 SQL,無需 LIMIT
    // 
}

Service 層:封裝分頁邏輯

@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;
    }
}

Controller 層:接收前端參數自動綁定

@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 的無感分頁體驗。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved