關于PageHelper的坑,導緻莫名其妙的sql錯誤的問題
前言
PageHelper 用得好,能省很多功夫。用得不好會埋下很大的隐患,并且很難發現。本文就是讨論這個很難發現的坑。
症狀
- 出現莫名其妙的分頁混亂
原來不分頁的sql卻隻查出了部分資料,觀察sql發現sql被添加了limit
- 或者sql語句報錯
觀察該sql語句有limit,但limit後面又被添加了limit
- 上述症狀比較難重制。但如果知道其發病機理,則100%可重制。
對陣下藥
這是因為PageHelper實作的原理是通過ThreadLocal實作。在PageHelper.startPage()的時候,将分頁的資訊綁定到線程A,當執行mapper的方法的時候,sql被PageHelper的攔截器攔截并取出放入線程A的分頁資訊。
接着sql被分解為查詢記錄總數的sql,如果總數不為0,再執行查詢分頁資料的sql(原sql被改造成末尾帶有limit),最後在攔截器的finally中移除綁定到線程A中的分頁資訊。
上述是正常情況。
假設分頁資料綁定到線程A最終沒被移除,那别的方法,甚至是不相關的方法在被請求時,若配置設定線程A為其執行任務,則可以擷取到分頁資訊,導緻PageHelper架構以為需要分頁(tomcat連接配接池的線程對象可能出現複用)
項目使用PageHelper,但是有些同僚使用不規範,如下
// 往線程中綁定分頁資訊,卻沒有移除,造成複用該線程的方法出現分頁的現象
PageHelper.startPage(pageNum, pageSize);
return new ResponseDTO<>(new PageInfo<>(userQueryService.queryPage(name)));
如何避免
1、規範編碼
PageHelper.startPage()之後請緊跟mapper的查詢方法。中間不要隔任何代碼,中間存在任何代碼就存在發生異常導緻mapper方法未被執行的可能性。
檢查mapper.xxx(p.getId) 是否會發生NPE,如p是null或p.getId是null且接收的資料類型是primitive type。
2、全局上設定攔截器移除線程變量
在攔截器裡調用移除變量的方法最好是用順序最靠前的javax.servlet.Filter,調用
PageHelper.startPage()
移除ThreadLocal中的變量
補充其他風險
可能導緻本文的"坑"的情況
情況1:
PageHelper.startPage(pageNum, pageSize)
// 後面沒有xxxMapper.xxxx()
情況2:
PageHelper.startPage(pageNum, pageSize)
int i = 1/0;// 但是在這裡發生了空指針異常
xxxMapper.xxxx()
情況3:
PageHelper.startPage(pageNum, pageSize)
// xxxMapper是null,或p是null,或p.getId是null且接收的資料類型是primitive type
xxxMapper.xxx(p.getId)
情況4:
PageHelper.startPage(pageNum, pageSize)
// 調用了其他service,而這個service還未執行到它的mapper方法的時候發生了異常
xxxService.xxxx()
動手做實驗
下面展示了我是如何100%找出這個問題的
1、準備
- 配置tomcat連接配接池數量為1,永遠複用這個,友善重制問題
server.tomcat.max-threads=1
- 寫一個方法現查是否真的永遠複用這個線程
@RequestMapping(value = "/test/currentThread", method = RequestMethod.GET)
public Object currentThread() {
return Thread.currentThread().getId();
}
在浏覽器中反複請求該方法,确認id隻有一個。
要注意不要用chrome,chrome有病,當
server.tomcat.max-threads=2
的時候經常id不變,本人使用搜狗浏覽器。PS: 搜狗浏覽器在測負載均衡的時候也是相當好用主要沒緩存,每次請求都能看到輪詢)
PS:這是比較蠢的方法确定線程服用數是1,請大神們用别的方法。另外從邏輯學上,這個隻能證明 “還沒出現”,但 “還沒出現” 不代表不出現,是以用于證明 “線程池隻有這個id的線程” 是有點瑕疵的。
2、設定ThreadLocal變量但不移除
// 該Controller是 @RestController
@RequestMapping(value = "/test/testPageHelper", method = RequestMethod.GET)
public Obect testPageHelper() {
theadIdOfSetThreadLocal = Thread.currentThread().getId();
System.out.println("目前線程id是:" + theadIdOfSetThreadLocal);
PageHelper.startPage(1, 10);
System.out.println("擷取綁定線上程的變量===>" + PageHelper.getLocalPage());
return null;
}
3、執行出錯
@RequestMapping(value = "/test/testPageHelper2", method = RequestMethod.GET)
public ResponseDTO<Object> testPageHelper2() {
System.out.println("是否有===>" + PageHelper.getLocalPage());
long curThreadId = Thread.currentThread().getId();
System.out.println("目前線程id===>" + curThreadId);
if (curThreadId == theadIdOfSetThreadLocal) {
System.out.println("tomcat的線程池複用了");
}
User cond = new User();
// 情況一:這條語句不帶limit,不會出錯,但是會查出分頁資料,即查不出全部資料
// Object obj = userMapper.select(cond);
// 情況二:這條sql語句本身帶了limit,分頁追加limit後會有2個limit,會出錯
Object obj = userMapper.testLimit(1);
return new ResponseDTO<>(obj);
}
可以看到,查詢的sql語句被改造了。如果此時sql語句是帶limit的,就會出現sql文法錯誤!當再調用一次
/test/testPageHelper2
就能正常查詢了,這是因為
Object obj = userMapper.testLimit();
執行不管成功還是失敗,都會觸發PageHelper.clearPage(),第二次查的時候線程綁定的分頁資訊就被移除了!
4、補充一些出錯場景
例 1
@RequestMapping(value = "/test/testPageHelper2", method = RequestMethod.GET)
public ResponseDTO<Object> testPageHelper2() {
System.out.println("是否有===>" + PageHelper.getLocalPage());
long curThreadId = Thread.currentThread().getId();
System.out.println("目前線程id===>" + curThreadId);
if (curThreadId == theadIdOfSetThreadLocal) {
System.out.println("tomcat的線程池複用了");
}
User cond = new User();
// 異常導緻未進入mapper方法,ThreadLocal的變量未能清除
int i = 8/0;
//userMapper = null;
Object obj = userMapper.select(cond);
return new ResponseDTO<>(obj);
}
無論是
int i = 8/0;
還是
userMapper = null;
,将導緻未進入mapper方法,ThreadLocal的變量未能清除
例 2
@RequestMapping(value = "/test/testPageHelper2", method = RequestMethod.GET)
public ResponseDTO<Object> testPageHelper2() {
System.out.println("是否有===>" + PageHelper.getLocalPage());
long curThreadId = Thread.currentThread().getId();
System.out.println("目前線程id===>" + curThreadId);
if (curThreadId == theadIdOfSetThreadLocal) {
System.out.println("tomcat的線程池複用了");
}
// 由于接受參數是primitive類型,null轉不了int報錯,并未進入mybatis攔截器,是以并不會移除線程的分頁資料,當再次進入該方法依然可以擷取到綁定線上程的資料
Integer limit = null;
Object obj = userMapper.testLimit(limit);
return new ResponseDTO<>(obj);
}
// userMapper.testLimit 的聲明
@Select("SELECT * FROM user LIMIT #{limit}")
User testLimit(int limit);
由于接受參數是primitive類型,null轉不了int報錯,并未進入mybatis攔截器,是以并不會移除線程的分頁資料,當再次進入該方法依然可以擷取到綁定線上程的資料
例 3
@RequestMapping(value = "/test/testPageHelper2", method = RequestMethod.GET)
public ResponseDTO<Object> testPageHelper2() {
System.out.println("是否有===>" + PageHelper.getLocalPage());
long curThreadId = Thread.currentThread().getId();
System.out.println("目前線程id===>" + curThreadId);
if (curThreadId == theadIdOfSetThreadLocal) {
System.out.println("tomcat的線程池複用了");
}
// 下面這種情況雖然傳了null,但 `userMapper.select(cond)` 接收的時候并不會報錯,隻是接收後從cond中get字段的時候報錯,這時候已經進入了mybatis攔截器,即使異常,也會進入finally塊移除線程變量
User cond = null;
Object obj = userMapper.select(cond);
return new ResponseDTO<>(obj);
}
// userMapper.select(cond) 的聲明
@SelectProvider(type = SqlTemplate.class,method = "select")
List<T> select(T record);
這種情況雖然傳了null,但
userMapper.select(cond)
接收的時候并不會報錯,隻是接收後從cond中get字段的時候報錯,這時候已經進入了mybatis攔截器,即使異常,也會進入finally塊移除線程變量