close

點擊關注公眾號,實用技術文章及時了解


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

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

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

賬號重複註冊?

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

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

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

<selectid="checkUserNameUnique"parameterType="String"resultType="int">selectcount(1)fromsys_userwhereuser_name=#{userName}limit1</select>

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

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

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

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

修改用戶密碼報錯?

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

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

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

小結

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

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

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

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

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

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

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

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

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

代碼如下:

@GetMapping("/cms/cmsEssayList")publicTableDataInfocmsEssayList(CmsBlogcmsBlog){//狀態為發布cmsBlog.setStatus("1");startPage();List<CmsBlog>list=cmsBlogService.selectCmsBlogList(cmsBlog);returngetDataTable(list);}

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

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

startPage()幹啥了?protectedvoidstartPage(){//通過request去獲取前端傳遞的分頁參數,不需控制器要顯示接收PageDomainpageDomain=TableSupport.buildPageRequest();IntegerpageNum=pageDomain.getPageNum();IntegerpageSize=pageDomain.getPageSize();if(StringUtils.isNotNull(pageNum)&&StringUtils.isNotNull(pageSize)){StringorderBy=SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());Booleanreasonable=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構造方法到達如下位置:

/***開始分頁**@parampageNum頁碼*@parampageSize每頁顯示數量*@paramcount是否進行count查詢*@paramreasonable分頁合理化,null時用默認配置*@parampageSizeZerotrue且pageSize=0時返回全部結果,false時分頁,null時用默認配置*/publicstatic<E>Page<E>startPage(intpageNum,intpageSize,booleancount,Booleanreasonable,BooleanpageSizeZero){Page<E>page=newPage<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);returnpage;}

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

getLocalPage()

進入方法:

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

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

protectedstaticfinalThreadLocal<Page>LOCAL_PAGE=newThreadLocal<Page>();

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

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

setLocalPage(page)

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

protectedstaticvoidsetLocalPage(Pagepage){LOCAL_PAGE.set(page);}小結

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

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

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

mybatis使用pageHelper分析

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

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

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

@OverridepublicObjectintercept(Invocationinvocation)throwsThrowable{try{Object[]args=invocation.getArgs();MappedStatementms=(MappedStatement)args[0];Objectparameter=args[1];RowBoundsrowBounds=(RowBounds)args[2];ResultHandlerresultHandler=(ResultHandler)args[3];Executorexecutor=(Executor)invocation.getTarget();CacheKeycacheKey;BoundSqlboundSql;//由於邏輯關係,只會進入一次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(dialectinstanceofBoundSqlInterceptor.Chain){boundSql=((BoundSqlInterceptor.Chain)dialect).doBoundSql(BoundSqlInterceptor.Type.ORIGINAL,boundSql,cacheKey);}ListresultList;//調用方法判斷是否需要進行分頁,如果不需要,直接返回結果if(!dialect.skip(ms,parameter,rowBounds)){//判斷是否需要進行count查詢if(dialect.beforeCount(ms,parameter,rowBounds)){//查詢總數Longcount=count(executor,ms,parameter,rowBounds,null,boundSql);//處理查詢總數,返回true時繼續分頁查詢,false時直接返回if(!dialect.afterCount(count,parameter,rowBounds)){//當查詢總數為0時,直接返回空的結果returndialect.afterPage(newArrayList(),parameter,rowBounds);}}resultList=ExecutorUtil.pageQuery(dialect,executor,ms,parameter,rowBounds,resultHandler,boundSql,cacheKey);}else{//rowBounds用參數值,不使用分頁插件處理時,仍然支持默認的內存分頁resultList=executor.query(ms,parameter,rowBounds,resultHandler,cacheKey,boundSql);}returndialect.afterPage(resultList,parameter,rowBounds);}finally{if(dialect!=null){dialect.afterAll();}}}

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

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

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

Pagepage=pageParams.getPage(parameterObject,rowBounds);

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

Pagepage=PageHelper.getLocalPage();統計數量:dialect.beforeCount(ms, parameter, rowBounds)

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

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

//處理查詢總數,返回true時繼續分頁查詢,false時直接返回if(!dialect.afterCount(count,parameter,rowBounds)){//當查詢總數為0時,直接返回空的結果returndialect.afterPage(newArrayList(),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()方法:

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

只關注 clearPage():

/***移除本地變量*/publicstaticvoidclearPage(){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

推薦

Java面試題寶典

技術內卷群,一起來學習!!

PS:因為公眾號平台更改了推送規則,如果不想錯過內容,記得讀完點一下「在看」,加個「星標」,這樣每次新文章推送才會第一時間出現在你的訂閱列表里。點「在看」支持我們吧!

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()