MyBatis版本升級(jí)引發(fā)的線上告警回顧及原理分析
背景
某天晚上,美團(tuán)到店事業(yè)群某項(xiàng)系統(tǒng)服務(wù)正在進(jìn)行常規(guī)需求的上線。因?yàn)樵趦?nèi)部的Plus系統(tǒng)發(fā)布時(shí),提示inf-bom版本需要升級(jí),于是我們就將inf-bom版本從1.3.9.6 升級(jí)至1.4.2.1,如下圖1所示:

圖1 版本升級(jí)
不過(guò),當(dāng)服務(wù)上線后,開(kāi)始陸續(xù)出現(xiàn)了一些更新系統(tǒng)交互日志方面的報(bào)警,這屬于系統(tǒng)的輔助流程,報(bào)警如下方代碼所示。我們發(fā)現(xiàn)都是跟MyBatis相關(guān)的報(bào)警,說(shuō)明在進(jìn)行類(lèi)型轉(zhuǎn)換的時(shí)候,系統(tǒng)產(chǎn)生了強(qiáng)轉(zhuǎn)錯(cuò)誤。
- 更新開(kāi)票請(qǐng)求返回日志, 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: Could not set parameters for mapping: ParameterMapping{property='updateTime', mode=IN, javaType=class java.lang.String,
- jdbcTyp=null,resultMapId='null',jdbcTypeName='null',expression='null'}.Cause org.apache.ibatis.type.TypeException,Error setting non null parameter #2 with JdbcType null. Try setting a
- different Jdbc Type for this parameter or a different configuration property.Cause java.lang.ClassCastException:java.time.LocalDateTime cannot be cast to java.lang.String
因?yàn)閳?bào)警這一塊代碼,屬于歷史功能,如果失敗并不會(huì)影響主流程。但在定位期間,如果頻繁報(bào)警的話,就會(huì)造成一定的干擾。因此,我們馬上采取了回滾操作,將inf-bom的版本回滾至歷史版本,直至報(bào)警消失,然后再進(jìn)行問(wèn)題的定位和分析。以下章節(jié)就是我們對(duì)報(bào)警原因的定位及原因詳細(xì)分析的介紹,希望這些思路能夠?qū)Υ蠹矣兴鶈l(fā)和幫助。
報(bào)警原因定位
在回滾完畢后,我們開(kāi)始具體分析報(bào)警產(chǎn)生的主要原因,于是進(jìn)行了以下幾步的排查。
第一步,查看了報(bào)警的Mapper方法,如下代碼段所示。這個(gè)是接收返回參數(shù),根據(jù)主鍵id,更新具體響應(yīng)內(nèi)容和時(shí)間的代碼,入?yún)⒂?個(gè),類(lèi)型分別為long、String和LocalDateTime。
- int updateResponse(@Param("id")long id, @Param("response")String response, @Param("updateTime")LocalDateTime updateTime);
第二步,我們查看了Mapper方法對(duì)應(yīng)的XML文件,如下代碼段所示,對(duì)應(yīng)的parameterType類(lèi)型是String,而實(shí)際參數(shù)的類(lèi)型包括long、String以及LocalDateTime。
- <update id="updateResponse" parameterType="java.lang.String">
- UPDATE invoice_log
- SET response = #{response}, update_time = #{updateTime}
- WHERE id = #{id}
- </update>
第三步,我們查看了MyBatis上線前后的版本,報(bào)警的內(nèi)容是:MyBatis在處理SQL語(yǔ)句時(shí),發(fā)現(xiàn)不能將LocalDateTime轉(zhuǎn)型為String,這一段邏輯在上線前是可以正常運(yùn)行的,并且上線的業(yè)務(wù)邏輯對(duì)這段歷史代碼無(wú)改動(dòng)。因此,我們猜測(cè)是因?yàn)閕nf-bom的升級(jí),從而導(dǎo)致MyBatis的版本發(fā)生了變化,對(duì)某些歷史功能不再支持了。MyBatis版本上線前后的變化如下表所示:

表1 MyBatis版本升級(jí)前后對(duì)比
第四步,我們通過(guò)第三步可以得到,在這次inf-bom的版本升級(jí)中,MyBatis的版本直接升了兩個(gè)大版本,因此我們可以基本將原因猜測(cè)為MyBatis升級(jí)跨度較大,導(dǎo)致部分歷史功能沒(méi)有兼容支持,從而引起線上SQL的更新報(bào)錯(cuò)。
第五步,為了具體驗(yàn)證第四步的想法,我們通過(guò)UT的方式,將MyBatis的版本不斷從3.4.6往下降,直至沒(méi)有報(bào)錯(cuò)的位置。最終的定位是:當(dāng)MyBatis版本為3.2.3時(shí),線上代碼是正常可用的,但只要升一個(gè)版本,也就是自3.2.4開(kāi)始,就開(kāi)始不兼容目前的用法。不過(guò),我們當(dāng)時(shí)的思路并不是很好,應(yīng)該從小版本逐個(gè)往上升或者使用二分法,可以加速定位版本的效率。
最后,我們定位到了產(chǎn)生報(bào)警的根本問(wèn)題。總的來(lái)說(shuō),MyBatis版本由inf-bom引入而來(lái),inf-bom從3.2.3 升級(jí)到了3.4.6版本,而MyBatis自3.2.4開(kāi)始就不支持目前系統(tǒng)內(nèi)的SQL Mapper的用法,因此在升級(jí)后,線上就出現(xiàn)了頻繁報(bào)警的問(wèn)題。
問(wèn)題已經(jīng)定位,但是還有很多事情我們需要弄清楚。為什么版本升級(jí)后就不兼容歷史的用法?具體是哪一塊內(nèi)容不兼容?背后的原理又是什么?下文,我們會(huì)詳細(xì)進(jìn)行分析。
詳細(xì)分析
MyBatis升級(jí)3.2.4版本的官方Release公告
首先,從報(bào)錯(cuò)的原因上來(lái)看,請(qǐng)注意這句話:“Caused by: java.lang.ClassCastException: java.lang.LocalDateTime cannot be cast to java.lang.String.”MyBatis在構(gòu)建SQL語(yǔ)句時(shí),發(fā)現(xiàn)時(shí)間字段類(lèi)型LocalDateTime不能強(qiáng)制轉(zhuǎn)為String類(lèi)型。而這個(gè)SQL對(duì)應(yīng)的XML配置在3.2.3的版本是可以正常使用的,那么我們先從MyBatis的Release Log上查看3.2.4版本到底發(fā)生了什么變化。
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.
從官網(wǎng)的Release Log可以看到,MyBatis在3.2.4以前的版本,會(huì)忽略XML中的parameterType這個(gè)屬性,并且使用真實(shí)的變量類(lèi)型進(jìn)行值的處理。但在3.2.4及以后的版本中,這個(gè)屬性就被啟用了,如果出現(xiàn)類(lèi)型不匹配的話,就會(huì)出現(xiàn)轉(zhuǎn)型失敗的報(bào)錯(cuò)。這也提示我們開(kāi)發(fā)者,在升級(jí)版本時(shí),需要檢查系統(tǒng)內(nèi)的XML配置,使類(lèi)型進(jìn)行匹配,或者不設(shè)置該屬性,讓MyBatis自行進(jìn)行計(jì)算。
根據(jù)以上內(nèi)容,我們可以了解到,在版本升級(jí)后,MyBatis在構(gòu)建SQL語(yǔ)句,在獲取字段值時(shí)的邏輯發(fā)生了變化。接下來(lái)我們將通過(guò)一個(gè)簡(jiǎn)單的示例,來(lái)了解一下MyBatis在獲取字段值這一塊的具體代碼流程是怎樣的,以3.2.3版本為例。
以版本3.2.3為例,MyBatis構(gòu)建SQL語(yǔ)句過(guò)程的原理分析
我們看一下配置,首先定義一個(gè)通過(guò)主鍵id獲取學(xué)生信息的方法,仿造系統(tǒng)內(nèi)的歷史代碼,我們將parameterType定義為java.lang.String,這和方法對(duì)應(yīng)的參數(shù)int并不相同。
- public StudentEntity getStudentById(@Param("id") int id);
- <select id="getStudentById" parameterType="java.lang.String" resultType="entity.StudentEntity">
- SELECT id,name,age FROM student WHERE id = #{id}
- </select>
MyBatis框架要做的事情,就是在運(yùn)行g(shù)etStudentById(2)的時(shí)候,將 #{id}進(jìn)行替換,使SQL語(yǔ)句變成SELECT id,name,age FROM student WHERE id = 2。MyBatis要將SQL語(yǔ)句完整替換成帶參數(shù)值的版本,需要經(jīng)歷框架初始化以及實(shí)際運(yùn)行時(shí)動(dòng)態(tài)替換這兩個(gè)部分。因?yàn)镸yBatis的代碼非常多,接下來(lái)我們主要闡釋和本次案例相關(guān)的內(nèi)容。
在框架初始化階段,主要包括以下流程,如下圖2所示:

圖2 框架初始化流程
在框架初始化階段,有一些組件會(huì)被構(gòu)建,逐一做個(gè)簡(jiǎn)單的介紹:
- SqlSession:作為MyBatis工作的主要頂層API,表示和數(shù)據(jù)庫(kù)交互的會(huì)話,完成必要的數(shù)據(jù)庫(kù)增刪改查功能。
- 數(shù)據(jù)庫(kù)增刪改查功能:負(fù)責(zé)根據(jù)用戶(hù)傳遞的parameterObject,動(dòng)態(tài)地生成SQL語(yǔ)句,將信息封裝到BoundSql對(duì)象中,并返回。
- Configuration:MyBatis所有的配置信息都維持在Configuration對(duì)象之中。
接下來(lái),我們主要關(guān)注SqlSource,這個(gè)類(lèi)會(huì)負(fù)責(zé)生成SQL語(yǔ)句,這也是本次案例中,3.2.3和3.2.4差異比較大的一個(gè)地方。下面,我們會(huì)介紹一些源碼。
在構(gòu)建Configuration的過(guò)程中,會(huì)涉及到構(gòu)建對(duì)應(yīng)每一條SQL語(yǔ)句對(duì)應(yīng)的MappedStatement,parameterTypeClass就是根據(jù)我們?cè)赬ML配置中寫(xiě)的parameterType轉(zhuǎn)換而來(lái),值為java.lang.String,在構(gòu)建SqlSource時(shí),傳入這個(gè)參數(shù)。如下圖3所示:

圖3 SqlSource依賴(lài)參數(shù)
在SqlSource的構(gòu)建中,parameterType參數(shù)其實(shí)是被忽略不用的,并沒(méi)有繼續(xù)往下傳遞,這跟官方的描述是一致的。因?yàn)?.2.4之前這個(gè)parameterType屬性被忽略了,然后就創(chuàng)建了DynamicSqlSource,這個(gè)類(lèi)主要是用于處理MyBatis動(dòng)態(tài)SQL的類(lèi)。如下圖4所示:

圖4 SqlSource構(gòu)建
在框架初始化的階段,需要介紹的內(nèi)容,在3.2.3版本已經(jīng)介紹完畢。當(dāng)執(zhí)行g(shù)etStudentById方法時(shí),MyBatis的流程如下圖5所示。因受限于圖片長(zhǎng)度,我們對(duì)布局進(jìn)行了一些調(diào)整:

圖5 運(yùn)行流程
在具體執(zhí)行階段,也涉及到一些組件,我們需要做簡(jiǎn)單的了解:
- SqlSession:作為MyBatis工作的主要頂層API,表示和數(shù)據(jù)庫(kù)交互的會(huì)話,完成必要數(shù)據(jù)庫(kù)增刪改查功能。
- Executor:MyBatis執(zhí)行器,這是MyBatis調(diào)度的核心,負(fù)責(zé)SQL語(yǔ)句的生成和查詢(xún)緩存的維護(hù)。
- BoundSql:表示動(dòng)態(tài)生成的SQL語(yǔ)句以及相應(yīng)的參數(shù)信息。
- StatementHandler:封裝了JDBC Statement操作,負(fù)責(zé)對(duì)JDBC statement的操作,如設(shè)置參數(shù)、將Statement結(jié)果集轉(zhuǎn)換成List集合等等。
- ParameterHandler:負(fù)責(zé)對(duì)用戶(hù)傳遞的參數(shù)轉(zhuǎn)換成JDBC Statement 所需要的參數(shù)。
- TypeHandler:負(fù)責(zé)Java數(shù)據(jù)類(lèi)型和JDBC數(shù)據(jù)類(lèi)型之間的映射和轉(zhuǎn)換。
我們主要關(guān)注獲取BoundSql以及參數(shù)化語(yǔ)句的流程,這也是3.2.3和3.2.4差異比較大的一個(gè)地方。在進(jìn)入Executor的Query方法后,會(huì)首先通過(guò)對(duì)應(yīng)的MappedStatement來(lái)獲取BoundSql,用來(lái)幫助我們動(dòng)態(tài)生成SQL語(yǔ)句,里面綁定了對(duì)應(yīng)的SQL以及參數(shù)映射關(guān)系。在構(gòu)建框架階段,我們使用的SqlSource是DynamicSqlSource,通過(guò)這個(gè)類(lèi)來(lái)生成獲取BoundSql,如下圖6所示:

圖6 獲取BoundSql
通過(guò)圖6的代碼,我們可以得知,parameterType在初始化階段未被使用,而是在SQL執(zhí)行時(shí)獲取到的,但獲取到的類(lèi)型是parameterObject對(duì)應(yīng)的類(lèi)型,這個(gè)類(lèi)是用來(lái)記錄Mapper方法上對(duì)應(yīng)的參數(shù)。如下圖7所示,它并非在SQL配置文件中標(biāo)注的java.lang.String。

圖7 parameterObject類(lèi)型
然后我們通過(guò)SqlSourceBuilder的parse方法對(duì)SQL以及獲取到的類(lèi)型進(jìn)行再次處理,其中的流程代碼比較長(zhǎng)。在這個(gè)過(guò)程中,我們主要去構(gòu)建SQL的參數(shù)和Java類(lèi)型的綁定關(guān)系,MyBatis依賴(lài)這個(gè)綁定關(guān)系,使用對(duì)應(yīng)的TypeHandler去進(jìn)行值的轉(zhuǎn)換。
調(diào)用鏈路是SqlSourceParser.parse -> 內(nèi)部類(lèi) ParameterMappingTokenHandler.handleToken -> 私有方法 buildParameterMapping,如下圖8中的代碼所示。因?yàn)楫?dāng)前的parameterType為MapperMethod$ParamMap,經(jīng)過(guò)了多個(gè)if判斷,判定當(dāng)前property id的propertyType為Object.class類(lèi)型。接下來(lái),構(gòu)建SQL的參數(shù)和Java類(lèi)型的綁定關(guān)系ParameterMapping,再進(jìn)行返回。

圖8 buildParameterMapping過(guò)程
構(gòu)建完成的ParameterMapping的結(jié)構(gòu)如下圖9中的代碼所示,參數(shù)id對(duì)應(yīng)的javaType類(lèi)型為java.lang.Object,對(duì)應(yīng)的TypeHander處理器為UnknownTypeHandler,也就是未找到合適的TypeHandler的兜底選項(xiàng)。

圖9 ParameterMapping結(jié)構(gòu)
接下來(lái),流程就會(huì)流轉(zhuǎn)到Executor,在org.apache.ibatis.executor.SimpleExecutor#doQuery進(jìn)行查詢(xún)時(shí),會(huì)根據(jù)當(dāng)前的SQL類(lèi)型,生成對(duì)應(yīng)的StatementHandler。因?yàn)槲覀兡壳岸际怯玫念A(yù)編譯SQL,因此生成的statementHandler就是PreparedStatementHandler,熟悉JDBC的小伙伴應(yīng)該馬上可以猜到對(duì)應(yīng)的語(yǔ)句是什么類(lèi)型了。然后,我們對(duì)這句SQL語(yǔ)句進(jìn)行填充,如下圖10中的代碼所示。我們會(huì)通過(guò)PreparedStatementHandler的parameterize方法對(duì)Statement進(jìn)行參數(shù)化,也就是進(jìn)行填充。

圖10 PrepareStatement處理過(guò)程
在PreparedStatementHandler進(jìn)行參數(shù)化時(shí),會(huì)將參數(shù)化的職責(zé)交給DefaultParameterHandler處理。如下圖11中的代碼所示,我們主要關(guān)注紅線部分,首先會(huì)獲取ParameterMapping對(duì)應(yīng)的TypeHander,如前文所述,獲取到的是UnknownTypeHandler,然后會(huì)通過(guò)setParameter方法,將參數(shù)id替換成對(duì)應(yīng)的值。

在Typehandler的流程里,首先會(huì)進(jìn)入BaseTypeHandler,然后在具體設(shè)置時(shí),會(huì)進(jìn)入子類(lèi)的方法。在UnknownTypeHandler,首先會(huì)再次對(duì)參數(shù)parameter進(jìn)行解析,判斷最正確的TypeHandler類(lèi)型,如下圖12中的代碼所示:

圖12 獲取可用TypeHandler
在resolveTypeHandler方法中,因?yàn)橐阎藚?shù)值的類(lèi)型,通過(guò)Integer這個(gè)class在typeHandlerRegistry中尋找對(duì)應(yīng)的TypeHandler,TypeHandlerRegistry是MyBatis啟動(dòng)時(shí)內(nèi)置好的,代表Java對(duì)象類(lèi)型和TypeHandler的映射關(guān)系,有興趣的同學(xué)可以進(jìn)入這個(gè)類(lèi)詳細(xì)看下。在這個(gè)例子中,我們會(huì)直接獲取到IntegerHandler,如下圖13中的代碼所示:

圖13 獲取IntegerHandler
在獲取到IntegerHandler后,我們就可以使用IntegerTypeHandler的setInt方法,對(duì)SQL語(yǔ)句中的參數(shù)進(jìn)行替換。如圖14中的代碼所示,SQL語(yǔ)句被成功替換:

圖14 IntegerHander值替換
后續(xù)就是執(zhí)行SQL并處理返回結(jié)果,這就不在本文的討論范圍內(nèi)了。從上文的分析中,我們可以了解到,在3.2.3及以下版本,MyBatis會(huì)忽略parameterType,在真正進(jìn)行SQL轉(zhuǎn)換時(shí),重新根據(jù)SQL方法入?yún)㈩?lèi)型,然后計(jì)算合適的TypeHandler處理器,所以本案例中的代碼在3.2.3版本時(shí),它在運(yùn)行時(shí)是正常的。
以版本3.2.4為例,相比版本3.2.3,MyBatis構(gòu)建SQL語(yǔ)句過(guò)程的變化分析
在前一章節(jié)中,我們得知MyBatis在運(yùn)行SQL階段重新計(jì)算參數(shù)對(duì)應(yīng)的TypeHandler,然后進(jìn)行SQL參數(shù)的替換。那么,在版本3.2.4中,MyBatis做了什么改動(dòng),從而導(dǎo)致了原有的使用方式變得不可用呢?從官方的Release Log來(lái)看,版本3.2.4做了這樣的一個(gè)改動(dòng)。
This version builds the binding information during startup and the “parameterType” attribute is used
這個(gè)意思是說(shuō):parameterType會(huì)在框架初始化階段階段就被使用到。我們將分析的重點(diǎn)放在構(gòu)建階段,因?yàn)樨?fù)責(zé)處理綁定關(guān)系的BoundSql由配置階段的SqlSource生成,我們主要查看SqlSource的構(gòu)建,在3.2.4中發(fā)生了什么變化。如圖15所示,與3.2.3不同,3.2.4首先判斷了是否為動(dòng)態(tài)SQL,在非動(dòng)態(tài)SQL情況下,才會(huì)將parameterType java.lang.String作為參數(shù),傳入SqlSource的構(gòu)造方法。

圖15 生成SqlSource
而后續(xù)流程與3.2.3一致,因?yàn)閜arameter類(lèi)型為java.lang.String,在構(gòu)建parameterMapping時(shí),使用的類(lèi)型就是java.lang.String。

圖16 構(gòu)建ParameterMapping與3.2.3版本的差異
因?yàn)樵诳蚣艹跏蓟A段,SqlSource的ParameterMapping中id對(duì)應(yīng)的類(lèi)型就是java.lang.String,這就導(dǎo)致在進(jìn)行SQL語(yǔ)句的替換時(shí),獲取到的TypeHandler是StringTypeHandler,如下圖17所示:

圖17 整數(shù)類(lèi)型的參數(shù)獲取到了StringTypeHandler
后面的報(bào)錯(cuò)原因就比較好理解了,在調(diào)用StringTypeHandler的setString方法時(shí),報(bào)出了java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String的錯(cuò)誤。
總結(jié)
我們總結(jié)一下這個(gè)案例因:
MyBatis 3.2.3版本支持parameterType和實(shí)際參數(shù)類(lèi)型不匹配,在執(zhí)行SQL階段,動(dòng)態(tài)計(jì)算值處理器類(lèi)型。在大版本升級(jí)2個(gè)版本號(hào)后,parameterType實(shí)際的類(lèi)型開(kāi)始生效,使用對(duì)應(yīng)這個(gè)類(lèi)型的TypeHandler對(duì)SQL進(jìn)行參數(shù)替換,會(huì)導(dǎo)致Mapper方法中的參數(shù)和XML中的parameterType不匹配時(shí),進(jìn)而會(huì)出現(xiàn)類(lèi)型轉(zhuǎn)換報(bào)錯(cuò)。
這一段排查的經(jīng)歷,對(duì)自己后續(xù)編寫(xiě)代碼及在系統(tǒng)上線時(shí)也有一些啟發(fā),主要包括以下幾個(gè)方面:
在inf-bom升級(jí)時(shí),需要線下進(jìn)行全面回歸,要避免框架存在不兼容的用法,不然的話,就容易導(dǎo)致線上錯(cuò)誤。
開(kāi)發(fā)同學(xué)可以檢查自己系統(tǒng)內(nèi)的MyBatis版本,如果是3.2.4以下,需要全面檢查下現(xiàn)在的Mapper文件里對(duì)于parameterType的使用和Mapper方法中實(shí)際的參數(shù)類(lèi)型是否一致,避免升級(jí)到3.2.4及以上版本時(shí)發(fā)生轉(zhuǎn)型報(bào)錯(cuò)。如果有不匹配的情況存在,需要進(jìn)行修正或者不使用parameterType,讓MyBatis在運(yùn)行SQL時(shí)自動(dòng)計(jì)算對(duì)應(yīng)的類(lèi)型。
可以考慮使用MyBatis-Generator來(lái)自動(dòng)生成XML和Mapper文件,畢竟是專(zhuān)業(yè)團(tuán)隊(duì)在維護(hù),穩(wěn)定性相對(duì)來(lái)說(shuō)會(huì)更好一些,同時(shí)能夠避免手動(dòng)修改XML文件帶來(lái)的誤操作。
可以主動(dòng)關(guān)注強(qiáng)依賴(lài)的一些開(kāi)源框架的Release Log,不要錯(cuò)過(guò)了重要的信息。
作者簡(jiǎn)介
凱倫,2016年校招加入美團(tuán),后端開(kāi)發(fā)工程師。