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

说实话,MyBatis这玩意儿平时挺好用的,但有时候报的错真让人摸不着头脑。尤其是那种本地跑得好好的,一上线就炸的Bug,简直让人怀疑人生。今天就记录两个让我debug到深夜的坑,它们都有个共同特点:代码看起来完全没问题,但运行时就是莫名其妙地报错

如果你也被MyBatis折磨过,这篇文章可能会让你会心一笑:原来不是我一个人踩过这些坑😂。

坑位一: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