1. 程式人生 > >MySQL的JDBC驅動原始碼解析 預編譯開啟

MySQL的JDBC驅動原始碼解析 預編譯開啟

一、背景

        現在我們淘寶持久化大多數是採用iBatis+MySQL做開發的,大家都知道,iBatis內建引數,形如#xxx#的,均採用了sql預編譯的形式,舉例如下:
<span style="font-size:18px;"><select id=”queryUserById” returnType=”userResult”>  
   SELECT * FROM user WHERE id =#id#
</select> </span>
       檢視日誌後,會發現這個sql執行時被記錄如下,SELECT * FROM user WHERE id = ?
       看過iBatis原始碼發現底層使用的就是JDBC的PreparedStatement,過程是先將帶有佔位符(即”?”)的sql模板傳送至mysql伺服器,由伺服器對此無引數的sql進行編譯後,將編譯結果快取,然後直接執行帶有真實引數的sql。查詢了相關文件及資料後, 基本結論都是,使用預編譯,可以提高sql的執行效率,並且有效地防止了sql注入。但是一直沒有親自去測試下,趁著最近看MySQL_JDBC的原始碼的契機,好好研究測試了下。測試結果出乎意料,發現原來一直以來我對PreparedStatement的理解是有誤的。我們平時使用的不管是JDBC還是ORM框架iBatis預設都沒有真正開啟預編譯,形如PreparedStatement( SELECT * FROMuser WHERE id = ? ),每次都是驅動拼好完整帶引數的SQL( SELECT * FROM user WHERE id = 5 ),然後再發送給MySQL服務端,壓根就沒用到如PreparedStatement名字的功能。諮詢了淘寶相關DBA    和相關TDDL同學,確認了現在我們線上使用的TDDL(JDBC)預設都是沒有開啟預編譯的,但是經過測試確實預編譯會快一點,DBA那邊之後會詳細測試並推廣到線上。

     接下來我會把探究過程跟大家分享並記錄下。

二、問題

       我的疑問有兩點:1.MySQL是否預設開啟了預編譯功能?若沒有,將如何開啟? 2.預編譯是否能有效提升執行SQL的效能?

三、探究一

      MySQL是否預設開啟了預編譯?

       首先針對第一個問題。我的電腦上已經安裝了MySQL,版本是5.1.9,開啟配置檔案my.ini,在"port=3306" 這一行下面加了配置:log=d:/logs/mysql_log.txt,這樣就開啟了MySQL日誌功能,該日誌主要記錄MySQL執行sql的過程。重啟MySQL,並建立一個庫studb,在該庫下建一個叫user的表,有id(主鍵)和username和password三個欄位。
         接著,我建立了一個簡單的Java工程,引入JDBC驅動包mysql-connector-java-5.0.3-bin.jar。然後寫了如下的程式碼:
<span style="font-size:18px;">public static void main(String[] args) throws Exception{  
	  String sql = "select * from userwhere id= ?";
	  Class.forName("com.mysql.jdbc.Driver");
	  Connection conn = null;  
	  try{  
	       conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/studb?user=root&password=root");  
	       PreparedStatement stmt = conn.prepareStatement(sql);
	       stmt.setString(1,5);
	       ResultSet rs = stmt.executeQuery();  
	       rs.close();  
	       stmt.close();  
	  }catch(Exception e){  
	       e.printStackTrace();  
	  }finally{  
	      conn.close();  
	  }  
}  </span>
執行這些程式碼後,開啟剛才配置的mysql日誌檔案mysql_log.txt,日誌記錄如下:
<span style="font-size:18px;"> Query      SET NAMES utf8
 Query    SET character_set_results = NULL
 Query    SHOW VARIABLES
 Query    SHOW WARNINGS
 Query    SHOW COLLATION
 Query    SET autocommit=1
 Prepare  select *from user where id = ?
 Execute  select * from user where id= 5
 Close stmt   
 Quit </span>
從MySQL日誌可以清晰看到,server端執行了一次預編譯Prepare及執行了一次Execute,預編譯sql模板為“select * from user where id= ?”,說明MySQL5.1.19+ mysql-connector-java-5.0.3是預設開啟預編譯的。但還是有很多疑惑,為什麼之前查閱資料,都說開啟預編譯是跟 useServerPrepStmts 引數有關的,於是將剛才程式碼裡的JDBC連線修改如下:
<span style="font-size:18px;">DriverManager.getConnection("jdbc:mysql://localhost:3306/prepare_stmt_test?user=root&password=root&useServerPrepStmts=false")</span>
執行程式碼後,再次檢視mysql日誌:
<span style="font-size:18px;">Query    SET NAMES utf8
Query    SET character_set_results = NULL
Query    SHOW VARIABLES
Query    SHOW WARNINGS
Query    SHOW COLLATION
Query    SET autocommit=1
Query    select * from user where id= 5
Quit   </span>

       果然,日誌沒有了prepare這一行,說明MySQL沒有進行預編譯。這意味著useServerPrepStmts這個引數是起效的,且預設值為true。
       最後意識到useServerPrepStmts這個引數是JDBC的連線引數,這說明此問題與JDBC驅動程式可能有關係。開啟MySQL官網,發現線上的官方文件很強大,支援全文檢索,於是我將“useServerPrepStmts”做為關鍵字,搜尋出了一些資訊,原文如下:

Important change: Due to a number ofissues with the use of server-side prepared statements, Connector/J5.0.5 

has disabled their use by default. The disabling of server-side prepared statements does not affect the operation of the connector in any way.To enable server-side prepared statements, add the following configuration property to your connectorstring:
useServerPrepStmts=true
The default value of thisproperty is false (that is,Connector/J does not use server-side prepared statements)

       這段文字說,Connector/J在5.0.5以後的版本,預設useServerPrepStmts引數為false,Connector/J就是我們熟知的JDBC驅動程式。看來,如果我們的驅動程式為5.0.5或之後的版本,想啟用mysql預編譯,就必須設定useServerPrepStmts=true。我的JDBC驅動用的是5.0.3,這個版本的useServerPrepStmts引數預設值是true。於是我將Java工程中的JDBC驅動程式替換為5.0.8的版本,去掉程式碼裡JDBC連線中的useServerPrepStmts引數,再執行,發現mysql_log.txt的日誌列印如下:

<span style="font-size:18px;"> Query    SHOW SESSIONVARIABLES
 Query    SHOW WARNINGS
 Query    SHOW COLLATION
 Query    SET NAMES utf8
 Query    SET character_set_results = NULL
 Query    SET autocommit=1
 Query    select * from user where id= 5
 Quit</span>

        果然,在mysql_log.txt日誌裡,prepare關鍵字沒有了,說明 useServerPrepStmts 引數確實跟JDBC驅動版本有關。另外還查閱了相關MySQL的官方文件後,發現MySQL服務端是在4.1版本才開始支援預編譯的,之後的版本都預設支援預編譯。
       第一個問題解決了,結論就是:要開啟預編譯功能跟MySQL版本及 MySQL Connector/J(JDBC驅動)版本都有關,首先MySQL服務端是在4.1版本之後才開始支援預編譯的,之後的版本都預設支援預編譯,並且預編譯還與 MySQL Connector/J(JDBC驅動)的版本有關, Connector/J 5.0.5之前的版本預設支援預編譯, Connector/J 5.0.5之後的版本預設不支援預編譯, 所以我們用的Connector/J 5.0.5驅動以後版本的話預設都是沒有開啟預編譯的 (如果需要開啟預編譯,需要配置 useServerPrepStmts 引數)

預編譯是否能有效提升執行SQL的效能?

      首先,我們要明白MySQL執行一個sql語句的過程。查了一些資料後,我得知,mysql執行指令碼的大致過程如下:prepare(準備)-> optimize(優化)-> exec(物理執行),其中,prepare也就是我們所說的編譯。前面已經說過,對於同一個sql模板,如果能將prepare的結果快取,以後如果再執行相同模板而引數不同的sql,就可以節省掉prepare(準備)的環節,從而節省sql執行的成本。明白這一點後,我寫了如下測試程式:
<span style="font-size:18px;">public static void main(String []a) throws Exception{  
      String sql = "select * from user whereid = ?";  
      Class.forName("com.mysql.jdbc.Driver");  
      Connection conn = null;  
      try{  
	      conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/studb?user=root&password=root&useServerPrepStmts=true");  
	      PreparedStatement stmt = conn.prepareStatement(sql);  
	      stmt.setString(1,5);  
	      ResultSet rs1 = stmt.executeQuery(); //第一次執行  
	      s1.close();  
	      stmt.setString(1,9);  
	      ResultSet rs2 = stmt.executeQuery(); //第二次執行  
	      rs2.close();  
	      stmt.close();  
      }catch(Exception e){  
          e.printStackTrace();  
      }finally{  
          conn.close();  
      }  
}   </span>
       執行該程式後,檢視mysql日誌:
<span style="font-size:18px;">Query    SHOW SESSION VARIABLES
Query    SHOW WARNINGS
Query    SHOW COLLATION
Query    SET NAMES utf8
Query    SET character_set_results = NULL
Query    SET autocommit=1
Prepare   select * from userwhere id = ?
Execute   select * from user where id = 5
Execute   select * from user where id = 9
Close stmt   
Quit</span>
       按照日誌看來,PreparedStatement重新設定sql引數後,並沒有重新prepare,看來預編譯起到了效果。但剛才我們使用的是同一個stmt,如果將stmt關閉,重新獲取一個stmt呢?
<span style="font-size:18px;">public static void main(String []a) throws Exception{  
	String sql = "select * from userwhere id = ?";  
	Class.forName("com.mysql.jdbc.Driver");  
	Connection conn = null;  
	try{  
		conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/studb?user=root&password=root&useServerPrepStmts=true");  
		PreparedStatement stmt = conn.prepareStatement(sql);  
		stmt.setString(1,5);  
		ResultSet rs1 = stmt.executeQuery(); //第一次執行  
		rs1.close();  
		stmt.close();
		stmt = conn.prepareStatement(sql); //重新獲取一個statement  
		stmt.setString(1,9);  
		ResultSet rs2 = stmt.executeQuery(); //第二次執行  
		rs2.close();  
		stmt.close();  
	}catch(Exception e){  
		e.printStackTrace();  
	}finally{  
		conn.close();  
	}  
}  </span>
mysql日誌列印如下:
<span style="font-size:18px;">Query    SHOW SESSION VARIABLES
Query    SHOW WARNINGS
Query    SHOW COLLATION
Query    SET NAMES utf8
Query    SET character_set_results = NULL
Query    SET autocommit=1
Prepare   select * from user where id=?
Execute   select * from user where id= 5
Close stmt   
Prepare   select *from user where id = ?
Execute   select * from user where id = 9
Close stmt   
Quit </span>
        很明顯,關閉stmt後再執行第二個sql,mysql就重新進行了一次預編譯,這樣是無法提高sql執行效率的。而在實際的應用場景中,我們不可能保持同一個statement。那麼,mysql如何快取預編譯結果呢?
        搜尋一些資料後得知,JDBC連線引數中有另外一個重要的引數:cachePrepStmts ,設定為true後可以快取預編譯結果。於是我將測試程式碼中JDBC連線串改為了這樣:
<span style="font-size:18px;">conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/prepare_stmt_test?user=root&password=root&useServerPrepStmts=true&cachePrepStmts=true");</span>
再執行程式碼後,發現mysql日誌記錄又變成了這樣:
<span style="font-size:18px;">Prepare  select * from user where id = ?
Execute  select * from user where id = 5
Execute  select * from user where id = 9</span>

        OK,現在我們才正式開啟了預編譯,並開啟了快取預編譯的功能。那麼接下來我們對預編譯語句("select * from userwhere id = ?")進行效能測試,測試資料如下:
         當不開啟預編譯功能時(String url ="jdbc:mysql://localhost:3306/studb"),做10次測試,100000個select總時間(單位毫秒)
<span style="font-size:18px;">12321,12173,12159,12132,12604,12349,12621,12356,12899,12287</span>
   (每次查詢一個RPC,每一個查詢,都會在mysql server端做一次編譯及一次執行)
      Mysql協議:xx xx xx xx QUERY .. .. .. .. .. ..
      Mysql協議:xx xx xx xx QUERY .. .. .. .. .. ..
      開啟預編譯,但不開啟預編譯快取時(String url= "jdbc:mysql://localhost:3306/studb?useServerPrepStmts=true"),做10次測試,100000個select總時間為(單位毫秒)
<span style="font-size:18px;"> 21349,22860,27237,26848,27772,28100,23114,22897,20010,23211</span>
    (每次查詢需要兩個RPC,第一個RPC是編譯,第二個RPC是執行,進測試資料可以看到這種其實與不開啟預編譯相比居然還慢,因為多了一次RPC,網路開銷在那裡)
      Mysql協議:xx xx xx xx PREPARE .. .. .. .. .. ..
      Mysql協議:xx xx xx xx EXECUTE PS_ID .. 
      Mysql協議:xx xx xx xx PREPARE .. .. .. .. .. ..
      Mysql協議:xx xx xx xx EXECUTE PS_ID .. 

開啟預編譯,並開啟預編譯快取時(String url ="jdbc:mysql://localhost:3306/studb?useServerPrepStmts=true&cachePrepStmts=true"),做10次測試,100000個select總時間為
<span style="font-size:18px;">8732,8655,8678,9693,8624,9874,8444,9660,8607,8780</span>
    (第一次兩個RPC,之後都是一個RPC,第一次會因為編譯sql模板走一次RPC,後面都只需要執行一次RPC,在 mysql server端不需要編譯,只需要執行)
      Mysql協議:xx xx xx xx PREPARE .. .. .. .. .. ..
      Mysql協議:xx xx xx xx EXECUTE PS_ID .. 
      Mysql協議:xx xx xx xx EXECUTE PS_ID .. 

從測試結果看來,若開啟預編譯,但不開啟預編譯快取,查詢效率會有明顯下降,因為需要走多次RPC,且每個查詢都需要編譯及執行;開啟預編譯並且開啟預編譯快取的明顯比不開啟預編譯的查詢效能好30%左右(這個是本機測試,還需要更多驗證)。
結論:對於Connector/J5.0.5以後的版本,若使用useServerPrepStmts=true開啟預編譯,則一定需要同時使用cachePrepStmts=true 開啟預編譯快取,否則效能會下降,只有二者都開啟,才算是真正開啟了預編譯功能,效能會比不開啟預編譯提升30%左右(這個可能是我測試程式的原因,有待進一步研究)

四、預編譯JDBC驅動原始碼剖析

  首先對於開啟預編譯的URL(String url ="jdbc:mysql://localhost:3306/studb?useServerPrepStmts=true&cachePrepStmts=true")獲取資料庫連線之後,本質是獲取預編譯語句pstmt = conn.prepareStatement(sql)時會向MySQL服務端傳送一個RPC,傳送一個預編譯的SQL模板(驅動會拼接mysql預編譯語句prepare s1 from 'select * fromuser where id = ?'),然會MySQL服務端會編譯好收到的SQL模板,再會為此預編譯模板語句分配一個serverStatementId傳送給JDBC驅動,這樣以後PreparedStatement就會持有當前預編譯語句的服務端的serverStatementId,並且會把此 PreparedStatement快取在當前資料庫連線中,以後對於相同SQL模板的操作pstmt.executeUpdate(),都用相同的PreparedStatement,執行SQL時只需要傳送serverStatementId和引數,節省一次SQL編譯, 直接執行。並且對於每一個連線(驅動端及Mysql服務端)都有自己的preparecache,具體的原始碼實現是在com.mysql.jdbc.ServerPreparedStatement中實現。