避免 PageHelper 使用中的一些坑

多年不用PageHelper了,最近新入職的公司,採用了此工具集成的框架,作爲一個獨立緊急項目開發的基礎。項目開發起來,還是手到擒來的,但是沒想到,最終測試的時候,深深的給我上了一課

我的項目發生了哪些奇葩現象?

一切的問題都要從我接受的項目開始說起, 在開發這個項目的過程中, 發生了各種奇葩的事情, 下面我簡單說給你們聽聽:

賬號重複註冊?

你肯定在想這是什麼意思? 就是字面意思, 已經註冊的賬號, 可以再次註冊成功!!!

else if (UserConstants.NOT_UNIQUE.equals(userService.checkUserNameUnique(username))
||"匿名用戶".equals(username)){
    // 註冊用戶已存在
    msg = "註冊用戶'" + username + "'失敗";
}

如上所示: checkUserNameUnique(username)用來驗證數據庫是否存在用戶名:

<select id="checkUserNameUnique" parameterType="String" resultType="int">
   select count(1) from sys_user where user_name = #{userName} limit 1
</select>

正常來說, 是不會有問題的, 那麼原因我們後面講, 接着看下一個問題。

查詢全部分類的下拉列表只能查出 5 條數據?

如上所示, 明明有十多個結果, 怎麼只能返回 5 個? 我也沒有添加分頁參數啊?

相信用過 PageHelper 的同學已經知道問題出在哪裏了。

修改用戶密碼報錯?

當管理員在後臺界面重置用戶的密碼的時候,居然報錯了??

報錯信息清晰的告訴了我:sql語句異常,update語句不認識 “Limit 5”

到此爲止,報錯信息已經告訴了我,我的sql被拼接了該死的“limit”分頁參數

小結

上面提到的幾個只是冰山一角,在我使用的過程中,還有各種涉及到 sql 的地方,會因爲這個分頁參數導致的問題,我可以分爲兩種:

比如 insert、update 語句等,不支持 limit,會直接報錯。

如我上面提到的用戶可以重複註冊,卻沒有報錯,實際在代碼當中是有報錯的,但是當前方法對異常進行了 throw,最終被全局異常捕獲了。

不分頁的 sql 被拼接了 limit,導致沒有報錯,但是數據返回量錯誤。

注意:異常不是每次出現,是有一定紀律的,但是觸發幾率較高,原因在後面會逐漸脫出。

PageHelper 是怎麼做到上面的問題的?

PageHelper 使用

我這裏只講解項目基於的框架的使用方式。

代碼如下:

@GetMapping("/cms/cmsEssayList")
public TableDataInfo cmsEssayList(CmsBlog cmsBlog) {
    //狀態爲發佈
    cmsBlog.setStatus("1");
    startPage();
    List<CmsBlog> list = cmsBlogService.selectCmsBlogList(cmsBlog);
    return getDataTable(list);
}

使用起來還是很簡單的,通過 startPage()指定分頁參數,通過getDataTable(list)對結果數據封裝成分頁的格式。

有些同學會問,這也沒沒傳分頁參數啊,並且實體類當中也沒有,這就是比較有意思的點,下一小結就來聊聊源碼。

startPage() 幹啥了?

protected void startPage(){
    // 通過request去獲取前端傳遞的分頁參數,不需控制器要顯示接收
    PageDomain pageDomain = TableSupport.buildPageRequest();
    Integer pageNum = pageDomain.getPageNum();
    Integer pageSize = pageDomain.getPageSize();
    if (StringUtils.isNotNull(pageNum) && StringUtils.isNotNull(pageSize))
    {
        String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
        Boolean reasonable = pageDomain.getReasonable();
        // 真正使用pageHelper進行分頁的位置
        PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
    }
}

PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable)的參數分別是:

繼續跟蹤,連續點擊 startpage 構造方法到達如下位置:

/**
 * 開始分頁
 *
 * @param pageNum      頁碼
 * @param pageSize     每頁顯示數量
 * @param count        是否進行count查詢
 * @param reasonable   分頁合理化,null時用默認配置
 * @param pageSizeZero true且pageSize=0時返回全部結果,false時分頁,null時用默認配置
 */
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
    Page<E> page = new Page<E>(pageNum, pageSize, count);
    page.setReasonable(reasonable);
    page.setPageSizeZero(pageSizeZero);
    // 1、獲取本地分頁
    Page<E> oldPage = getLocalPage();
    if (oldPage != null && oldPage.isOrderByOnly()) {
        page.setOrderBy(oldPage.getOrderBy());
    }
     // 2、設置本地分頁
    setLocalPage(page);
    return page;
}

到達終點位置了,分別是:getLocalPage()setLocalPage(page),分別來看下:

getLocalPage()

進入方法:

/**
 * 獲取 Page 參數
 *
 * @return
 */
public static <T> Page<T> getLocalPage() {
    return LOCAL_PAGE.get();
}

看看常量LOCAL_PAGE是個什麼路數?

protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();

好傢伙,是ThreadLocal,學過 java 基礎的都知道吧,獨屬於每個線程的本地緩存對象。

當一個請求來的時候,會獲取持有當前請求的線程的 ThreadLocal,調用LOCAL_PAGE.get(),查看當前線程是否有未執行的分頁配置。

setLocalPage(page)

此方法顯而易見,設置線程的分頁配置:

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

小結

經過前面的分析,我們發現,問題似乎就是這個 ThreadLocal 導致的。

是否在使用完之後沒有進行清理?導致下一次此線程再次處理請求時,還在使用之前的配置?

我們帶着疑問,看看 mybatis 時如何使用 pageHelper 的。

mybatis 使用 pageHelper 分析

我們需要關注的就是 mybatis 在何時使用的這個 ThreadLocal,也就是何時將分頁餐數獲取到的。

前面提到過,通過 PageHelper 的startPage()方法進行 page 緩存的設置,當程序執行 sql 接口 mapper 的方法時,就會被攔截器PageInterceptor攔截到。

PageHelper 其實就是 mybatis 的分頁插件,其實現原理就是通過攔截器的方式,pageHelper 通PageInterceptor實現分頁效果,我們只關注intercept方法:

@Override
public Object intercept(Invocation invocation) throws Throwable {
    try {
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameter = args[1];
        RowBounds rowBounds = (RowBounds) args[2];
        ResultHandler resultHandler = (ResultHandler) args[3];
        Executor executor = (Executor) invocation.getTarget();
        CacheKey cacheKey;
        BoundSql boundSql;
        // 由於邏輯關係,只會進入一次
        if (args.length == 4) {
            //4 個參數時
            boundSql = ms.getBoundSql(parameter);
            cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
        } else {
            //6 個參數時
            cacheKey = (CacheKey) args[4];
            boundSql = (BoundSql) args[5];
        }
        checkDialectExists();
        //對 boundSql 的攔截處理
        if (dialect instanceof BoundSqlInterceptor.Chain) {
            boundSql = ((BoundSqlInterceptor.Chain) dialect).doBoundSql(BoundSqlInterceptor.Type.ORIGINAL, boundSql, cacheKey);
        }
        List resultList;
        //調用方法判斷是否需要進行分頁,如果不需要,直接返回結果
        if (!dialect.skip(ms, parameter, rowBounds)) {
            //判斷是否需要進行 count 查詢
            if (dialect.beforeCount(ms, parameter, rowBounds)) {
                //查詢總數
                Long count = count(executor, ms, parameter, rowBounds, null, boundSql);
                //處理查詢總數,返回 true 時繼續分頁查詢,false 時直接返回
                if (!dialect.afterCount(count, parameter, rowBounds)) {
                    //當查詢總數爲 0 時,直接返回空的結果
                    return dialect.afterPage(new ArrayList(), parameter, rowBounds);
                }
            }
            resultList = ExecutorUtil.pageQuery(dialect, executor,
                    ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
        } else {
            //rowBounds用參數值,不使用分頁插件處理時,仍然支持默認的內存分頁
            resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
        }
        return dialect.afterPage(resultList, parameter, rowBounds);
    } finally {
        if(dialect != null){
            dialect.afterAll();
        }
    }
}

如上所示是 intecept 的全部代碼,我們下面只關注幾個終點位置:

設置分頁:dialect.skip(ms, parameter, rowBounds)

此處的skip方法進行設置分頁參數,內部調用方法:

Page page = pageParams.getPage(parameterObject, rowBounds);

繼續跟蹤getPage(),發現此方法的第一行就獲取了 ThreadLocal 的值:

Page page = PageHelper.getLocalPage();

統計數量:dialect.beforeCount(ms, parameter, rowBounds)

我們都知道,分頁需要獲取記錄總數,所以,這個攔截器會在分頁前先進行 count 操作。

如果 count 爲 0,則直接返回,不進行分頁:

//處理查詢總數,返回 true 時繼續分頁查詢,false 時直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
    //當查詢總數爲 0 時,直接返回空的結果
    return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}

afterPage 其實是對分頁結果的封裝方法,即使不分頁,也會執行,只不過返回空列表。

分頁:ExecutorUtil.pageQuery

在處理完 count 方法後,就是真正的進行分頁了:

resultList = ExecutorUtil.pageQuery(dialect, executor,
        ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);

此方法在執行分頁之前,會判斷是否執行分頁,依據就是前面我們通過 ThreadLocal 的獲取的 page。

當然,不分頁的查詢,以及新增和更新不會走到這個方法當中。

非分頁:executor.query

而是會走到下面的這個分支:

resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);

我們可以思考一下,如果 ThreadLoad 在使用後沒有被清除,當執行非分頁的方法時,那麼就會將 Limit 拼接到 sql 後面。

爲什麼不分也得也會拼接?我們回頭看下前面提到的dialect.skip(ms, parameter, rowBounds):

如上所示,只要 page 被獲取到了,那麼這個 sql,就會走前面提到的ExecutorUtil.pageQuery分頁邏輯,最終導致出現不可預料的情況。

其實 PageHelper 對於分頁後的 ThreaLocal 是有清除處理的。

清除 TheadLocal

在 intercept 方法的最後,會在 sql 方法執行完成後,清理 page 緩存:

finally {
    if(dialect != null){
        dialect.afterAll();
    }
}

看看這個afterAll()方法:

@Override
public void afterAll() {
    //這個方法即使不分頁也會被執行,所以要判斷 null
    AbstractHelperDialect delegate = autoDialect.getDelegate();
    if (delegate != null) {
        delegate.afterAll();
        autoDialect.clearDelegate();
    }
    clearPage();
}

只關注 clearPage()

/**
 * 移除本地變量
 */
public static void clearPage() {
    LOCAL_PAGE.remove();
}

小結

到此爲止,關於 PageHelper 的使用方式就講解完了。

整體看下來,似乎不會存在什麼問題,但是我們可以考慮集中極端情況:

所以,官方給我們的建議,在使用 PageHelper 進行分頁時,執行 sql 的代碼要緊跟 startPage() 方法

除此之外,我們可以手動調用clearPage()方法,在存在問題的方法之前。

需要注意:不要分頁的方法前手動調用 clearPage,將會導致你的分頁出現問題

還有人問爲什麼不是每次請求都出錯?

這個其實取決於我們啓動服務所使用的容器,比如 tomcat,在其內部處理請求是通過線程池的方式。甚至現在的很多容器是基於 netty 的,都是通過線程池,複用線程來增加服務的併發量。

假設線程 1 持有沒有被清除的 page 參數,不斷調用同一個方法,後面兩個請求使用的是線程 2 和線程 3 沒有問題,再一個請求輪到線程 1 了,此時就會出現問題了。

總結

關於 PageHelper 的介紹就這麼多,真的是折磨我好幾天,要不是項目緊急,來不及替換,我一定不會使用這個組件。

莫名其妙的就會有個方法出現問題,一通排查,發現都是這個 PageHelper 導致的。雖然我已經全局搜索使用的地方,保證startPage()後緊跟 sql 命令,但是仍然有嫌犯潛逃,只能在有問題的方法使用clearPage()來打補丁。

雖然 PageHelper 給我帶來一些困擾,耗費了一定的時間,但是定位問題的過程中,也學習了 mybatis 和 pagehepler 的實現方式,對於熱愛源碼閱讀的同學來說還是有一定的提升的。

source: juejin.cn/post/7125356642366914596

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/IixkC9HkKwr__UEcfStlGg