某天晚上,美团到店事业群某项系统服务正在进行常规需求的上线。因为在内部的Plus系统发布时,提示inf-bom版本需要升级,于是我们就将inf-bom版本从1.3.9.6升级至1.4.2.1,如下图1所示:
更新开票请求返回日志,id:{#######},response:{{"code":XXX,"data":{"callType":3,"code":XXX,"msg":"XXXX","shopId":XXXXX,"taxPlateDockType":"XXXXXXX"},"msg":"XXXXX","success":XXXX}}nestedexecptionisorg.apache.ibatis.type.TypeException:Couldnotsetparametersformapping:ParameterMapping{property='updateTime',mode=IN,javaType=classjava.lang.String,jdbcTyp=null,resultMapId='null',jdbcTypeName='null',expression='null'}.Causeorg.apache.ibatis.type.TypeException,Errorsettingnonnullparameter#2withJdbcTypenull.TrysettingadifferentJdbcTypeforthisparameteroradifferentconfigurationproperty.Causejava.lang.ClassCastException:java.time.LocalDateTimecannotbecasttojava.lang.String因为报警这一块代码,属于历史功能,如果失败并不会影响主流程。但在定位期间,如果频繁报警的话,就会造成一定的干扰。因此,我们马上采取了回滚操作,将inf-bom的版本回滚至历史版本,直至报警消失,然后再进行问题的定位和分析。以下章节就是我们对报警原因的定位及原因详细分析的介绍,希望这些思路能够对大家有所启发和帮助。
在回滚完毕后,我们开始具体分析报警产生的主要原因,于是进行了以下几步的排查。
intupdateResponse(@Param("id")longid,@Param("response")Stringresponse,@Param("updateTime")LocalDateTimeupdateTime);第二步,我们查看了Mapper方法对应的XML文件,如下代码段所示,对应的parameterType类型是String,而实际参数的类型包括long、String以及LocalDateTime。
第四步,我们通过第三步可以得到,在这次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开始就不支持目前系统内的SQLMapper的用法,因此在升级后,线上就出现了频繁报警的问题。
问题已经定位,但是还有很多事情我们需要弄清楚。为什么版本升级后就不兼容历史的用法?具体是哪一块内容不兼容?背后的原理又是什么?下文,我们会详细进行分析。
Anspecialremarkaboutthisfeature.Previousversionsignoredthe“parameterType”attributeandusedtheactualparametertocalculatebindings.Thisversionbuildsthebindinginformationduringstartupandthe“parameterType”attributeisusedifpresent(thoughitisstilloptional),soincaseyouhadawrongvalueforityouwillhavetochangeit.
从官网的ReleaseLog可以看到,MyBatis在3.2.4以前的版本,会忽略XML中的parameterType这个属性,并且使用真实的变量类型进行值的处理。但在3.2.4及以后的版本中,这个属性就被启用了,如果出现类型不匹配的话,就会出现转型失败的报错。这也提示我们开发者,在升级版本时,需要检查系统内的XML配置,使类型进行匹配,或者不设置该属性,让MyBatis自行进行计算。
我们看一下配置,首先定义一个通过主键id获取学生信息的方法,仿造系统内的历史代码,我们将parameterType定义为java.lang.String,这和方法对应的参数int并不相同。
在框架初始化阶段,主要包括以下流程,如下图2所示:
在框架初始化阶段,有一些组件会被构建,逐一做个简单的介绍:
在构建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所示。因受限于图片长度,我们对布局进行了一些调整:
在具体执行阶段,也涉及到一些组件,我们需要做简单的了解:
通过图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判断,判定当前propertyid的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进行参数化,也就是进行填充。
在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做了什么改动,从而导致了原有的使用方式变得不可用呢?从官方的ReleaseLog来看,版本3.2.4做了这样的一个改动。
Thisversionbuildsthebindinginformationduringstartupandthe“parameterType”attributeisused
这个意思是说:parameterType会在框架初始化阶段阶段就被使用到。我们将分析的重点放在构建阶段,因为负责处理绑定关系的BoundSql由配置阶段的SqlSource生成,我们主要查看SqlSource的构建,在3.2.4中发生了什么变化。如图15所示,与3.2.3不同,3.2.4首先判断了是否为动态SQL,在非动态SQL情况下,才会将parameterTypejava.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.Integercannotbecasttojava.lang.String的错误。
我们总结一下这个案例因:
MyBatis3.2.3版本支持parameterType和实际参数类型不匹配,在执行SQL阶段,动态计算值处理器类型。在大版本升级2个版本号后,parameterType实际的类型开始生效,使用对应这个类型的TypeHandler对SQL进行参数替换,会导致Mapper方法中的参数和XML中的parameterType不匹配时,进而会出现类型转换报错。
这一段排查的经历,对自己后续编写代码及在系统上线时也有一些启发,主要包括以下几个方面: