笑四少 發表於 2025-12-24 09:38:00

MyBatis踩坑实录:那些不报错但让你debug到深夜的Bug

<blockquote>
<p>早上刚到公司,打开电脑,写着需求听着歌。突然钉钉一响,测试发来消息:"你那个接口报错了"。打开日志一看,MyBatis又炸了。</p>
</blockquote>
<p>说实话,MyBatis这玩意儿平时挺好用的,但有时候报的错真让人摸不着头脑。尤其是那种<strong>本地跑得好好的,一上线就炸的Bug</strong>,简直让人怀疑人生。今天就记录两个让我debug到深夜的坑,它们都有个共同特点:<strong>代码看起来完全没问题,但运行时就是莫名其妙地报错</strong>。</p>
<p>如果你也被MyBatis折磨过,这篇文章可能会让你会心一笑:原来不是我一个人踩过这些坑😂。</p>
<p><img src="https://img2024.cnblogs.com/blog/3703499/202512/3703499-20251224093434309-1370579322.png"></p>
<h2 id="坑位一arraysaslist-遇上老版本mybatis32x版本">坑位一:Arrays.asList() 遇上老版本MyBatis(3.2.x版本)</h2>
<h3 id="事故现场">事故现场</h3>
<p>周五下午四点半(是的,Bug总是在快下班时出现),测试环境突然报了个令人头大的异常:</p>
<pre><code class="language-java">   "org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.builder.BuilderException: Error evaluating expression 'userCode.size() &gt; 0'. Cause: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object
   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)
</code></pre>
<p>看到这个异常,我第一反应是:<strong>什么鬼?size()方法还能调用失败?</strong></p>
<p>来看看出问题的代码:</p>
<pre><code class="language-java">// Controller层
List&lt;String&gt; userCodes = Arrays.asList("aaa", "bbb", "ccc");
orderService.fetchOrderByUserCodes(userCodes);
</code></pre>
<pre><code class="language-xml">&lt;!-- Mapper.xml --&gt;
&lt;select id="fetchOrder" resultType="Order"&gt;
    SELECT * FROM t_order
    WHERE 1=1
    &lt;if test="userCode != null and userCode.size() &gt; 0"&gt;
      AND user_code IN
      &lt;foreach collection="userCode" item="code" open="(" close=")" separator=","&gt;
            #{code}
      &lt;/foreach&gt;
    &lt;/if&gt;
&lt;/select&gt;
</code></pre>
<p>这代码看起来没啥问题啊?userCode不为空,调个size()方法判断长度,天经地义。但它就是报错了,而且是偶现(一般偶现都有大坑)。</p>
<h3 id="先说解决方案">先说解决方案</h3>
<p>一顿<strong>ChatGPT + Google + Stack Overflow搜索</strong>后,找到了三种解决办法:</p>
<p><strong>方案1:改入参类型(最快)</strong></p>
<pre><code class="language-java">// 把Arrays.asList返回的"假ArrayList"转成真正的ArrayList
List&lt;String&gt; userCodes = new ArrayList&lt;&gt;(Arrays.asList("aaa", "bbb", "ccc"));
</code></pre>
<p>改完重新发布,问题秒解决。测试验证通过,终于可以下班了。</p>
<p><strong>方案2:改XML表达式(不改Java代码)</strong></p>
<pre><code class="language-xml">&lt;!-- 用length属性替代size()方法 --&gt;
&lt;if test="userCode != null and userCode.length &gt; 0"&gt;
    AND user_code IN ...
&lt;/if&gt;
</code></pre>
<p>这个方案也能work,而且不用改业务代码,改完就能用。</p>
<p><strong>方案3:升级MyBatis版本(治本之策)</strong></p>
<pre><code class="language-xml">&lt;!-- 从老古董版本 --&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.mybatis&lt;/groupId&gt;
    &lt;artifactId&gt;mybatis&lt;/artifactId&gt;
    &lt;version&gt;3.2.8&lt;/version&gt; &lt;!-- 2014年的版本 --&gt;
&lt;/dependency&gt;

&lt;!-- 升级到现代版本 --&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.mybatis&lt;/groupId&gt;
    &lt;artifactId&gt;mybatis&lt;/artifactId&gt;
    &lt;version&gt;3.5.13&lt;/version&gt;
&lt;/dependency&gt;
</code></pre>
<p>不过这个方案需要做全面的回归测试,周五晚上就算了,留到下周慢慢搞。</p>
<h3 id="刨根问底这到底是个啥坑">刨根问底:这到底是个啥坑?</h3>
<p>线上问题解决了,但总感觉哪里不对劲。周末闲着没事,决定把这个诡异的异常刨根问底搞清楚。翻了半天资料,终于明白是怎么回事了。</p>
<p><strong>第一层问题:类型不同</strong></p>
<p><code>Arrays.asList()</code> 返回的不是我们熟悉的 <code>java.util.ArrayList</code>,而是 <code>java.util.Arrays</code> 的一个私有静态内部类 <code>Arrays$ArrayList</code>。</p>
<p>写个简单的测试验证一下:</p>
<pre><code class="language-java">List&lt;String&gt; list1 = Arrays.asList("a", "b", "c");
List&lt;String&gt; list2 = new ArrayList&lt;&gt;(Arrays.asList("a", "b", "c"));

System.out.println(list1.getClass());
// 输出: class java.util.Arrays$ArrayList

System.out.println(list2.getClass());
// 输出: class java.util.ArrayList
</code></pre>
<p>看到没?一个是<code>Arrays$ArrayList</code>,一个是<code>ArrayList</code>,虽然都实现了List接口,但类型完全不同。</p>
<p><strong>第二层问题:访问权限异常</strong></p>
<p>MyBatis用OGNL表达式引擎来解析XML中的条件判断(比如 <code>userCode.size() &gt; 0</code>)。当OGNL尝试通过反射调用 <code>Arrays$ArrayList</code> 的 <code>size()</code> 方法时,发现这个类是 <code>private static class</code>(私有静态内部类)。</p>
<p>虽然 <code>size()</code> 方法本身是 <code>public</code> 的,但因为类本身是 <code>private</code> 修饰符,OGNL在反射访问时需要调用 <code>setAccessible(true)</code> 来绕过权限检查。问题就出在这里!</p>
<p><strong>第三层问题:并发Bug(重点来了!)</strong></p>
<p>老版本MyBatis在处理反射时有个并发问题:<strong>当需要调用私有类的方法时,会先设置 <code>accessible = true</code>,调用完再设回 <code>false</code>。但这个操作没有加锁!(非原子性)</strong></p>
<p>想象一下这个场景:</p>
<ul>
<li>线程A:设置 <code>accessible = true</code>,准备调用方法</li>
<li>线程B:也设置 <code>accessible = true</code>,然后调用方法,再设回 <code>false</code></li>
<li>线程A:此时去调用方法,发现 <code>accessible</code> 已经被B改成 <code>false</code> 了,boom!💥</li>
</ul>
<p>这就是<strong>为什么这个Bug偶尔才出现,因为它本质上是个并发问题</strong>!只有在高并发场景下,多个线程同时调用这个接口时才会触发。</p>
<p>GitHub上有人早在2014年就提了这个issue:mybatis/mybatis-3#384</p>
<p>后来MyBatis在3.3.x版本修复了这个问题,对反射操作加了同步控制,确保 <code>accessible</code> 的设置和方法调用是原子操作。</p>
<hr>
<h2 id="坑位二参数传0sql条件神秘消失之谜">坑位二:参数传0,SQL条件神秘消失之谜</h2>
<h3 id="又一个周五的故事">又一个周五的故事</h3>
<p>是的,又是周五下午(墨菲定律:Bug永远在周五出现😭)。需求很简单:查询所有"待支付"状态(status=0)的订单。十分钟写完代码:</p>
<pre><code class="language-java">// Service层
public List&lt;Order&gt; queryPendingOrders() {
    return orderMapper.queryOrderByStatus(0);// 0表示待支付
}
</code></pre>
<pre><code class="language-xml">&lt;!-- Mapper.xml --&gt;
&lt;select id="queryOrderByStatus" resultType="Order"&gt;
    SELECT * FROM t_order
    WHERE 1=1
    &lt;if test="status != null and status != ''"&gt;
      AND status = #{status}
    &lt;/if&gt;
&lt;/select&gt;
</code></pre>
<p>本地测试,完美运行。提交代码,合并主干,发布测试环境。心想这次稳了,准备提前收拾东西下班。</p>
<p>结果半小时后,测试同学发来消息:"这个接口有问题啊,怎么把所有状态的订单都查出来了?我要的是status=0的订单。"</p>
<p>我一脸懵逼:???不可能啊,我刚测过的,明明没问题!</p>
<p>打开测试环境日志,执行的SQL是:</p>
<pre><code class="language-sql">SELECT * FROM t_order WHERE 1=1
</code></pre>
<p><strong>WHERE后面的status条件呢?被吃了?</strong></p>
<h3 id="debug之旅">Debug之旅</h3>
<p>我在本地打断点,一步步调试:</p>
<ol>
<li>Controller层传入的参数:<code>status = 0</code> ✅</li>
<li>Service层收到的参数:<code>status = 0</code> ✅</li>
<li>MyBatis执行的SQL:<code>WHERE 1=1</code> ❌</li>
</ol>
<p>问题肯定出在XML的if判断上。盯着这行看了好几分钟:</p>
<pre><code class="language-xml">&lt;if test="status != null and status != ''"&gt;
</code></pre>
<p>突然灵光一现:<strong>会不会是 0 被判定成了空字符串?</strong></p>
<p>赶紧改成这样试试:</p>
<pre><code class="language-xml">&lt;if test="status != null"&gt;
    AND status = #{status}
&lt;/if&gt;
</code></pre>
<p>重新发布,问题解决!测试环境查询status=0的订单,正常返回了。</p>
<h3 id="原理揭秘ognl的类型转换陷阱">原理揭秘:OGNL的类型转换陷阱</h3>
<p>这又是一个MyBatis(准确说是OGNL)的经典坑。<strong>这个坑比第一个还隐蔽,因为它不会报错,而是悄悄地把你的条件吃掉</strong>。</p>
<p><strong>OGNL的求值逻辑</strong></p>
<p>MyBatis的 <code>&lt;if&gt;</code> 标签用的是OGNL表达式引擎。当你写 <code>status != ''</code> 时,OGNL内部会经历这样的判断流程:</p>
<ol>
<li>先通过 <code>OgnlCache.getValue()</code> 获取表达式的值(这里表达式返回了false)</li>
<li>然后在 <code>ExpressionEvaluator.evaluateBoolean()</code> 中判断这个值,根据返回的不同类型作不同判断,最终返回boolean类型结果。</li>
</ol>
<p>先看第二步!OGNL对不同类型有不同的判断逻辑:</p>
<pre><code class="language-java">// 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;
}
</code></pre>
<p><strong>类型转换的坑</strong></p>
<p>当你写 <code>status != ''</code> 时,从<code>OgnlCache.getValue()</code>往下不断追溯,OGNL最终会调用 <code>compareWithConversion</code> 方法做类型转换比较。这个方法会把两边的值都转成同一类型再比较:</p>
<ul>
<li>数值 <code>0</code> 会被转成 <code>double类型:0.0</code></li>
<li>空字符串 <code>""</code> 也会被转成 <code>double类型:0.0</code></li>
</ul>
<p>结果就是 <code>0 == ""</code> 被判定为 <code>true</code>,导致 <code>status != ''</code> 返回 <code>false</code>,你的if条件不成立,SQL条件就没了!</p>
<p><strong>为什么本地测试没问题?</strong></p>
<p>是因为我本地测试时传的参数是 <code>status=1</code> 或其他非0值,而测试环境刚好传了 <code>status=0</code>。这种Bug特别隐蔽,因为它不会报错,只是查询结果不符合预期。</p>
<p><strong>这个坑的适用范围</strong>:</p>
<ul>
<li>参数是<strong>数值类型</strong>(Integer、Long等)的 <strong>0</strong></li>
<li>XML中写了 <code>!= ''</code> 的空字符串判断</li>
<li><strong>字符串"0"不受影响</strong>(<code>"0" != ''</code> 正常判定为true)</li>
</ul>
<p>感兴趣的可以自己去看看MyBatis源码中的 <code>ExpressionEvaluator</code> 类和OGNL的 <code>OgnlOps.compareWithConversion</code> 方法,就能明白整个转换过程了。</p>
<h3 id="正确姿势大全">正确姿势大全</h3>
<p>根据不同场景,我总结了几种写法:</p>
<p><strong>场景1:参数是Integer/Long等包装类型</strong></p>
<pre><code class="language-xml">&lt;!-- 推荐:只判null,数值0是有效值 --&gt;
&lt;if test="status != null"&gt;
    AND status = #{status}
&lt;/if&gt;
</code></pre>
<p><strong>场景2:参数可能是数值也可能是字符串</strong></p>
<pre><code class="language-xml">&lt;!-- 显式包含0的判断 --&gt;
&lt;if test="status != null and (status != '' or status == 0)"&gt;
    AND status = #{status}
&lt;/if&gt;
</code></pre>
<p><strong>场景3:字符串类型参数</strong></p>
<pre><code class="language-xml">&lt;!-- 字符串正常判断,不会有坑 --&gt;
&lt;if test="userName != null and userName != ''"&gt;
    AND user_name = #{userName}
&lt;/if&gt;
</code></pre>
<h3 id="避坑建议">避坑建议</h3>
<ol>
<li>
<p><strong>数值类型参数,别用空字符串判断</strong></p>
<pre><code class="language-xml">&lt;!-- ❌ 错误写法 --&gt;
&lt;if test="count != null and count != ''"&gt;

&lt;!-- ✅ 正确写法 --&gt;
&lt;if test="count != null"&gt;
</code></pre>
</li>
<li>
<p><strong>记住数值类型和字符串类型要区分对待</strong></p>
<ul>
<li>数值类型(Integer、Long):只判<code>!= null</code></li>
<li>字符串类型:判<code>!= null and != ''</code></li>
</ul>
</li>
<li>
<p><strong>确实需要兼容的场景,明确写出0的判断</strong></p>
<pre><code class="language-xml">&lt;if test="value != null and (value != '' or value == 0)"&gt;
</code></pre>
</li>
<li>
<p><strong>升级MyBatis版本并不能解决这个问题</strong>(因为这是OGNL的特性,不是bug)</p>
</li>
</ol>
<hr>
<h2 id="总结与最佳实践">总结与最佳实践</h2>
<p>这两个问题虽然表现形式不同,但都源于<strong>OGNL表达式引擎</strong>的特殊行为。理解这些陷阱背后的机制,能帮助我们写出更健壮的MyBatis代码。</p>
<h3 id="核心要点">核心要点</h3>
<ol>
<li>
<p><strong>Arrays.asList()的陷阱</strong></p>
<ul>
<li>
<p><code>Arrays.asList()</code> 返回的不是真正的ArrayList,是Arrays的私有静态内部类</p>
</li>
<li>
<p>老版本MyBatis(3.3.x之前)的OGNL表达式引擎:<strong>反射访问私有静态内部类的public方法时,在设置 <code>accessible = true</code>参数时会有并发问题</strong></p>
</li>
<li>
<p><strong>解决方法</strong>:包装成真正的ArrayList或升级MyBatis版本</p>
</li>
</ul>
</li>
<li>
<p><strong>「if」标签:数值0判空的陷阱</strong></p>
<ul>
<li>
<p>OGNL会将数值0与空字符串做类型转换比较</p>
</li>
<li>
<p><code>status != ''</code> 会返回false,导致「if」表达式条件不满足,数值0的查询条件被过滤</p>
</li>
<li>
<p><strong>解决方法</strong>:数值类型只判断<code>!= null</code>,不要判断空字符串</p>
</li>
</ul>
</li>
</ol>
<p>希望这篇文章能帮你避开这些坑,让周五下班不再是奢望 😄</p>
<hr>
<blockquote>
<p>文章的最后,想和你多聊两句。</p>
<p>技术之路,常常是热闹与孤独并存。那些深夜的调试、灵光一闪的方案、还有踩坑爬起后的顿悟,如果能有人一起聊聊,该多好。</p>
<p>为此,我建了一个小花园——我的微信公众号「<strong>[努力的小郑]</strong>」。</p>
<p>这里没有高深莫测的理论堆砌,只有我对后端开发、系统设计和工程实践的持续思考与沉淀。它更像我的<strong>数字笔记本</strong>,记录着那些值得被记住的解决方案和思维火花。</p>
<p>如果你觉得今天的文章还有一点启发,或者单纯想找一个同行者偶尔聊聊技术、谈谈思考,那么,欢迎你来坐坐。<br>
<img src="https://img2024.cnblogs.com/blog/3703499/202601/3703499-20260105210259813-964799315.jpg"></p>
<p>愿你前行路上,总有代码可写,有梦可追,也有灯火可亲。</p>
</blockquote><br><br>
来源:https://www.cnblogs.com/xzqcsj/p/19390256
頁: [1]
查看完整版本: MyBatis踩坑实录:那些不报错但让你debug到深夜的Bug