早上剛到公司,打開電腦,寫着需求聽着歌。突然釘釘一響,測試發來消息:"你那個接口報錯了"。打開日誌一看,MyBatis又炸了。
説實話,MyBatis這玩意兒平時挺好用的,但有時候報的錯真讓人摸不着頭腦。尤其是那種本地跑得好好的,一上線就炸的Bug,簡直讓人懷疑人生。今天就記錄兩個讓我debug到深夜的坑,它們都有個共同特點:代碼看起來完全沒問題,但運行時就是莫名其妙地報錯。
如果你也被MyBatis折磨過,這篇文章可能會讓你會心一笑:原來不是我一個人踩過這些坑😂。

坑位一:Arrays.asList() 遇上老版本MyBatis(3.2.x版本)
事故現場
週五下午四點半(是的,Bug總是在快下班時出現),測試環境突然報了個令人頭大的異常:
"org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.builder.BuilderException: Error evaluating expression 'userCode.size() > 0'. Cause: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object [aaa, bbb] [java.lang.IllegalAccessException: Class org.apache.ibatis.ognl.OgnlRuntime can not access a member of class java.util.Collections$UnmodifiableCollection with modifiers "public"]
at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:73)
at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:364)
at $Proxy15.selectList(Unknown Source)
at org.mybatis.spring.SqlSessionTemplate.selectList(SqlSessionTemplate.java:194)
at org.apache.ibatis.binding.MapperMethod.executeForMany(MapperMethod.java:114)
at org.apache.ibatis.binding.MapperMethod.execute(MapperMethod.java:58)
at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:43)
at $Proxy18.fetchOrder(Unknown Source)
at com.xx.xx.server.impl.XX.fetchOrderByUnitNo(RechargeCardBillServiceImpl.java:351)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:39)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:25)
at java.lang.reflect.Method.invoke(Method.java:597)
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:317)
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:198)
at $Proxy26.fetchOrderByUnitNo(Unknown Source)
at com.ofpay.ofdc.task.AbstractRechargeTask.run(AbstractRechargeTask.java:65)
at java.lang.Thread.run(Thread.java:662)
看到這個異常,我第一反應是:什麼鬼?size()方法還能調用失敗?
來看看出問題的代碼:
// Controller層
List<String> userCodes = Arrays.asList("aaa", "bbb", "ccc");
orderService.fetchOrderByUserCodes(userCodes);
<!-- Mapper.xml -->
<select id="fetchOrder" resultType="Order">
SELECT * FROM t_order
WHERE 1=1
<if test="userCode != null and userCode.size() > 0">
AND user_code IN
<foreach collection="userCode" item="code" open="(" close=")" separator=",">
#{code}
</foreach>
</if>
</select>
這代碼看起來沒啥問題啊?userCode不為空,調個size()方法判斷長度,天經地義。但它就是報錯了,而且是偶現(一般偶現都有大坑)。
先説解決方案
一頓ChatGPT + Google + Stack Overflow搜索後,找到了三種解決辦法:
方案1:改入參類型(最快)
// 把Arrays.asList返回的"假ArrayList"轉成真正的ArrayList
List<String> userCodes = new ArrayList<>(Arrays.asList("aaa", "bbb", "ccc"));
改完重新發布,問題秒解決。測試驗證通過,終於可以下班了。
方案2:改XML表達式(不改Java代碼)
<!-- 用length屬性替代size()方法 -->
<if test="userCode != null and userCode.length > 0">
AND user_code IN ...
</if>
這個方案也能work,而且不用改業務代碼,改完就能用。
方案3:升級MyBatis版本(治本之策)
<!-- 從老古董版本 -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.2.8</version> <!-- 2014年的版本 -->
</dependency>
<!-- 升級到現代版本 -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.13</version>
</dependency>
不過這個方案需要做全面的迴歸測試,週五晚上就算了,留到下週慢慢搞。
刨根問底:這到底是個啥坑?
線上問題解決了,但總感覺哪裏不對勁。週末閒着沒事,決定把這個詭異的異常刨根問底搞清楚。翻了半天資料,終於明白是怎麼回事了。
第一層問題:類型不同
Arrays.asList() 返回的不是我們熟悉的 java.util.ArrayList,而是 java.util.Arrays 的一個私有靜態內部類 Arrays$ArrayList。
寫個簡單的測試驗證一下:
List<String> list1 = Arrays.asList("a", "b", "c");
List<String> list2 = new ArrayList<>(Arrays.asList("a", "b", "c"));
System.out.println(list1.getClass());
// 輸出: class java.util.Arrays$ArrayList
System.out.println(list2.getClass());
// 輸出: class java.util.ArrayList
看到沒?一個是Arrays$ArrayList,一個是ArrayList,雖然都實現了List接口,但類型完全不同。
第二層問題:訪問權限異常
MyBatis用OGNL表達式引擎來解析XML中的條件判斷(比如 userCode.size() > 0)。當OGNL嘗試通過反射調用 Arrays$ArrayList 的 size() 方法時,發現這個類是 private static class(私有靜態內部類)。
雖然 size() 方法本身是 public 的,但因為類本身是 private 修飾符,OGNL在反射訪問時需要調用 setAccessible(true) 來繞過權限檢查。問題就出在這裏!
第三層問題:併發Bug(重點來了!)
老版本MyBatis在處理反射時有個併發問題:當需要調用私有類的方法時,會先設置 accessible = true,調用完再設回 false。但這個操作沒有加鎖!(非原子性)
想象一下這個場景:
- 線程A:設置
accessible = true,準備調用方法 - 線程B:也設置
accessible = true,然後調用方法,再設回false - 線程A:此時去調用方法,發現
accessible已經被B改成false了,boom!💥
這就是為什麼這個Bug偶爾才出現,因為它本質上是個併發問題!只有在高併發場景下,多個線程同時調用這個接口時才會觸發。
GitHub上有人早在2014年就提了這個issue:mybatis/mybatis-3#384
後來MyBatis在3.3.x版本修復了這個問題,對反射操作加了同步控制,確保 accessible 的設置和方法調用是原子操作。
坑位二:參數傳0,SQL條件神秘消失之謎
又一個週五的故事
是的,又是週五下午(墨菲定律:Bug永遠在週五出現😭)。需求很簡單:查詢所有"待支付"狀態(status=0)的訂單。十分鐘寫完代碼:
// Service層
public List<Order> queryPendingOrders() {
return orderMapper.queryOrderByStatus(0); // 0表示待支付
}
<!-- Mapper.xml -->
<select id="queryOrderByStatus" resultType="Order">
SELECT * FROM t_order
WHERE 1=1
<if test="status != null and status != ''">
AND status = #{status}
</if>
</select>
本地測試,完美運行。提交代碼,合併主幹,發佈測試環境。心想這次穩了,準備提前收拾東西下班。
結果半小時後,測試同學發來消息:"這個接口有問題啊,怎麼把所有狀態的訂單都查出來了?我要的是status=0的訂單。"
我一臉懵逼:???不可能啊,我剛測過的,明明沒問題!
打開測試環境日誌,執行的SQL是:
SELECT * FROM t_order WHERE 1=1
WHERE後面的status條件呢?被吃了?
Debug之旅
我在本地打斷點,一步步調試:
- Controller層傳入的參數:
status = 0✅ - Service層收到的參數:
status = 0✅ - MyBatis執行的SQL:
WHERE 1=1❌
問題肯定出在XML的if判斷上。盯着這行看了好幾分鐘:
<if test="status != null and status != ''">
突然靈光一現:會不會是 0 被判定成了空字符串?
趕緊改成這樣試試:
<if test="status != null">
AND status = #{status}
</if>
重新發布,問題解決!測試環境查詢status=0的訂單,正常返回了。
原理揭秘:OGNL的類型轉換陷阱
這又是一個MyBatis(準確説是OGNL)的經典坑。這個坑比第一個還隱蔽,因為它不會報錯,而是悄悄地把你的條件吃掉。
OGNL的求值邏輯
MyBatis的 <if> 標籤用的是OGNL表達式引擎。當你寫 status != '' 時,OGNL內部會經歷這樣的判斷流程:
- 先通過
OgnlCache.getValue()獲取表達式的值(這裏表達式返回了false) - 然後在
ExpressionEvaluator.evaluateBoolean()中判斷這個值,根據返回的不同類型作不同判斷,最終返回boolean類型結果。
先看第二步!OGNL對不同類型有不同的判斷邏輯:
// ExpressionEvaluator.evaluateBoolean()方法 OGNL的判斷邏輯
public boolean evaluateBoolean(String expression, Object parameterObject) {
// 這裏value返回的是false
Object value = OgnlCache.getValue(expression, parameterObject);
if (value instanceof Boolean) {
// 因此會走到這裏,返回false
return (Boolean) value;
}
if (value instanceof Number) {
return new BigDecimal(String.valueOf(value)).compareTo(BigDecimal.ZERO) != 0;
}
return value != null;
}
類型轉換的坑
當你寫 status != '' 時,從OgnlCache.getValue()往下不斷追溯,OGNL最終會調用 compareWithConversion 方法做類型轉換比較。這個方法會把兩邊的值都轉成同一類型再比較:
- 數值
0會被轉成double類型:0.0 - 空字符串
""也會被轉成double類型:0.0
結果就是 0 == "" 被判定為 true,導致 status != '' 返回 false,你的if條件不成立,SQL條件就沒了!
為什麼本地測試沒問題?
是因為我本地測試時傳的參數是 status=1 或其他非0值,而測試環境剛好傳了 status=0。這種Bug特別隱蔽,因為它不會報錯,只是查詢結果不符合預期。
這個坑的適用範圍:
- 參數是數值類型(Integer、Long等)的 0
- XML中寫了
!= ''的空字符串判斷 - 字符串"0"不受影響(
"0" != ''正常判定為true)
感興趣的可以自己去看看MyBatis源碼中的 ExpressionEvaluator 類和OGNL的 OgnlOps.compareWithConversion 方法,就能明白整個轉換過程了。
正確姿勢大全
根據不同場景,我總結了幾種寫法:
場景1:參數是Integer/Long等包裝類型
<!-- 推薦:只判null,數值0是有效值 -->
<if test="status != null">
AND status = #{status}
</if>
場景2:參數可能是數值也可能是字符串
<!-- 顯式包含0的判斷 -->
<if test="status != null and (status != '' or status == 0)">
AND status = #{status}
</if>
場景3:字符串類型參數
<!-- 字符串正常判斷,不會有坑 -->
<if test="userName != null and userName != ''">
AND user_name = #{userName}
</if>
避坑建議
-
數值類型參數,別用空字符串判斷
<!-- ❌ 錯誤寫法 --> <if test="count != null and count != ''"> <!-- ✅ 正確寫法 --> <if test="count != null"> -
記住數值類型和字符串類型要區分對待
- 數值類型(Integer、Long):只判
!= null - 字符串類型:判
!= null and != ''
- 數值類型(Integer、Long):只判
-
確實需要兼容的場景,明確寫出0的判斷
<if test="value != null and (value != '' or value == 0)"> -
升級MyBatis版本並不能解決這個問題(因為這是OGNL的特性,不是bug)
總結與最佳實踐
這兩個問題雖然表現形式不同,但都源於OGNL表達式引擎的特殊行為。理解這些陷阱背後的機制,能幫助我們寫出更健壯的MyBatis代碼。
核心要點
-
Arrays.asList()的陷阱
-
Arrays.asList()返回的不是真正的ArrayList,是Arrays的私有靜態內部類 -
老版本MyBatis(3.3.x之前)的OGNL表達式引擎:反射訪問私有靜態內部類的public方法時,在設置
accessible = true參數時會有併發問題 -
解決方法:包裝成真正的ArrayList或升級MyBatis版本
-
-
「if」標籤:數值0判空的陷阱
-
OGNL會將數值0與空字符串做類型轉換比較
-
status != ''會返回false,導致「if」表達式條件不滿足,數值0的查詢條件被過濾 -
解決方法:數值類型只判斷
!= null,不要判斷空字符串
-
希望這篇文章能幫你避開這些坑,讓週五下班不再是奢望 😄