早上刚到公司,打开电脑,写着需求听着歌。突然钉钉一响,测试发来消息:"你那个接口报错了"。打开日志一看,MyBatis又炸了。
说实话,MyBatis这玩意儿平时挺好用的,但有时候报的错真让人摸不着头脑。尤其是那种本地跑得好好的,一上线就炸的Bug ,简直让人怀疑人生。今天就记录两个让我debug到深夜的坑,它们都有个共同特点:代码看起来完全没问题,但运行时就是莫名其妙地报错。
如果你也被MyBatis折磨过,这篇文章可能会让你会心一笑:原来不是我一个人踩过这些坑😂。

坑位一:Arrays.asList() 遇上老版本MyBatis(3.2.x版本)
事故现场
周五下午四点半(是的,Bug总是在快下班时出现),测试环境突然报了个令人头大的异常:
java
"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()方法还能调用失败?
来看看出问题的代码:
java
// Controller层
List<String> userCodes = Arrays.asList("aaa", "bbb", "ccc");
orderService.fetchOrderByUserCodes(userCodes);
xml
<!-- 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:改入参类型(最快)
java
// 把Arrays.asList返回的"假ArrayList"转成真正的ArrayList
List<String> userCodes = new ArrayList<>(Arrays.asList("aaa", "bbb", "ccc"));
改完重新发布,问题秒解决。测试验证通过,终于可以下班了。
方案2:改XML表达式(不改Java代码)
xml
<!-- 用length属性替代size()方法 -->
<if test="userCode != null and userCode.length > 0">
AND user_code IN ...
</if>
这个方案也能work,而且不用改业务代码,改完就能用。
方案3:升级MyBatis版本(治本之策)
xml
<!-- 从老古董版本 -->
<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。
写个简单的测试验证一下:
java
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)的订单。十分钟写完代码:
java
// Service层
public List<Order> queryPendingOrders() {
return orderMapper.queryOrderByStatus(0); // 0表示待支付
}
xml
<!-- 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是:
sql
SELECT * FROM t_order WHERE 1=1
WHERE后面的status条件呢?被吃了?
Debug之旅
我在本地打断点,一步步调试:
- Controller层传入的参数:
status = 0✅ - Service层收到的参数:
status = 0✅ - MyBatis执行的SQL:
WHERE 1=1❌
问题肯定出在XML的if判断上。盯着这行看了好几分钟:
xml
<if test="status != null and status != ''">
突然灵光一现:会不会是 0 被判定成了空字符串?
赶紧改成这样试试:
xml
<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对不同类型有不同的判断逻辑:
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;
}
类型转换的坑
当你写 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等包装类型
xml
<!-- 推荐:只判null,数值0是有效值 -->
<if test="status != null">
AND status = #{status}
</if>
场景2:参数可能是数值也可能是字符串
xml
<!-- 显式包含0的判断 -->
<if test="status != null and (status != '' or status == 0)">
AND status = #{status}
</if>
场景3:字符串类型参数
xml
<!-- 字符串正常判断,不会有坑 -->
<if test="userName != null and userName != ''">
AND user_name = #{userName}
</if>
避坑建议
-
数值类型参数,别用空字符串判断
xml<!-- ❌ 错误写法 --> <if test="count != null and count != ''"> <!-- ✅ 正确写法 --> <if test="count != null"> -
记住数值类型和字符串类型要区分对待
- 数值类型(Integer、Long):只判
!= null - 字符串类型:判
!= null and != ''
- 数值类型(Integer、Long):只判
-
确实需要兼容的场景,明确写出0的判断
xml<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,不要判断空字符串
-
希望这篇文章能帮你避开这些坑,让周五下班不再是奢望 😄