如何規避MyBatis使用過程中帶來的全表更新風險

2023-03-14 09:05:46

作者:京東零售 賈玉西

一、前言

程式設計師A: MyBatis用過吧?

程式設計師B: 用過

程式設計師A: 好巧,我也用過,那你遇到過什麼風險沒?比如全表資料被更新或者刪除了。

程式設計師B: 咔,還沒遇到過,這種情況需要跑路嗎?

程式設計師A: 哈哈,不至於。但使用過程中,由於業務資料校驗不當,確實可能會造成全表更新或者刪除。

程式設計師B: 喔,嚇死我了,我們都是好人,不會做刪庫跑路類似蠢事,能展開講講這個風險怎樣造成的嗎?

程式設計師A: 好的,你能看出下面這段程式碼會有風險嗎?

程式設計師B: 平時大家都這樣寫的,也沒看出啥風險呀!

程式設計師A: 假如DAO層沒做非空校驗,relationId欄位傳入為空,這段程式碼組裝出來的是什麼語句?

程式設計師B: update cms_relation_area_code set yn = 1 where yn = 0 我擦,全表被邏輯刪除了!哥哥,我們的web應用數量多,程式碼行數幾十萬行,你怎麼處理的呀,不會人力梳理程式碼吧?得累死......

程式設計師A: 昂,可以的,基於MyBatis的擴充套件點可以實現一款外掛做到降低全表更新的風險,降低人工成本。

程式設計師B: 哥哥,要不講講MyBatis和實現的外掛?

程式設計師A: 那必須嘞,技術是需要分享和互補的。

不知大家在使用MyBatis有沒有過程式設計師A哥哥遇到的事件?好巧,本人也經歷過跟程式設計師A小哥哥一樣的境遇,初始思路也是人工梳理程式碼,後來經由架構師點撥能不能開發一款SDK統一處理,要不然就扛著身體去梳理這幾十萬行程式碼了。要不一起聊聊這塊,共同成長~

一起先看下MyBatis原理吧?當然這部分比較枯燥,本篇文章也不會大廢篇幅去介紹這塊,簡單給大家聊下基本流程,對MyBatis原理不感興趣的同學可以直接跳到第三章往後看

那... 第二章我就簡單開始淡筆介紹MyBatis了,在座各位好友沒啥意見吧,想更深入瞭解學習,可以讀下原始碼,或者閱讀下京東架構-小傅哥手擼MyBatis專欄部落格(地址:bugstack.cn

二、MyBatis 原理

先來看下MyBatis執行的概括執行流程,就不逐步貼原始碼了,東西實在多...

//1.載入組態檔
InputStream inputStream =Resources.getResourceAsStream(「mybatis-config.xml」);
//2.建立 SqlSessionFactory 物件(實際建立的是 DefaultSqlSessionFactory 物件)
SqlSessionFactory builder =newSqlSessionFactoryBuilder().build(inputStream);
//3.建立 SqlSession 物件(實際建立的是 DefaultSqlSession 物件)
SqlSession sqlSession = builder.openSession(); 
//4.建立代理物件
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
//5.執行查詢語句
List<User> users = mapper.selectUserList();
//釋放資源
sqlSession.close();
inputStream.close();

mybatis整個執行流程,可以抽象為上面5步核心流程,咱們這裡只講解XML開發的方式,註解的方式基本核心思想一致:

第一步:讀取mybatis-config.xml組態檔。轉化為流,這一步沒有需要細說的。

第二步:建立SqlSessionFactory 物件。 實際建立的是DefaultSqlSessionFactory物件,這裡SqlSessionFactory和DefaultSqlSessionFactory的關係為:SqlSessionFactory是一個介面,DefaultSqlSessionFactory是該介面的一個實現,也是利用了Java的多型特性。SqlSessionFactory是MyBatis中的一個重要的物件,漢譯過來可以叫做:SQL對談工廠,見名知意,它是用來建立SQL對談的一個工廠類,它可以通過SqlSessionFactoryBuilder來獲得,SqlSessionFactory是用來建立SqlSession物件的,SqlSession就是SQL對談工廠所建立的SQL對談。並且SqlSessionFactory是執行緒安全的,它一旦被建立,應該在應用執行期間都存在,在應用執行期間(也就是Application作用域)不要重複建立多次,建議使用單例模式。

第三步:建立 SqlSession 物件。 實際建立的是 DefaultSqlSession 物件,這裡同上步,SqlSession為介面,DefaultSqlSession為SqlSession介面的一個實現類,SqlSession的主要作用是用來運算元據庫的,它是MyBatis 核心 API,主要用來執行命令,獲取對映,管理事務等。SqlSession雖然提供select/insert/update/delete方法,在舊版本中使用使用SqlSession介面的這些方法,但是新版的Mybatis中就會建議使用Mapper介面的方法,也就是下面要講到的第四步操作。SqlSession物件,該物件中包含了執行SQL語句的所有方法,類似於JDBC裡面的Connection。在JDBC中,Connection不直接執行SQL方法,而是生成Statement或者PrepareStatement物件,利用Statement或者PrepareStatement來執行增刪改查方法;在MyBatis中,SqlSession可以直接執行增刪改查方法,可以通過提供的 selectOne、 insert等方法,也可以獲取對映器Mapper來執行增刪改查操作,通過對映器Mapper來執行增刪改查如第四步程式碼所示。這裡需要注意的是SqlSession 的範例不是執行緒安全的,因此是不能被共用的,所以它的最佳的作用域是請求或方法作用域。絕對不能將 SqlSession 範例的參照放在一個類的靜態域。

第四步:建立代理物件。 SqlSession一個重要的方法getMapper,顧名思義,這個方法是用來獲取Mapper對映器的。什麼是MyBatis對映器?MyBatis框架包括兩種型別的XML檔案,一類是組態檔,即mybatis-config.xml,另外一類是操作DAO層的對映檔案,例如UserInfoMapper.xml等等。在MyBatis的組態檔mybatis-config.xml包含了標籤節點,這裡就是MyBatis對映器。也可以理解為標籤下設定的各種DAO操作的mapper.xml的對映檔案與DaoMapper介面的一種對映關係。對映器只是一個介面,而不是一個實現類。可能初學者可能會產生一個很大的疑問:介面不是不能執行嗎?的確,介面不能直接執行,但是MyBatis內部運用了動態代理技術,生成介面的實現類,從而完成介面的相關功能。所以在第四步這裡 MyBatis 會為這個介面生成一個代理物件。

第五步:執行SQL操作以及釋放連線操作。

Emmm... 再補張圖吧,剛剛的介紹感覺還沒開始就結束了,通過下面這張圖我們再深入瞭解下MyBatis整體設計(此圖借鑑京東架構-小傅哥手擼MyBatis專欄)

第一步:讀取Mybatis組態檔。

第二步:建立SqlSessionFactory物件。 上面已經對SqlSessionFactory做了說明,但SqlSessionFactoryBuilder具體還沒描述,SqlSessionFactoryBuilder是構造器,見名知意,它的主要作用便是構造SqlSessionFactory範例,基本流程為根據傳入的資料流建立XMLConfigBuilder,生成Configuration物件,然後根據Configuration物件建立預設的SqlSessionFactory範例。XMLConfigBuilder主要作用是解析mybatis-config.xml中的標籤資訊,如圖中列舉出的兩個標籤資訊,解析環境資訊及mapper.xml資訊,解析mapper.xml時,Mybatis預設XML驅動類為XMLLanguageDriver,它的主要作用是解析select、update、insert、delete節點為完整的SQL語句,也是對應SQL的解析過程,XMLLanguageDriver在解析mapper.xml時,會將解析結果儲存至SqlSource的實現類中,SqlSource是一個介面,只定義了一個 getBoundSql() 方法,它控制著動態 SQL 語句解析的整個流程,它會根據從 Mapper.xml 對映檔案解析到的 SQL 語句以及執行 SQL 時傳入的實參,返回一條可執行的 SQL。它有三個重要的實現類,對應圖中寫到的RawSqlSource、DynamicSqlSource及StaticSqlSource,其中RawSqlSource處理的是非動態 SQL 語句,DynamicSqlSource處理的是動態 SQL 語句,StaticSqlSource是BoundSql中要儲存SQL語句的一個載體,上面RawSqlSource、DynamicSqlSource的SQL語句,最終都會儲存到StaticSqlSource實現類中。StaticSqlSource的 getBoundSql() 方法是真正建立 BoundSql 物件的地方, BoundSql 包含了解析之後的 SQL 語句、欄位、每個「#{}」預留位置的屬性資訊、實參資訊等。這裡也重點介紹下Configuration物件,Configuration 的建立會裝載一些基本屬性,如事務,資料來源,快取,代理,型別處理器等,從這裡可以看出 Configuration 也是一個大的容器,來為後面的SQL語句解析和初始化提供保障,也是Mybatis中貫穿全域性的存在,後續我們要提到的Mybatis降低全表更新外掛,也是基於這個物件來完成。其中解析mapper.xml這步最終作用便是將解析的每一條CRUD語句封裝成對應的MappedStatement存放至Configuration中。

第三步:建立SqlSession物件。 建立過程中會建立另外兩個東西,事務及執行器,SqlSession可以說只是一個前臺客服,真正發揮作用的是Executor,它是 MyBatis 排程的核心,負責 SQL 語句的生成以及查詢快取的維護,對SqlSession方法的存取最終都會落到Executor的相應方法上去。Executor分成兩大類:一類是CachingExecutor,另一類是普通的Executor。CachingExecutor是在開啟二級快取中用到的,二級快取是慎開啟的,這裡只介紹普通的Executor,普通的Executor分為三大類,SimpleExecutor、ReuseExecutor和BatchExecutor,他們是根據全域性設定來建立的。SimpleExecutor是一種常規執行器,也是預設的執行器,每次執行都會建立一個Statement,用完後關閉;ReuseExecutor是可重用執行器,將Statement存入map中,操作map中的Statement而不會重複建立Statement;BatchExecutor是批次處理型執行器,專門用於執行批次sql操作。總之,Executor最終是通過JDBC的java.sql.Statement來執行資料庫操作。

第四步:獲取Mapper代理物件。 上面也已經提到了這塊用到的是jdk動態代理技術,這裡MapperRegistry和MapperProxyFactory在解析mapper.xml已經被建立儲存在了Configuration中,這步主要就是從MapperProxyFactory獲取MapperProxy代理。其中MapperMethod主要的功能是執行SQL的相關操作,它根據提供的Mapper的介面路徑,待執行的方法以及設定Configuration作為入參來執行對應的MappedStatement操作。

第五步:執行SQL操作。 這步就是執行執行對應的MappedStatement操作,Executor最終是通過JDBC的java.sql.Statement來執行資料庫操作。但其實真正負責操作的是StatementHanlder物件,StatementHanlder封裝了JDBC Statement 操作,負責對 JDBC Statement 的操作,它通過控制不同的子類,去執行完整的一條SQL執行與解析的流程。

三、MyBatis攔截器

Mybatis一共提供了四大擴充套件點,也稱作四大攔截器外掛,它是生成層層代理物件的一種責任鏈模式。這裡代理的實現方式是將切入的目標處理器與攔截器進行包裝,生成一個代理類,在執行invoke方法前先執行自定義攔截器外掛的邏輯從而實現的一種攔截方式。每個處理器在Mybatis的整個執行鏈路中扮演的角色也不同,大家如果有想法可以基於這幾個擴充套件點實現一款自己的攔截器外掛。例如我們常用的一個分頁外掛pageHelper就是利用Executor攔截器實現的,有興趣的可以自行閱讀下pageHelper原始碼。MyBatis一共提供了四個擴充套件點:

Executor (update, query, ……)

Executor根據傳遞的引數,完成SQL語句的動態解析,生成BoundSql物件,供StatementHandler使用。建立JDBC的Statement連線物件,傳遞給StatementHandler物件。這裡Executor又稱作 SQL執行器

· StatementHandler (prepare, parameterize, ……)

StatementHandler對於JDBC的PreparedStatement型別的物件,建立的過程中,這時的SQL語句字串是包含若干個 「?」 預留位置。這裡StatementHandler又稱作SQL 語法構建器

· ParameterHandler (getParameterObject, ……)

ParameterHandler用於SQL對引數的處理,這步會通過TypeHandler將預留位置替換為引數值,接著繼續進入PreparedStatementHandler物件的query方法進行查詢。這裡ParameterHandler又稱作引數處理器

· ResultSetHandler (handleResultSets, ……)

ResultSetHandler進行最後資料集(ResultSet)的封裝返回處理。這裡ResultSetHandler又稱作結果集處理器

四、MyBatis防止全表更新外掛

上面說到程式設計師A小哥哥遇到過歷史業務引數因校驗問題造成了全表更新的風險,梳理程式碼成本又過高,不符合當下網際網路將本增效的理念。那麼有沒有一種成本又低,效率又高,又能通用的產品來解決此類問題呢?

當然有了!!! 不然這篇貼文擱這湊績效呢? 哈哈... 不好笑不好笑,見諒。

第三章節中,提到MyBatis為使用者提供了四個擴充套件點,那麼我們就可以藉助擴充套件點來實現一個Mybatis防止全表更新的外掛,具體怎麼實現呢?這裡博主是使用StatementHandler攔截器抽象出來一個SDK供需求方接入,攔截器具體用法參考度娘,這裡SDK實現流程為:獲取預處理SQL及引數值 --> 替換預留位置組裝完整SQL --> SQL語句規則解析 --> 校驗是否為全表更新SQL。 當然還做了一些橫向擴充套件,這裡放張圖吧,更清晰些。

那麼這個外掛能攔截哪些型別的SQL語句呢?

·無where條件:update/delete table 

·邏輯刪除欄位:update/delete table where yn = 0  //yn為邏輯刪除欄位

·拼接條件語句:update/delete table where 1 = 1

·AND條件語句:update/delete table where 1 = 1 and 1 <> 2

·OR 條件語句:update/delete table where 1 = 1 or 1 <> 2

然後聊下怎麼接入吧:

4.1 檢查專案依賴

scope為provided的請在專案中加入該jar包依賴,此外掛預設引入p6spy、jsqlparser依賴,如遇版本衝突請排包

<dependency>    
    <groupId>org.slf4j</groupId>    
    <artifactId>slf4j-api</artifactId>    
    <version>${slf4j.version}</version>    
    <scope>provided</scope>
</dependency>
<dependency>    
    <groupId>p6spy</groupId>    
    <artifactId>p6spy</artifactId>    
    <version>${p6spy.version}</version>
</dependency>
<dependency>    
    <groupId>org.mybatis</groupId>    
    <artifactId>mybatis</artifactId>    
    <version>${mybatis.version}</version>    
    <scope>provided</scope>
</dependency>
<dependency>    
    <groupId>org.mybatis</groupId>    
    <artifactId>mybatis-spring</artifactId>    
    <version>${mybatis-spring.version}</version>    
    <scope>provided</scope>    
    <exclusions>        
        <exclusion>            
        <groupId>org.mybatis</groupId>            
        <artifactId>mybatis</artifactId>        
        </exclusion>    
    </exclusions>
</dependency>
<dependency>    
    <groupId>com.github.jsqlparser</groupId>    
    <artifactId>jsqlparser</artifactId>    
    <version>${jsqlparser.version}</version>
</dependency>
<dependency>    
    <groupId>org.springframework</groupId>    
    <artifactId>spring-core</artifactId>    
    <version>${spring.core.version}</version>    
    <scope>provided</scope>
</dependency>

4.2 專案中引入防止全表更新依賴SDK

<dependency>    
    <groupId>com.jd.o2o</groupId>    
    <artifactId>o2o-mybatis-interceptor</artifactId>    
    <version>1.0.0-SNAPSHOT</version>
</dependency>

4.3 專案中新增設定

springboot專案使用方式: 設定類中加入攔截器設定

@Configuration
public class MybatisConfig {    
    @Bean    
    ConfigurationCustomizer configurationCustomizer() {        
        return new ConfigurationCustomizer() {            
            @Override            
            public void customize(org.apache.ibatis.session.Configuration configuration) {                
                FullTableDataOperateInterceptor fullTableDataOperateInterceptor = new FullTableDataOperateInterceptor();                
                //表預設邏輯刪除欄位,按需設定,update cms set name = "zhangsan" where yn = 0,yn為邏輯刪除資源,此語句被認為是全表更新語句                
                fullTableDataOperateInterceptor.setLogicField("yn");                
                //白名單表,按需設定,設定的白名單表不攔截該表全表更新操作                
                fullTableDataOperateInterceptor.setWhiteTables(Arrays.asList("tableName1","tableName2"));                                
                //個別表的邏輯刪除欄位對映,如果設定此項,此表邏輯刪除欄位優先走該表設定,key為表名,value為該表的邏輯刪除欄位名,每對key-value以英文逗號分隔設定                
                Map<String,String> tableToLogicFieldMap = new HashMap<>();                
                tableToLogicFieldMap.put("tableName3","ynn");                
                tableToLogicFieldMap.put("tableName4","ynn");                
                fullTableDataOperateInterceptor.setTableToLogicFieldMap(tableToLogicFieldMap);                
                //設定攔截器                
                configuration.addInterceptor(fullTableDataOperateInterceptor);            
            }        
        };    
    }
}

傳統SSM專案使用方式: 在mybatis.xml中追加plugin設定

<configuration>      
    <plugins>        
        <plugin interceptor="com.jd.o2o.cms.mybatis.interceptor.FullTableDataOperateInterceptor">            
            //表預設邏輯刪除欄位,按需設定,update cms set name = "zhangsan" where yn = 0,yn為邏輯刪除欄位,此語句被認為是全表更新語句            
            <property name="logicField" value="yn"/>            
            //白名單表,按需設定,設定的白名單表不攔截該表全表更新操作            
            <property name="whiteTables" value="tableName1,tableName2"/>            
            //個別表的邏輯刪除欄位對映,如果設定此項,此表邏輯刪除欄位優先走該表設定,key為表名,value為該表的邏輯刪除欄位名,每對key-value以英文逗號分隔設定            
            <property name="tableToLogicFieldMap" value="key1:value1,key2:value2"/>        
        </plugin>    
    </plugins>
</configuration>

4.4 新增紀錄檔輸出

該外掛有四處輸出error紀錄檔,具體可看原始碼

<Logger name="com.jd.o2o.cms.mybatis.interceptor" level="error" additivity="false">    
    <AppenderRef ref="RollingFileError"/>
</Logger>

4.5 效能及接入說明

大家最關心的可能是,接入這個SDK後,對我們資料庫操作的效能有多大影響,這裡針對效能做下說明:

•select:無效能影響

•insert:不足千分之一毫秒

•update:約為0.02毫秒

•delete:約為0.02毫秒

然後就是對接入的風險的考慮,如果為該外掛解析過程中的異常,該外掛直接catch交由MyBatis進行下個執行鏈的處理,對業務流程無影響,程式碼為證: