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中實現。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章