MyBatis版本升級引發的線上告警回顧及原理分析

本文從一次MyBatis版本升級引發的線上告警開始講起,然後針對告警定位過程、源碼原理進行了深入的分析,並加入了不同版本的類比分析,最後結合實際工作做了一些經驗總結,希望能對大家的工程實踐有一定的幫助。

背景

某天晚上,美團到店事業羣某項系統服務正在進行常規需求的上線。因爲在發佈時,提示inf-bom版本需要升級,於是我們就將inf-bom版本從1.3.9.6升級至1.4.2.1,如下圖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: 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

因爲報警這一塊代碼,屬於歷史功能,如果失敗並不會影響主流程。但在定位期間,如果頻繁報警的話,就會造成一定的干擾。因此,我們馬上採取了回滾操作,將inf-bom的版本回滾至歷史版本,直至報警消失,然後再進行問題的定位和分析。以下章節就是我們對報警原因的定位及原因詳細分析的介紹,希望這些思路能夠對大家有所啓發和幫助。

報警原因定位

在回滾完畢後,我們開始具體分析報警產生的主要原因,於是進行了以下幾步的排查。

第一步,查看了報警的Mapper方法,如下代碼段所示。這個是接收返回參數,根據主鍵id,更新具體響應內容和時間的代碼,入參有3個,類型分別爲long、String和LocalDateTime。

int updateResponse(@Param("id")long id, @Param("response")String response, @Param("updateTime")LocalDateTime updateTime);

第二步,我們查看了Mapper方法對應的XML文件,如下代碼段所示,對應的parameterType類型是String,而實際參數的類型包括long、String以及LocalDateTime。

<update id="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版本上線前後的變化如下表所示:

表1 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的用法,因此在升級後,線上就出現了頻繁報警的問題。

問題已經定位,但是還有很多事情我們需要弄清楚。爲什麼版本升級後就不兼容歷史的用法?具體是哪一塊內容不兼容?背後的原理又是什麼?下文,我們會詳細進行分析。

詳細分析

MyBatis升級3.2.4版本的官方Release公告

首先,從報錯的原因上來看,請注意這句話:“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版本爲例。

以版本3.2.3爲例,MyBatis構建SQL語句過程的原理分析

我們看一下配置,首先定義一個通過主鍵id獲取學生信息的方法,仿造系統內的歷史代碼,我們將parameterType定義爲java.lang.String,這和方法對應的參數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框架要做的事情,就是在運行getStudentById(2)的時候,將 #{id}進行替換,使SQL語句變成SELECT id,name,age FROM student WHERE id = 2。MyBatis要將SQL語句完整替換成帶參數值的版本,需要經歷框架初始化以及實際運行時動態替換這兩個部分。因爲MyBatis的代碼非常多,接下來我們主要闡釋和本次案例相關的內容。

在框架初始化階段主要包括以下流程,如下圖2所示:

圖2 框架初始化流程

在框架初始化階段,有一些組件會被構建,逐一做個簡單的介紹:

  • SqlSession:作爲MyBatis工作的主要頂層API,表示和數據庫交互的會話,完成必要的數據庫增刪改查功能。

  • SqlSource:負責根據用戶傳遞的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所示:

圖3 SqlSource依賴參數

在SqlSource的構建中,parameterType參數其實是被忽略不用的,並沒有繼續往下傳遞,這跟官方的描述是一致的。因爲3.2.4之前這個parameterType屬性被忽略了,然後就創建了DynamicSqlSource,這個類主要是用於處理MyBatis動態SQL的類。如下圖4所示:

圖4 SqlSource構建

在框架初始化的階段,需要介紹的內容,在3.2.3版本已經介紹完畢。當執行getStudentById方法時,MyBatis的流程如下圖5所示。因受限於圖片長度,我們對佈局進行了一些調整:

圖5 運行流程

在具體執行階段,也涉及到一些組件,我們需要做簡單的瞭解:

  • SqlSession:作爲MyBatis工作的主要頂層API,表示和數據庫交互的會話,完成必要數據庫增刪改查功能。

  • Executor:MyBatis執行器,這是MyBatis調度的核心,負責SQL語句的生成和查詢緩存的維護。

  • BoundSql:表示動態生成的SQL語句以及相應的參數信息。

  • StatementHandler:封裝了JDBC Statement操作,負責對JDBC statement的操作,如設置參數、將Statement結果集轉換成List集合等等。

  • ParameterHandler:負責對用戶傳遞的參數轉換成JDBC Statement 所需要的參數。

  • 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。

圖7 parameterObject類型

然後我們通過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,再進行返回。

圖8 buildParameterMapping過程

構建完成的ParameterMapping的結構如下圖9中的代碼所示,參數id對應的javaType類型爲java.lang.Object,對應的TypeHander處理器爲UnknownTypeHandler,也就是未找到合適的TypeHandler的兜底選項。

圖9 ParameterMapping結構

接下來,流程就會流轉到Executor,在org.apache.ibatis.executor.SimpleExecutor#doQuery進行查詢時,會根據當前的SQL類型,生成對應的StatementHandler。因爲我們目前都是用的預編譯SQL,因此生成的statementHandler就是PreparedStatementHandler,熟悉JDBC的小夥伴應該馬上可以猜到對應的語句是什麼類型了。然後,我們對這句SQL語句進行填充,如下圖10中的代碼所示。我們會通過PreparedStatementHandler的parameterize方法對Statement進行參數化,也就是進行填充。

圖10 PrepareStatement處理過程

在PreparedStatementHandler進行參數化時,會將參數化的職責交給DefaultParameterHandler處理。如下圖11中的代碼所示,我們主要關注紅線部分,首先會獲取ParameterMapping對應的TypeHander,如前文所述,獲取到的是UnknownTypeHandler,然後會通過setParameter方法,將參數id替換成對應的值。

圖11 設置參數過程

在Typehandler的流程裏,首先會進入BaseTypeHandler,然後在具體設置時,會進入子類的方法。在UnknownTypeHandler,首先會再次對參數parameter進行解析,判斷最正確的TypeHandler類型,如下圖12中的代碼所示:

圖12 獲取可用TypeHandler

在resolveTypeHandler方法中,因爲已知了參數值的類型,通過Integer這個class在typeHandlerRegistry中尋找對應的TypeHandler,TypeHandlerRegistry是MyBatis啓動時內置好的,代表Java對象類型和TypeHandler的映射關係,有興趣的同學可以進入這個類詳細看下。在這個例子中,我們會直接獲取到IntegerHandler,如下圖13中的代碼所示:

圖13 獲取IntegerHandler

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

圖14 IntegerHander值替換

後續就是執行SQL並處理返回結果,這就不在本文的討論範圍內了。從上文的分析中,我們可以瞭解到,在3.2.3及以下版本,MyBatis會忽略parameterType,在真正進行SQL轉換時,重新根據SQL方法入參類型,然後計算合適的TypeHandler處理器,所以本案例中的代碼在3.2.3版本時,它在運行時是正常的。

以版本3.2.4爲例,相比版本3.2.3,MyBatis構建SQL語句過程的變化分析

在前一章節中,我們得知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的構造方法。

圖15 生成SqlSource

而後續流程與3.2.3一致,因爲parameter類型爲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.Integer cannot be cast to java.lang.String的錯誤。

總結

我們總結一下這個案例:

MyBatis 3.2.3版本支持parameterType和實際參數類型不匹配,在執行SQL階段,動態計算值處理器類型。在大版本升級2個版本後,parameterType實際的類型開始生效,使用對應這個類型的TypeHandler對SQL進行參數替換,會導致Mapper方法中的參數和XML中的parameterType不匹配時,進而會出現類型轉換報錯。

這一段排查的經歷,對自己後續編寫代碼及在系統上線時也有一些啓發,主要包括以下幾個方面:

  • 在inf-bom升級時,需要線下進行全面迴歸,要避免框架存在不兼容的用法,不然的話,就容易導致線上錯誤。

  • 開發同學可以檢查自己系統內的MyBatis版本,如果是3.2.4以下,需要全面檢查下現在的Mapper文件裏對於parameterType的使用和Mapper方法中實際的參數類型是否一致,避免升級到3.2.4及以上版本時發生轉型報錯。如果有不匹配的情況存在,需要進行修正或者不使用parameterType,讓MyBatis在運行SQL時自動計算對應的類型。

  • 可以考慮使用MyBatis-Generator來自動生成XML和Mapper文件,畢竟是專業團隊在維護,穩定性相對來說會更好一些,同時能夠避免手動修改XML文件帶來的誤操作。

  • 可以主動關注強依賴的一些開源框架的Release Log,不要錯過了重要的信息。

參考資料

帶你一步一步手撕 MyBatis 源碼加手繪流程圖——構建部分
帶你一步一步手撕 MyBatis 源碼加手繪流程圖——執行部分
MyBatis源碼解析(三)—緩存篇
面試官問你MyBatis SQL是如何執行的?把這篇文章甩給他
源碼分析(1.4萬字) | MyBatis接口沒有實現類爲什麼可以執行增刪改查
MyBatis/MyBatis-3/Comparing changes

作者簡介

凱倫,2016年校招加入美團,後端開發工程師。

----------  END  ----------

招聘信息

電子發票技術團隊負責美團發票平臺建設和相關創新探索工作,我們的宗旨是提升美團用戶在美團各業務場景下的開票體驗,同時賦能商家提升發票管理效率。歡迎志同道合的小夥伴加入,通過技術的力量爲億萬用戶提升電子發票服務體驗。感興趣的同學可投遞簡歷至:[email protected](郵件標題註明:電子發票業務)

也許你還想看

Netty堆外內存泄露排查與總結

疑案追蹤:Spring Boot內存泄露排查記

美團MySQL數據庫巡檢系統的設計與應用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章