当前位置: 首页 > 科技观察

MyBatis版本升级导致上线告警回顾及原理分析

时间:2023-03-18 00:51:49 科技观察

背景一天晚上,美团到店事业群某系统服务上线,需求正常。因为内部Plus系统发布时,提示需要升级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}}nestedexecptionisorg.apache.ibatis.type.TypeException:Couldnotsetparametersformingapping:ParameterMapping{property='updateTime',mode=IN,javaType=classjava.lang.String,jdbcTyp=null,resultMapId='null',jdbcTypeName='null',expression='null'}.Causeorg.apache.ibatis.type.TypeException,Errorsettingnonnullparameter#2和JdbcTypenull.TrysettingadifferentJdbcTypeforthisparameterordifferentconfigurationproperty.Causejava.lang.ClassCastException:java.time.LocalDateTimecannotbecasttojava.lang.String因为报警的代码是一个历史函数,如果失败了不会影响主进程。但是在定位期间,如果频繁的告警,就会造成一定的干扰。于是,我们立即采取回滚操作,将inf-bom版本回滚到历史版本,直到告警消失,再定位分析问题。以下章节是我们对告警原因的定位和详细分析的介绍。我们希望这些想法能对您有所启发和帮助。回滚完成后定位告警原因,我们开始详细分析告警的主要原因,于是进行了如下排查。第一步,查看告警的Mapper方法,如下代码段所示。这是接收返回参数,根据主键id更新具体响应内容和时间的代码。输入参数有3个,类型分别是long、String和LocalDateTime。intupdateResponse(@Param("id")longid,@Param("response")Stringresponse,@Param("updateTime")LocalDateTimeupdateTime);第二步,我们查看了Mapper方法对应的XML文件,如下代码段所示,对应的parameterType类型为String,实际参数类型包括long、String、LocalDateTime。UPDATEinvoice_logSETresponse=#{response},update_time=#{updateTime}WHEREid=#{id}第三步,我们检查MyBatis前后上线版本,告警内容为:MyBatis在处理SQL语句时,发现LocalDateTime无法转换为String。上线前这段逻辑可以正常运行,上线业务逻辑没有改变这段历史代码。因此,我们猜测是因为inf-bom的升级,导致MyBatis的版本发生了变化,部分历史功能不再支持。MyBatis版本上线前后的变化如下表所示:表1MyBatis版本升级前后对比Version,所以我们基本可以猜到原因是MyBatis的升级跨度比较大,导致部分历史函数缺乏兼容支持,导致在线SQL更新错误。第五步,为了具体验证第四步的思路,我们用UT不断的从3.4.6降下MyBatis的版本,直到没有报错为止。最终的定位是:当MyBatis版本为3.2.3时,线上代码是正常可用的,但是只要升级版本,即从3.2.4开始,就不会兼容现在的使用了。但是,我们当时的想法并不是很好。我们应该从小版本一个一个往上走,或者用二分法的方式来加快定位版本的效率。最终定位到告警的根源问题。一般来说,MyBatis版本是由inf-bom引入的,inf-bom已经从3.2.3升级到3.4.6,而MyBatis从3.2.4开始已经不支持在当前系统中使用SQLMapper,所以在之后升级后,出现在线频繁告警的问题。问题已经找到了,但是我们还有很多事情需要弄清楚。为什么版本升级后history的使用不兼容?哪部分内容不兼容?它背后的原理是什么?我们将在下面详细分析。MyBatis升级3.2.4版本官方Release公告详解首先从报错原因来看,请注意这句话:“Causedby:java.lang.ClassCastException:java.lang.LocalDateTimecannotbe转换为java.lang.String。”MyBatis在构造SQL语句时发现时间字段类型LocalDateTime无法转换为String类型。这条SQL对应的XML配置在3.2.3版本可以正常使用,那么我们先从MyBatis的ReleaseLog中查看3.2.4版本有什么变化。关于此功能的特别说明。以前的版本忽略了“parameterType”属性并使用实际参数来计算绑定。此版本在启动期间构建绑定信息,如果存在“parameterType”属性(尽管它仍然是可选的),则使用该属性,因此如果您的值错误,您将不得不更改它。从官网的ReleaseLog可以看到,MyBatis3.2.4之前的版本会忽略XML中的parameterType属性,使用真正的变量类型来处理值。但是在3.2.4及之后的版本中,开启了该属性,如果存在类型不匹配,则会报错为转换失败。这也提醒我们的开发者,在升级版本的时候,需要检查系统中的XML配置是否匹配类型,或者不设置这个属性,让MyBatis自己计算。根据以上内容,我们可以了解到,版本升级后,MyBatis构造SQL语句,获取字段值的逻辑发生了变化。下面以3.2.3版本为例,通过一个简单的例子来了解MyBatis获取字段值的具体代码流程。以3.2.3版本为例,MyBatis中构造SQL语句的过程原理分析下面看配置,先定义一个通过主键id获取学生信息的方法,模仿系统中的历史代码,我们定义parameterType为java.lang.String,与方法对应的参数int不同。publicStudentEntitygetStudentById(@Param("id")intid);SELECTid,name,ageFROMstudentWHEREid=#{id}MyBatis框架要做的就是在运行getStudentById(2)的时候替换掉#{id},这样SQL语句就变成了SELECTid,name,ageFROMstudentWHEREid=2。要完全替换SQL语句的版本为参数值,MyBatis在实际运行时需要进行框架初始化和动态替换。因为MyBatis的代码比较多,接下来我们主要讲解本案例相关的内容。在框架初始化阶段,主要包括以下几个流程,如下图2所示:图2框架初始化流程在框架初始化阶段,会构建一些组件,下面一一简单介绍一下:SqlSession:作为MyBatis工作的主要顶层API,代表一个与数据库交互的会话,完成必要的数据库增删改查功能。数据库增删改查功能:负责根据用户传入的parameterObject动态生成SQL语句,将信息封装到BoundSql对象中,并返回。Configuration:MyBatis的所有配置信息都在Configuration对象中维护。接下来我们主要关注SqlSource,它负责生成SQL语句。这也是3.2.3和3.2.4在这种情况下的一个很大的区别。接下来,我们将介绍一些源代码。在构建Configuration的过程中,会涉及到构建每条SQL语句对应的MappedStatement。parameterTypeClass是根据我们在XML配置中写的parameterType进行转换的。该值为java.lang.String。构建SqlSource时,传入该参数。如下图3所示:图3SqlSource依赖的参数在SqlSource的构造中,实际上忽略了parameterType参数,没有传递下去,与官方描述一致。因为3.2.4之前忽略了parameterType属性,所以创建了DynamicSqlSource,主要用于处理MyBatis动态SQL。如下图4所示:图4SqlSource构建于框架初始化阶段,需要引入的内容在3.2.3版本中已经引入。执行getStudentById方法时,MyBatis的流程如下图5所示。由于图片篇幅限制,我们对布局做了一些调整:图5运行过程在具体执行阶段还涉及到一些组件,我们需要做一个简单的了解:SqlSession:作为主top-MyBatis工作的层次API、表示和数据库交互会话完成必要的数据库增删改查功能。Executor:MyBatis执行器,是MyBatis调度的核心,负责SQL语句的生成和查询缓存的维护。BoundSql:表示动态生成的SQL语句和对应的参数信息。StatementHandler:封装了JDBCStatement操作,负责对JDBC语句的操作,比如设置参数,将Statement结果集转换为List集合等。ParameterHandler:负责将用户传递的参数转换成JDBCStatement需要的参数。TypeHandler:负责Java数据类型和JDBC数据类型之间的映射和转换。我们主要关注获取BoundSql和参数化语句的过程,这也是3.2.3和3.2.4的一个比较大的区别。进入Executor的Query方法后,首先会通过对应的MappedStatement获取BoundSql,用于帮助我们动态生成SQL语句,绑定对应的SQL和参数映射关系。在搭建框架阶段,我们使用的SqlSource是DynamicSqlSource,用于生成和获取BoundSql,如下图6所示:图6获取BoundSql通过图6的代码可以知道没有使用parameterType在初始化阶段,并且在SQL执行的时候获取到,但是获取到的类型是parameterObject对应的类型。该类用于记录Mapper方法上的相应参数。如下图7所示,并不是SQL配置文件中标注的java.lang.String。图7parameterObject类型然后我们通过SqlSourceBuilder的parse方法再次对SQL和获取到的类型进行处理,处理代码比较长。在这个过程中,我们主要构建SQL参数与Java类型的绑定关系。MyBatis就是依赖这种绑定关系,使用对应的TypeHandler来进行值的转换。调用链接为SqlSourceParser.parse->内部类ParameterMappingTokenHandler.handleToken->私有方法buildParameterMapping,如下图8代码所示。因为当前parameterType为MapperMethod$ParamMap,经过多次if判断,确定当前属性id的propertyType为Object.class类型。接下来构造SQL参数与Java类型的绑定关系ParameterMapping,然后返回。图8buildParameterMapping过程完成的ParameterMapping的结构如下图9的代码所示。参数id对应的javaType类型为java.lang.Object,对应的TypeHander处理器为UnknownTypeHandler,即没有找到合适的TypeHandler。.图9ParameterMapping结构接下来,流程将流向Executor。在org.apache.ibatis.executor.SimpleExecutor#doQuery中查询时,会根据当前的SQL类型生成对应的StatementHandler。因为我们目前使用的是预编译SQL,所以生成的statementHandler为PreparedStatementHandler。熟悉JDBC的朋友应该能马上猜出对应语句的类型。然后,我们填写这条SQL语句,如下图10中的代码所示。我们会通过PreparedStatementHandler的parameterize方法对Statement进行参数化,即填充。图10PrepareStatement处理流程当PreparedStatementHandler被参数化后,参数化的职责就会交给DefaultParameterHandler处理。如下图11的代码所示,我们主要关注红线。首先,我们会获取ParameterMapping对应的TypeHander。如前所述,我们会获取到UnknownTypeHandler,然后通过setParameter方法将参数id替换为对应的值。在Typehandler的过程中,首先会进入BaseTypeHandler,然后在具体的设置中,会进入到子类的方法中。在UnknownTypeHandler中,parameter参数会先被再次解析,确定最正确的TypeHandler类型,如下图12代码所示:图12在resolveTypeHandler方法中获取可用的TypeHandler,因为参数值的类型是已知的,通过Integer类在typeHandlerRegistry中寻找对应的TypeHandler。TypeHandlerRegistry是MyBatis启动时内置的,代表Java对象类型与TypeHandler的映射关系。有兴趣的同学可以进这门课详细看看。在本例中,我们将直接获取IntegerHandler,如下图13中的代码所示:图13获取IntegerHandler获取到IntegerHandler后,我们可以使用IntegerTypeHandler的setInt方法来替换SQL语句中的参数。如图14代码所示,SQL语句替换成功:图14IntegerHander值替换后执行SQL并对返回结果进行处理,不在本文讨论范围之内。从上面的分析我们可以知道,在3.2.3及以下的版本中,MyBatis会忽略parameterType,而在真正进行SQL转换的时候,会根据SQL的方式重新输入参数类型,然后计算出合适的TypeHandler处理器,所以这个案例中的代码是3.2.3版本的,运行时运行良好。以3.2.4版本为例,与3.2.3版本相比,MyBatis构造SQL语句过程的变化分析在上一章中,我们了解到MyBatis在运行SQL阶段会重新计算参数对应的TypeHandler,然后执行SQL参数替换。那么,MyBatis在3.2.4版本中做了哪些改动,使得原有的使用方式无法使用呢?从官方ReleaseLog来看,3.2.4版本做了这样的改动。此版本在启动时构建绑定信息,并使用“parameterType”属性这意味着:parameterType将在框架初始化阶段使用。我们重点分析构造阶段,因为负责处理绑定关系的BoundSql是由配置阶段的SqlSource生成的。我们主要看SqlSource的构造,3.2.4有什么变化。如图15所示,与3.2.3不同,3.2.4先判断是否为动态SQL。在非动态SQL的情况下,parameterTypejava.lang.String会作为参数传递给SqlSource的构造方法。图15生成SqlSource,后续流程与3.2.3一致,因为参数类型是java.lang.String,在构建parameterMapping时,使用的类型是java.lang.String。图16构建ParameterMapping与3.2.3版本的区别由于在框架初始化阶段,SqlSource的ParameterMapping中id对应的类型为java.lang.String,导致替换SQL语句时获取到的TypeHandler为StringTypeHandler。如下图17所示:在图17中,在StringTypeHandler中获取整型参数后报错的原因比较容易理解。调用StringTypeHandler的setString方法时,报java.lang.ClassCastException:java.lang.Integercannotbecast。java.lang.String错误。小结总结一下这个案例的原因:MyBatis3.2.3版本支持parameterType和实际参数类型不匹配,在SQL执行阶段动态计算值处理器类型。大版本升级2个版本号后,parameterType的实际类型开始生效。使用该类型对应的TypeHandler来替换SQL参数,会导致Mapper方法中的参数与XML中的parameterType不匹配,进而发生类型转换。错误。这次调研的经历也给了我后续写代码和系统上线的一些启发,主要包括以下几个方面:inf-bom升级时,需要离线进行全量回归,避免不兼容的使用framework,否则容易造成线上错误。开发者可以在自己的系统中查看MyBatis版本。如果是3.2.4以下,需要全面检查当前Mapper文件中parameterType的使用是否与Mapper方法中的实际参数类型一致,避免升级到3.2.4及以上版本。版本转换时出现转换错误。如果不匹配,需要更正或者不使用parameterType,让MyBatis在运行SQL的时候自动计算出对应的类型。可以考虑使用MyBatis-Generator来自动生成XML和Mapper文件。毕竟有专业的团队维护,稳定性相对会好一些。同时可以避免手动修改XML文件造成的误操作。可以主动关注一些依赖性强的开源框架的ReleaseLog,以免错过重要信息。作者简介Karen,2016年从校招加入美团,后端开发工程师。