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

圖1 版本升級
不過,當(dāng)服務(wù)上線后,開始陸續(xù)出現(xiàn)了一些更新系統(tǒng)交互日志方面的報(bào)警,這屬于系統(tǒng)的輔助流程,報(bào)警如下方代碼所示。我們發(fā)現(xiàn)都是跟MyBatis相關(guān)的報(bào)警,說明在進(jìn)行類型轉(zhuǎn)換的時(shí)候,系統(tǒng)產(chǎn)生了強(qiáng)轉(zhuǎn)錯(cuò)誤。
- 更新開票請求返回日志, 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)行問題的定位和分析。以下章節(jié)就是我們對報(bào)警原因的定位及原因詳細(xì)分析的介紹,希望這些思路能夠?qū)Υ蠹矣兴鶈l(fā)和幫助。
報(bào)警原因定位
在回滾完畢后,我們開始具體分析報(bào)警產(chǎn)生的主要原因,于是進(jìn)行了以下幾步的排查。
第一步,查看了報(bào)警的Mapper方法,如下代碼段所示。這個(gè)是接收返回參數(shù),根據(jù)主鍵id,更新具體響應(yīng)內(nèi)容和時(shí)間的代碼,入?yún)⒂?個(gè),類型分別為long、String和LocalDateTime。
- int updateResponse(@Param("id")long id, @Param("response")String response, @Param("updateTime")LocalDateTime updateTime);
第二步,我們查看了Mapper方法對應(yīng)的XML文件,如下代碼段所示,對應(yīng)的parameterType類型是String,而實(shí)際參數(shù)的類型包括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語句時(shí),發(fā)現(xiàn)不能將LocalDateTime轉(zhuǎn)型為String,這一段邏輯在上線前是可以正常運(yùn)行的,并且上線的業(yè)務(wù)邏輯對這段歷史代碼無改動(dòng)。因此,我們猜測是因?yàn)閕nf-bom的升級,從而導(dǎo)致MyBatis的版本發(fā)生了變化,對某些歷史功能不再支持了。MyBatis版本上線前后的變化如下表所示:

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

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

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

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

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

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

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

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

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

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

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

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

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

圖14 IntegerHander值替換
后續(xù)就是執(zhí)行SQL并處理返回結(jié)果,這就不在本文的討論范圍內(nèi)了。從上文的分析中,我們可以了解到,在3.2.3及以下版本,MyBatis會(huì)忽略parameterType,在真正進(jìn)行SQL轉(zhuǎn)換時(shí),重新根據(jù)SQL方法入?yún)㈩愋?,然后?jì)算合適的TypeHandler處理器,所以本案例中的代碼在3.2.3版本時(shí),它在運(yùn)行時(shí)是正常的。
以版本3.2.4為例,相比版本3.2.3,MyBatis構(gòu)建SQL語句過程的變化分析
在前一章節(jié)中,我們得知MyBatis在運(yùn)行SQL階段重新計(jì)算參數(shù)對應(yīng)的TypeHandler,然后進(jìn)行SQL參數(shù)的替換。那么,在版本3.2.4中,MyBatis做了什么改動(dòng),從而導(dǎo)致了原有的使用方式變得不可用呢?從官方的Release Log來看,版本3.2.4做了這樣的一個(gè)改動(dòng)。
This version builds the binding information during startup and the “parameterType” attribute is used
這個(gè)意思是說: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類型為java.lang.String,在構(gòu)建parameterMapping時(shí),使用的類型就是java.lang.String。

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

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