博客 / 詳情

返回

MyBatis踩坑實錄:那些不報錯但讓你debug到深夜的Bug

早上剛到公司,打開電腦,寫着需求聽着歌。突然釘釘一響,測試發來消息:"你那個接口報錯了"。打開日誌一看,MyBatis又炸了。

説實話,MyBatis這玩意兒平時挺好用的,但有時候報的錯真讓人摸不着頭腦。尤其是那種本地跑得好好的,一上線就炸的Bug,簡直讓人懷疑人生。今天就記錄兩個讓我debug到深夜的坑,它們都有個共同特點:代碼看起來完全沒問題,但運行時就是莫名其妙地報錯

如果你也被MyBatis折磨過,這篇文章可能會讓你會心一笑:原來不是我一個人踩過這些坑😂。

image

坑位一: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$ArrayListsize() 方法時,發現這個類是 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之旅

我在本地打斷點,一步步調試:

  1. Controller層傳入的參數:status = 0
  2. Service層收到的參數:status = 0
  3. 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內部會經歷這樣的判斷流程:

  1. 先通過 OgnlCache.getValue() 獲取表達式的值(這裏表達式返回了false)
  2. 然後在 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>

避坑建議

  1. 數值類型參數,別用空字符串判斷

    <!-- ❌ 錯誤寫法 -->
    <if test="count != null and count != ''">
    
    <!-- ✅ 正確寫法 -->
    <if test="count != null">
    
  2. 記住數值類型和字符串類型要區分對待

    • 數值類型(Integer、Long):只判!= null
    • 字符串類型:判!= null and != ''
  3. 確實需要兼容的場景,明確寫出0的判斷

    <if test="value != null and (value != '' or value == 0)">
    
  4. 升級MyBatis版本並不能解決這個問題(因為這是OGNL的特性,不是bug)


總結與最佳實踐

這兩個問題雖然表現形式不同,但都源於OGNL表達式引擎的特殊行為。理解這些陷阱背後的機制,能幫助我們寫出更健壯的MyBatis代碼。

核心要點

  1. Arrays.asList()的陷阱

    • Arrays.asList() 返回的不是真正的ArrayList,是Arrays的私有靜態內部類

    • 老版本MyBatis(3.3.x之前)的OGNL表達式引擎:反射訪問私有靜態內部類的public方法時,在設置 accessible = true參數時會有併發問題

    • 解決方法:包裝成真正的ArrayList或升級MyBatis版本

  2. 「if」標籤:數值0判空的陷阱

    • OGNL會將數值0與空字符串做類型轉換比較

    • status != '' 會返回false,導致「if」表達式條件不滿足,數值0的查詢條件被過濾

    • 解決方法:數值類型只判斷!= null,不要判斷空字符串

希望這篇文章能幫你避開這些坑,讓週五下班不再是奢望 😄


user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.