多年以後,PageHelper 又深深給我上了一課!

来源:https://www.cnblogs.com/javastack/p/18023194
-Advertisement-
Play Games

多年不用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>

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

推薦一個開源免費的 Spring Boot 實戰項目:

https://github.com/javastacks/spring-boot-best-practice

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

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

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

修改用戶密碼報錯?

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

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

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

小結

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

1)直接導致報錯的:明確報錯原因的

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

2)導致業務邏輯錯誤,但是代碼沒有錯誤提示

如我上面提到的用戶可以重覆註冊,卻沒有報錯,實際在代碼當中是有報錯的,但是當前方法對異常進行了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)的參數分別是:

  • pageNum:頁數
  • pageSize:每頁數據量
  • orderBy:排序
  • reasonable:分頁合理化,對於不合理的分頁參數自動處理,比如傳遞pageNum是小於0,會預設設置為1.

繼續跟蹤,連續點擊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的使用方式就講解完了。

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

如果使用了startPage(),但是沒有執行對應的sql,那麼就表明,當前線程ThreadLocal被設置了分頁參數,可是沒有被使用,當下一個使用此線程的請求來時,就會出現問題。

如果程式在執行sql前,發生異常了,就沒辦法執行finally當中的clearPage()方法,也會造成線程的ThreadLocal被污染。

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

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

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

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

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

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

總結

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

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

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

原文:juejin.cn/post/7125356642366914596

更多文章推薦:

1.Spring Boot 3.x 教程,太全了!

2.2,000+ 道 Java面試題及答案整理(2024最新版)

3.免費獲取 IDEA 激活碼的 7 種方式(2024最新版)

覺得不錯,別忘了隨手點贊+轉發哦!


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 日常工作中,數據開發上線完一個任務後並不是就可以高枕無憂,時常因上游鏈路數據異常或者自身處理邏輯的 BUG 導致產出的數據結果不可信。而問題發現可經歷較長周期(尤其離線場景),往往是業務方通過上層數據報表發現數據異常後 push 數據方去定位問題(對於一個較冷的報表,這個周期可能會更長)。 由於數據 ...
  • Java 面向對象編程 面向對象編程 (OOP) 是一種編程範式,它將程式組織成對象。對象包含數據和操作數據的方法。 OOP 的優勢: 更快、更易於執行 提供清晰的結構 代碼更易於維護、修改和調試 提高代碼重用性 減少開發時間 類和對象 類 是對象的模板,它定義了對象的屬性和方法。 對象 是類的實例 ...
  • Miniconda是Anaconda的簡化版, 可以管理多個Python版本的環境. 實際使用的話, 占用的空間不會很小, 我跑一些正常的應用後, 安裝目錄占用空間4.3GB, 安裝建議要預留10到20G的空間. 安裝 Miniconda 下載安裝包 https://docs.anaconda.co ...
  • 美團面試:Kafka如何處理百萬級消息隊列? 在今天的大數據時代,處理海量數據已成為各行各業的標配。特別是在消息隊列領域,Apache Kafka 作為一個分散式流處理平臺,因其高吞吐量、可擴展性、容錯性以及低延遲的特性而廣受歡迎。但當面對真正的百萬級甚至更高量級的消息處理時,如何有效地利用 Kaf ...
  • 摘要 我們報告了 GPT-4 的開發,這是一個大規模、多模態的模型,可以接受圖像和文本輸入,並生成文本輸出。雖然在許多現實場景中不如人類,但 GPT-4 在各種專業和學術基準測試中表現出與人類水平相當的性能,包括在模擬的律師資格考試中取得了約前10%的考生得分。 GPT-4 是基於 Transfor ...
  • 虛擬線程(Virtual Threads)是 Java 21 所有新特性中最為吸引人的內容,它可以大大來簡化和增強Java應用的併發性。但是,隨著這些變化而來的是如何最好地管理此吞吐量的問題。本文,就讓我們看一下開發人員在使用虛擬線程時,應該如何管理吞吐量。 在大多數情況下,開發人員不需要自己創建虛 ...
  • 首先,跨域的域是什麼? 跨域的英文是:Cross-Origin。 Origin 中文含義為:起源,源頭,出生地。 在跨域中,"域"指的是一個 Web 資源(比如網頁、腳本、圖片等)的源頭。 包括該資源的協議、主機名、埠號。 在同源策略中,如果兩個資源的域相同,則它們屬於同一域,可以自由進行交互和共 ...
  • 通過使用Python編程語言,編寫腳本來自動化Excel和CSV之間的轉換過程,可以批量處理大量文件,定期更新數據,並集成轉換過程到自動化工作流程中。本文將介紹如何使用第三方庫Spire.XLS for Python 實現: 使用Python將Excel轉為CSV 使用Python 將CSV轉為Ex ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...