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,不要判断空字符串

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


相关推荐
摇滚侠16 分钟前
DBeaver 导入数据库 导入 SQL 文件 MySQL 备份恢复
java·数据库·mysql
keep one's resolveY40 分钟前
SpringBoot实现重试机制的四种方案
java·spring boot·后端
天空属于哈夫克31 小时前
企业微信API常见的错误和解决方案
java·数据库·企业微信
摇滚侠2 小时前
VMvare 虚拟机 Oracle19c 安装步骤,远程连接 Oracle19c,百度网盘安装包
java·oracle
梁萌2 小时前
idea报错找不到XX包的解决方法
java·intellij-idea·启动报错·缺少包
女生也可以敲代码2 小时前
AI时代下的50道前端开发面试题:从基础到大模型应用
前端·面试
Agent产品评测局2 小时前
生产排期与MES/ERP系统打通,实操方法详解 —— 2026企业级智能体自动化选型与实战指南
java·运维·人工智能·ai·chatgpt·自动化
阿丰资源3 小时前
基于Spring Boot的电影城管理系统(直接运行)
java·spring boot·后端
呱牛do it3 小时前
企业级门户网站设计与实现:基于SpringBoot + Vue3的全栈解决方案(Day 8)
java
消失的旧时光-19434 小时前
Spring Boot 工程化进阶:统一返回 + 全局异常 + AOP 通用工具包
java·spring boot·后端·aop·自定义注解