某天晚上,美团到店事业群某项系统服务正在进行常规需求的上线。因为在发布时,提示 inf-bom 版本需要升级,于是我们就将 inf-bom 版本从 1.3.9.6 升级至 1.4.2.1,如下图 1 所示:
不过,当服务上线后,开始陆续出现了一些更新系统交互日志方面的报警,这属于系统的辅助流程,报警如下方代码所示。我们发现都是跟 MyBatis 相关的报警,说明在进行类型转换的时候,系统产生了强转错误。
复制代码
更新开票请求返回日志, id:{#######}, response:{{"code":XXX,"data":{"callType":3,"code":XXX,"msg":"XXXX","shopId":XXXXX,"taxPlateDockType":"XXXXXXX"},"msg":"XXXXX","success":XXXX}} nested execption is org.apache.ibatis.type.TypeException: Couldnotsetparametersformapping: ParameterMapping{property='updateTime',mode=IN,javaType=class java.lang.String, jdbcTyp=null,resultMapId='null',jdbcTypeName='null',expression='null'}.Cause org.apache.ibatis.type.TypeException,Errorsetting nonnullparameter #2 with JdbcTypenull. Try setting a different JdbcTypeforthis parameterora different configuration property.Cause java.lang.ClassCastException:java.time.LocalDateTime cannot be casttojava.lang.String
因为报警这一块代码,属于历史功能,如果失败并不会影响主流程。但在定位期间,如果频繁报警的话,就会造成一定的干扰。因此,我们马上采取了回滚操作,将 inf-bom 的版本回滚至历史版本,直至报警消失,然后再进行问题的定位和分析。以下章节就是我们对报警原因的定位及原因详细分析的介绍,希望这些思路能够对大家有所启发和帮助。
在回滚完毕后,我们开始具体分析报警产生的主要原因,于是进行了以下几步的排查。
第一步,查看了报警的 Mapper 方法,如下代码段所示。这个是接收返回参数,根据主键 id,更新具体响应内容和时间的代码,入参有 3 个,类型分别为 long、String 和 LocalDateTime。
复制代码
intupdateResponse(@Param("id")long id,@Param("response")String response,@Param("updateTime")LocalDateTime updateTime);
第二步,我们查看了 Mapper 方法对应的 XML 文件,如下代码段所示,对应的 parameterType 类型是 String,而实际参数的类型包括 long、String 以及 LocalDateTime。
复制代码
<updateid="updateResponse"parameterType="java.lang.String"> UPDATE invoice_log SET response = #{response}, update_time = #{updateTime} WHERE id = #{id} </update>
第三步,我们查看了 MyBatis 上线前后的版本,报警的内容是:MyBatis 在处理 SQL 语句时,发现不能将 LocalDateTime 转型为 String,这一段逻辑在上线前是可以正常运行的,并且上线的业务逻辑对这段历史代码无改动。因此,我们猜测是因为 inf-bom 的升级,从而导致 MyBatis 的版本发生了变化,对某些历史功能不再支持了。MyBatis 版本上线前后的变化如下表所示:
第四步,我们通过第三步可以得到,在这次 inf-bom 的版本升级中,MyBatis 的版本直接升了两个大版本,因此我们可以基本将原因猜测为 MyBatis 升级跨度较大,导致部分历史功能没有兼容支持,从而引起线上 SQL 的更新报错。
第五步,为了具体验证第四步的想法,我们通过 UT 的方式,将 MyBatis 的版本不断从 3.4.6 往下降,直至没有报错的位置。最终的定位是:当 MyBatis 版本为 3.2.3 时,线上代码是正常可用的,但只要升一个版本,也就是自 3.2.4 开始,就开始不兼容目前的用法。不过,我们当时的思路并不是很好,应该从小版本逐个往上升或者使用二分法,可以加速定位版本的效率。
最后,我们定位到了产生报警的根本问题。总的来说,MyBatis 版本由 inf-bom 引入而来,inf-bom 从 3.2.3 升级到了 3.4.6 版本,而 MyBatis 自 3.2.4 开始就不支持目前系统内的 SQL Mapper 的用法,因此在升级后,线上就出现了频繁报警的问题。
问题已经定位,但是还有很多事情我们需要弄清楚。为什么版本升级后就不兼容历史的用法?具体是哪一块内容不兼容?背后的原理又是什么?下文,我们会详细进行分析。
首先,从报错的原因上来看,请注意这句话:“Caused by: java.lang.ClassCastException: java.lang.LocalDateTime cannot be cast to java.lang.String.”MyBatis 在构建 SQL 语句时,发现时间字段类型 LocalDateTime 不能强制转为 String 类型。而这个 SQL 对应的 XML 配置在 3.2.3 的版本是可以正常使用的,那么我们先从 MyBatis 的 Release Log 上查看 3.2.4 版本到底发生了什么变化。
An special remark about this feature. Previous versions ignored the “parameterType” attribute and used the actual parameter to calculate bindings. This version builds the binding information during startup and the “parameterType” attribute is used if present (though it is still optional), so in case you had a wrong value for it you will have to change it
从官网的 Release Log 可以看到,MyBatis 在 3.2.4 以前的版本,会忽略 XML 中的 parameterType 这个属性,并且使用真实的变量类型进行值的处理。但在 3.2.4 及以后的版本中,这个属性就被启用了,如果出现类型不匹配的话,就会出现转型失败的报错。这也提示我们开发者,在升级版本时,需要检查系统内的 XML 配置,使类型进行匹配,或者不设置该属性,让 MyBatis 自行进行计算。
根据以上内容,我们可以了解到,在版本升级后,MyBatis 在构建 SQL 语句,在获取字段值时的逻辑发生了变化。接下来我们将通过一个简单的示例,来了解一下 MyBatis 在获取字段值这一块的具体代码流程是怎样的,以 3.2.3 版本为例。
我们看一下配置,首先定义一个通过主键 id 获取学生信息的方法,仿造系统内的历史代码,我们将 parameterType 定义为 java.lang.String,这和方法对应的参数 int 并不相同。
复制代码
publicStudentEntity getStudentById(@Param("id")intid); <selectid="getStudentById" parameterType="java.lang.String" resultType="entity.StudentEntity"> SELECTid,name,ageFROMstudentWHEREid = #{id} </select>
MyBatis 框架要做的事情,就是在运行 getStudentById(2) 的时候,将 #{id}进行替换,使 SQL 语句变成 SELECT id,name,age FROM student WHERE id = 2。MyBatis 要将 SQL 语句完整替换成带参数值的版本,需要经历框架初始化以及实际运行时动态替换这两个部分。因为 MyBatis 的代码非常多,接下来我们主要阐释和本次案例相关的内容。
在框架初始化阶段主要包括以下流程,如下图 2 所示:
在框架初始化阶段,有一些组件会被构建,逐一做个简单的介绍:
接下来,我们主要关注 SqlSource,这个类会负责生成 SQL 语句,这也是本次案例中,3.2.3 和 3.2.4 差异比较大的一个地方。下面,我们会介绍一些源码。
在构建 Configuration 的过程中,会涉及到构建对应每一条 SQL 语句对应的 MappedStatement,parameterTypeClass 就是根据我们在 XML 配置中写的 parameterType 转换而来,值为 java.lang.String,在构建 SqlSource 时,传入这个参数。如下图 3 所示:
在 SqlSource 的构建中,parameterType 参数其实是被忽略不用的,并没有继续往下传递,这跟官方的描述是一致的。因为 3.2.4 之前这个 parameterType 属性被忽略了,然后就创建了 DynamicSqlSource,这个类主要是用于处理 MyBatis 动态 SQL 的类。如下图 4 所示:
在框架初始化的阶段,需要介绍的内容,在 3.2.3 版本已经介绍完毕。当执行 getStudentById 方法时,MyBatis 的流程如下图 5 所示。因受限于图片长度,我们对布局进行了一些调整:
在具体执行阶段,也涉及到一些组件,我们需要做简单的了解:
我们主要关注获取 BoundSql 以及参数化语句的流程,这也是 3.2.3 和 3.2.4 差异比较大的一个地方。在进入 Executor 的 Query 方法后,会首先通过对应的 MappedStatement 来获取 BoundSql,用来帮助我们动态生成 SQL 语句,里面绑定了对应的 SQL 以及参数映射关系。在构建框架阶段,我们使用的 SqlSource 是 DynamicSqlSource,通过这个类来生成获取 BoundSql,如下图 6 所示:
通过图 6 的代码,我们可以得知,parameterType 在初始化阶段未被使用,而是在 SQL 执行时获取到的,但获取到的类型是 parameterObject 对应的类型,这个类是用来记录 Mapper 方法上对应的参数。如下图 7 所示,它并非在 SQL 配置文件中标注的 java.lang.String。
然后我们通过 SqlSourceBuilder 的 parse 方法对 SQL 以及获取到的类型进行再次处理,其中的流程代码比较长。在这个过程中,我们主要去构建 SQL 的参数和 Java 类型的绑定关系,MyBatis 依赖这个绑定关系,使用对应的 TypeHandler 去进行值的转换。
调用链路是 SqlSourceParser.parse
-> 内部类 ParameterMappingTokenHandler.handleToken
-> 私有方法 buildParameterMapping
,如下图 8 中的代码所示。因为当前的 parameterType 为 MapperMethod$ParamMap,经过了多个 if 判断,判定当前 property id 的 propertyType 为 Object.class 类型。接下来,构建 SQL 的参数和 Java 类型的绑定关系 ParameterMapping,再进行返回。
构建完成的 ParameterMapping 的结构如下图 9 中的代码所示,参数 id 对应的 javaType 类型为 java.lang.Object,对应的 TypeHander 处理器为 UnknownTypeHandler,也就是未找到合适的 TypeHandler 的兜底选项。
接下来,流程就会流转到 Executor,在 org.apache.ibatis.executor.SimpleExecutor#doQuery 进行查询时,会根据当前的 SQL 类型,生成对应的 StatementHandler。因为我们目前都是用的预编译 SQL,因此生成的 statementHandler 就是 PreparedStatementHandler,熟悉 JDBC 的小伙伴应该马上可以猜到对应的语句是什么类型了。然后,我们对这句 SQL 语句进行填充,如下图 10 中的代码所示。我们会通过 PreparedStatementHandler 的 parameterize 方法对 Statement 进行参数化,也就是进行填充。
在 PreparedStatementHandler 进行参数化时,会将参数化的职责交给 DefaultParameterHandler 处理。如下图 11 中的代码所示,我们主要关注红线部分,首先会获取 ParameterMapping 对应的 TypeHander,如前文所述,获取到的是 UnknownTypeHandler,然后会通过 setParameter 方法,将参数 id 替换成对应的值。
在 Typehandler 的流程里,首先会进入 BaseTypeHandler,然后在具体设置时,会进入子类的方法。在 UnknownTypeHandler,首先会再次对参数 parameter 进行解析,判断最正确的 TypeHandler 类型,如下图 12 中的代码所示:
在 resolveTypeHandler 方法中,因为已知了参数值的类型,通过 Integer 这个 class 在 typeHandlerRegistry 中寻找对应的 TypeHandler,TypeHandlerRegistry 是 MyBatis 启动时内置好的,代表 Java 对象类型和 TypeHandler 的映射关系,有兴趣的同学可以进入这个类详细看下。在这个例子中,我们会直接获取到 IntegerHandler,如下图 13 中的代码所示:
在获取到 IntegerHandler 后,我们就可以使用 IntegerTypeHandler 的 setInt 方法,对 SQL 语句中的参数进行替换。如图 14 中的代码所示,SQL 语句被成功替换:
后续就是执行 SQL 并处理返回结果,这就不在本文的讨论范围内了。从上文的分析中,我们可以了解到,在 3.2.3 及以下版本,MyBatis 会忽略 parameterType,在真正进行 SQL 转换时,重新根据 SQL 方法入参类型,然后计算合适的 TypeHandler 处理器,所以本案例中的代码在 3.2.3 版本时,它在运行时是正常的。
在前一章节中,我们得知 MyBatis 在运行 SQL 阶段重新计算参数对应的 TypeHandler,然后进行 SQL 参数的替换。那么,在版本 3.2.4 中,MyBatis 做了什么改动,从而导致了原有的使用方式变得不可用呢?从官方的 Release Log 来看,版本 3.2.4 做了这样的一个改动。
This version builds the binding information during startup and the “parameterType” attribute is used.
这个意思是说:parameterType 会在框架初始化阶段阶段就被使用到。我们将分析的重点放在构建阶段,因为负责处理绑定关系的 BoundSql 由配置阶段的 SqlSource 生成,我们主要查看 SqlSource 的构建,在 3.2.4 中发生了什么变化。如图 15 所示,与 3.2.3 不同,3.2.4 首先判断了是否为动态 SQL,在非动态 SQL 情况下,才会将 parameterType java.lang.String
作为参数,传入 SqlSource 的构造方法。
而后续流程与 3.2.3 一致,因为 parameter 类型为 java.lang.String,在构建 parameterMapping 时,使用的类型就是 java.lang.String。
因为在框架初始化阶段,SqlSource 的 ParameterMapping 中 id 对应的类型就是 java.lang.String,这就导致在进行 SQL 语句的替换时,获取到的 TypeHandler 是 StringTypeHandler,如下图 17 所示:
后面的报错原因就比较好理解了,在调用 StringTypeHandler 的 setString 方法时,报出了 java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
的错误。
我们总结一下这个案例:
MyBatis 3.2.3 版本支持 parameterType 和实际参数类型不匹配,在执行 SQL 阶段,动态计算值处理器类型。在大版本升级 2 个版本后,parameterType 实际的类型开始生效,使用对应这个类型的 TypeHandler 对 SQL 进行参数替换,会导致 Mapper 方法中的参数和 XML 中的 parameterType 不匹配时,进而会出现类型转换报错。
这一段排查的经历,对自己后续编写代码及在系统上线时也有一些启发,主要包括以下几个方面:
带你一步一步手撕 MyBatis 源码加手绘流程图——构建部分
带你一步一步手撕 MyBatis 源码加手绘流程图——执行部分
MyBatis 源码解析(三)—缓存篇
面试官问你 MyBatis SQL 是如何执行的?把这篇文章甩给他
源码分析 (1.4 万字) | MyBatis 接口没有实现类为什么可以执行增删改查
MyBatis/MyBatis-3/Comparing changes
凯伦,2016 年校招加入美团,后端开发工程师。
https://mp.weixin.qq.com/s?__biz=MjM5NjQ5MTI5OA==&mid=2651752091&idx=1&sn=57a47dc2099fb93a2bc9447640dfca8c&chksm=bd125fd68a65d6c0774f3ea5ad6bb0c97ab0f0f4a915fad2c0143cb61c228d70cd0a7fe873b3&scene=27#wechat_redirect