MyBatis 两个隐蔽深坑实录:Arrays.asList() 与数字 0 的“离奇失踪”

早上刚到公司,打开电脑,写着需求听着歌。突然钉钉一响,测试发来消息:"你那个接口报错了"。打开日志一看,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$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)的订单。十分钟写完代码:

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之旅

我在本地打断点,一步步调试:

  1. Controller层传入的参数:status = 0
  2. Service层收到的参数:status = 0
  3. 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内部会经历这样的判断流程:

  1. 先通过 OgnlCache.getValue() 获取表达式的值(这里表达式返回了false)
  2. 然后在 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>

避坑建议

  1. 数值类型参数,别用空字符串判断

    xml 复制代码
    <!-- ❌ 错误写法 -->
    <if test="count != null and count != ''">
    
    <!-- ✅ 正确写法 -->
    <if test="count != null">
  2. 记住数值类型和字符串类型要区分对待

    • 数值类型(Integer、Long):只判!= null
    • 字符串类型:判!= null and != ''
  3. 确实需要兼容的场景,明确写出0的判断

    xml 复制代码
    <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,不要判断空字符串

希望这篇文章能帮你避开这些坑,让周五下班不再是奢望 😄


相关推荐
掘根2 小时前
【消息队列项目】消费者管理模块实现
java·开发语言
故渊ZY2 小时前
SpringMVC核心原理与实战全解析
java·spring
秋邱2 小时前
Java基础语法核心:程序结构、注释规范、变量常量与数据类型
java·开发语言·spring cloud·tomcat·hibernate
勤劳打代码2 小时前
循序渐进 —— Flutter GetX 状态管理
flutter·面试·前端框架
故渊ZY2 小时前
SpringBoot与Redis实战:企业级缓存进阶指南
java·spring boot
廋到被风吹走2 小时前
【Spring】核心类研究价值排行榜
java·后端·spring
wanghowie2 小时前
01.05 Java基础篇|I/O、NIO 与序列化实战
java·开发语言·nio
孔明兴汉2 小时前
springboot4 项目从零搭建
java·java-ee·springboot
APIshop2 小时前
Java 爬虫 1688 评论 API 接口实战解析
java·开发语言·爬虫