前言
最近聽一個老師講了公開課,在其中講到了PreparedStatement的執行原理和Statement的區別。
當時聽公開課老師講的時候感覺以前就只知道PreparedStatement是“預編譯類”,能夠對sql語句進行預編譯,預編譯后能夠提高數據庫sql語句執行效率。
但是,聽了那個老師講后我就突然很想問自己,預編譯??是誰對sql語句的預編譯??是數據庫?還是PreparedStatement對象??到底什么是預編譯??為什么能夠提高效率??為什么在數據庫操作時能夠防止sql注入攻擊??這就引起了我對Preparedstatement的疑惑。
公開課老師講的時候說:”PreparedStatement會對sql文進行預編譯,預編譯后,會存儲在PreparedStatement對象中,等下次再執行這個PreparedStatement對象時,會提高很多效率”。這句話我聽了后更疑惑了,預編譯是什么我不知道就算了,竟然還說:對sql預編譯后會存儲在PreparedStatement對象中??我就想問問sql預編譯后是什么??什么被存儲在PreparedStatement對象中??
更讓人感覺疑惑的是Statement。對就是Statement,公開課老師說:“同一條sql語句(字符串都是相同的)在Statement對象中多次執行時,Statement只會對當前sql文編譯一次,編譯后存儲在Statement中,在之后的執行過程中,都不會進行編譯而是直接運行sql語句”。什么??我沒聽錯吧?Statement還有編譯??等等等等。。。。我當時真的是聽的懷疑人生。
PreparedStatement
在說PreparedStatement之前,我們來看看什么是預編譯。其實預編譯是MySQL數據庫本身都支持的。但是MySQL Server 4.1之前的版本是不支持預編譯的。(具體是否包括4.1還得讀者們親自試驗)
在這里,筆者用的是MySQL5.6綠色版。
MySQL中的預編譯功能是這樣的
預編譯的好處:
大家平時都使用過JDBC中的PreparedStatement接口,它有預編譯功能。什么是預編譯功能呢?它有什么好處呢?
當客戶發送一條SQL語句給服務器后,服務器總是需要校驗SQL語句的語法格式是否正確,然后把SQL語句編譯成可執行的函數,最后才是執行SQL語句。其中校驗語法,和編譯所花的時間可能比執行SQL語句花的時間還要多。
注意:可執行函數存儲在MySQL服務器中,並且當前連接斷開后,MySQL服務器會清除已經存儲的可執行函數。
如果我們需要執行多次insert語句,但只是每次插入的值不同,MySQL服務器也是需要每次都去校驗SQL語句的語法格式,以及編譯,這就浪費了太多的時間。如果使用預編譯功能,那么只對SQL語句進行一次語法校驗和編譯,所以效率要高。
MySQL執行預編譯
MySQL執行預編譯分為如三步:
1.執行預編譯語句,例如:prepare showUsersByLikeName from 'select * from user where username like ?';
2.設置變量,例如:set @username='%小明%';
3.執行語句,例如:execute showUsersByLikeName using @username;
如果需要再次執行myfun,那么就不再需要第一步,即不需要再編譯語句了:
1.設置變量,例如:set @username='%小宋%';
2.執行語句,例如:execute showUsersByLikeName using @username;
如果你看MySQL日志記錄,你就會看到:
配置MySQL日志記錄
路徑地址可以自己修改。
log-output=FILE
general-log=1
general_log_file="E:\mysql.log"
slow-query-log=1
slow_query_log_file="E:\mysql_slow.log"
long_query_time=2
配置之后就重啟MySQL服務器:
在cmd管理員界面執行以下操作。
net stop mysql
net start mysql
使用PreparedStatement執行sql查詢
JDBC MySQL驅動5.0.5以后的版本默認PreparedStatement是關閉預編譯功能的,所以需要我們手動開啟。而之前的JDBC MySQL驅動版本默認是開啟預編譯功能的。
MySQL數據庫服務器的預編譯功能在4.1之后才支持預編譯功能的。如果數據庫服務器不支持預編譯功能時,並且使用PreparedStatement開啟預編譯功能是會拋出異常的。這點非常重要。筆者用的是mysql-connector-jar-5.1.13版本的JDBC驅動。
在我們以前寫項目的時候,貌似都沒有注意是否開啟PreparedStatement的預編譯功能,以為它一直都是在使用的,現在看看不開啟PreparedStatement的預編譯,查看MySQL的日志輸出到底是怎么樣的。
@Test public void showUser(){ //數據庫連接 Connection connection = null; //預編譯的Statement,使用預編譯的Statement提高數據庫性能 PreparedStatement preparedStatement = null; //結果 集 ResultSet resultSet = null; try { //加載數據庫驅動 Class.forName("com.mysql.jdbc.Driver"); //通過驅動管理類獲取數據庫鏈接 connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis", "root", ""); //定義sql語句 ?表示占位符 String sql = "select * from user where username = ?"; //獲取預處理statement preparedStatement = connection.prepareStatement(sql); //設置參數,第一個參數為sql語句中參數的序號(從1開始),第二個參數為設置的參數值 preparedStatement.setString(1, "王五"); //向數據庫發出sql執行查詢,查詢出結果集 resultSet = preparedStatement.executeQuery(); preparedStatement.setString(1, "張三"); resultSet = preparedStatement.executeQuery(); //遍歷查詢結果集 while(resultSet.next()){ System.out.println(resultSet.getString("id")+" "+resultSet.getString("username")); } resultSet.close(); preparedStatement.close(); System.out.println("#############################"); } catch (Exception e) { e.printStackTrace(); }finally{ //釋放資源 if(resultSet!=null){ try { resultSet.close(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } } if(preparedStatement!=null){ try { preparedStatement.close(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } } if(connection!=null){ try { connection.close(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } }
這是輸出日志:
20 Query /* mysql-connector-java-5.1.13 ( Revision: ${bzr.revision-id} ) */SELECT @@session.auto_increment_increment 20 Query SHOW COLLATION 20 Query SET NAMES utf8mb4 20 Query SET character_set_results = NULL 20 Query SET autocommit=1 20 Query select * from user where username = '王五' 20 Query select * from user where username = '張三' 20 Quit
可以看到,在日志中並沒有看到"prepare"命令來預編譯"select * from user where username = ?"這個sql模板。所以我們一般用的PreparedStatement並沒有用到預編譯功能的,只是用到了防止sql注入攻擊的功能。防止sql注入攻擊的實現是在PreparedStatement中實現的,和服務器無關。筆者在源碼中看到,PreparedStatement對敏感字符已經轉義過了。
在PreparedStatement中開啟預編譯功能
設置MySQL連接URL參數:useServerPrepStmts=true,如下所示。
jdbc:mysql://localhost:3306/mybatis?&useServerPrepStmts=true
這樣才能保證mysql驅動會先把SQL語句發送給服務器進行預編譯,然后在執行executeQuery()時只是把參數發送給服務器。
再次執行上面的程序看下MySQL日志輸出:
21 Query SHOW WARNINGS 21 Query /* mysql-connector-java-5.1.13 ( Revision: ${bzr.revision-id} ) */SELECT @@session.auto_increment_increment 21 Query SHOW COLLATION 21 Query SET NAMES utf8mb4 21 Query SET character_set_results = NULL 21 Query SET autocommit=1 21 Prepare select * from user where username = ? 21 Execute select * from user where username = '王五' 21 Execute select * from user where username = '張三' 21 Close stmt 21 Quit
很明顯已經進行了預編譯,Prepare select * from user where username = ?,這一句就是對sql語句模板進行預編譯的日志。好的非常Nice。
注意:
我們設置的是MySQL連接參數,目的是告訴MySQL JDBC的PreparedStatement使用預編譯功能(5.0.5之后的JDBC驅動版本需要手動開啟,而之前的默認是開啟的),不管我們是否使用預編譯功能,MySQL Server4.1版本以后都是支持預編譯功能的。
cachePrepStmts參數
當使用不同的PreparedStatement對象來執行相同的SQL語句時,還是會出現編譯兩次的現象,這是因為驅動沒有緩存編譯后的函數key,導致二次編譯。如果希望緩存編譯后函數的key,那么就要設置cachePrepStmts參數為true。例如:
jdbc:mysql://localhost:3306/mybatis?useServerPrepStmts=true&cachePrepStmts=true
程序代碼:
@Test public void showUser(){ //數據庫連接 Connection connection = null; //預編譯的Statement,使用預編譯的Statement提高數據庫性能 PreparedStatement preparedStatement = null; //結果 集 ResultSet resultSet = null; try { //加載數據庫驅動 Class.forName("com.mysql.jdbc.Driver"); //通過驅動管理類獲取數據庫鏈接 connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis?&useServerPrepStmts=true&cachePrepStmts=true", "root", ""); preparedStatement=connection.prepareStatement("select * from user where username like ?"); preparedStatement.setString(1, "%小明%"); resultSet = preparedStatement.executeQuery(); //遍歷查詢結果集 while(resultSet.next()){ System.out.println(resultSet.getString("id")+" "+resultSet.getString("username")); } //注意這里必須要關閉當前PreparedStatement對象流,否則下次再次創建PreparedStatement對象的時候還是會再次預編譯sql模板,使用PreparedStatement對象后不關閉當前PreparedStatement對象流是不會緩存預編譯后的函數key的 resultSet.close(); preparedStatement.close(); preparedStatement=connection.prepareStatement("select * from user where username like ?"); preparedStatement.setString(1, "%三%"); resultSet = preparedStatement.executeQuery(); //遍歷查詢結果集 while(resultSet.next()){ System.out.println(resultSet.getString("id")+" "+resultSet.getString("username")); } resultSet.close(); preparedStatement.close(); } catch (Exception e) { e.printStackTrace(); }finally{ //釋放資源 if(resultSet!=null){ try { resultSet.close(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } } if(preparedStatement!=null){ try { preparedStatement.close(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } } if(connection!=null){ try { connection.close(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } }
日志輸出:
24 Query SHOW WARNINGS 24 Query /* mysql-connector-java-5.1.13 ( Revision: ${bzr.revision-id} ) */SELECT @@session.auto_increment_increment 24 Query SHOW COLLATION 24 Query SET NAMES utf8mb4 24 Query SET character_set_results = NULL 24 Query SET autocommit=1 24 Prepare select * from user where username like ? 24 Execute select * from user where username like '%小明%' 24 Execute select * from user where username like '%三%' 24 Quit
注意:每次使用PreparedStatement對象后都要關閉該PreparedStatement對象流,否則預編譯后的函數key是不會緩存的。
Statement執行sql語句是否會對編譯后的函數進行緩存
這個不好說,對於每個數據庫的具體實現都是不一樣的,對於預編譯肯定都大體相同,但是對於Statement和普通sql,數據庫一般都是先檢查sql語句是否正確,然后編譯sql語句成為函數,最后執行函數。其實也不乏某些數據庫很瘋狂,對於普通sql的函數進行緩存。但是目前的主流數據庫都不會對sql函數進行緩存的。因為sql語句變化那么多,如果對所有函數緩存,那么對於內存的消耗也是非常巨大的。
如果你不確定普通sql語句的函數是否被存儲,那要怎么做呢??
其實還是一個道理,查看MySQL日志記錄:檢查第二次執行相同sql語句時,是否是直接通過execute來進行查詢的。
@Test public void showUser(){ //數據庫連接 Connection connection = null; //預編譯的Statement,使用預編譯的Statement提高數據庫性能 PreparedStatement preparedStatement = null; //結果 集 ResultSet resultSet = null; try { //加載數據庫驅動 Class.forName("com.mysql.jdbc.Driver"); //通過驅動管理類獲取數據庫鏈接 connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis?&useServerPrepStmts=true&cachePrepStmts=true", "root", ""); Statement statement=connection.createStatement(); resultSet = statement.executeQuery("select * from user where username='小天'"); //遍歷查詢結果集 while(resultSet.next()){ System.out.println(resultSet.getString("id")+" "+resultSet.getString("username")); } resultSet.close(); statement.close(); statement=connection.createStatement(); resultSet = statement.executeQuery("select * from user where username='小天'"); //遍歷查詢結果集 while(resultSet.next()){ System.out.println(resultSet.getString("id")+" "+resultSet.getString("username")); } resultSet.close(); statement.close(); } catch (Exception e) { e.printStackTrace(); }finally{ //釋放資源 if(resultSet!=null){ try { resultSet.close(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } } if(preparedStatement!=null){ try { preparedStatement.close(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } } if(connection!=null){ try { connection.close(); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } }
日志記錄:
26 Query SHOW WARNINGS 26 Query /* mysql-connector-java-5.1.13 ( Revision: ${bzr.revision-id} ) */SELECT @@session.auto_increment_increment 26 Query SHOW COLLATION 26 Query SET NAMES utf8mb4 26 Query SET character_set_results = NULL 26 Query SET autocommit=1 26 Query select * from user where username='小天' 26 Query select * from user where username='小天' 26 Quit
看日志就會知道,都是Query命令,所以並沒有存儲函數。
總結:
所以到了這里我的疑惑都解開了,PreparedStatement的預編譯是數據庫進行的,編譯后的函數key是緩存在PreparedStatement中的,編譯后的函數是緩存在數據庫服務器中的。預編譯前有檢查sql語句語法是否正確的操作。只有數據庫服務器支持預編譯功能時,JDBC驅動才能夠使用數據庫的預編譯功能,否則會報錯。預編譯在比較新的JDBC驅動版本中默認是關閉的,需要配置連接參數才能夠打開。在已經配置好了數據庫連接參數的情況下,Statement對於MySQL數據庫是不會對編譯后的函數進行緩存的,數據庫不會緩存函數,Statement也不會緩存函數的key,所以多次執行相同的一條sql語句的時候,還是會先檢查sql語句語法是否正確,然后編譯sql語句成函數,最后執行函數。
對於PreparedStatement在設置參數的時候會對參數進行轉義處理。
因為PreparedStatement已經對sql模板進行了編譯,並且存儲了函數,所以PreparedStatement做的就是把參數進行轉義后直接傳入參數到數據庫,然后讓函數執行。這就是為什么PreparedStatement能夠防止sql注入攻擊的原因了。
PreparedStatement的預編譯還有注意的問題,在數據庫端存儲的函數和在PreparedStatement中存儲的key值,都是建立在數據庫連接的基礎上的,如果當前數據庫連接斷開了,數據庫端的函數會清空,建立在連接上的PreparedStatement里面的函數key也會被清空,各個連接之間的預編譯都是互相獨立的。
使用Statement執行預編譯
使用Statement執行預編譯就是把上面的原始SQL語句預編譯執行一次。
Connection con = JdbcUtils.getConnection(); Statement stmt = con.createStatement(); stmt.executeUpdate("prepare myfun from 'select * from t_book where bid=?'"); stmt.executeUpdate("set @str='b1'"); ResultSet rs = stmt.executeQuery("execute myfun using @str"); while(rs.next()) { System.out.print(rs.getString(1) + ", "); System.out.print(rs.getString(2) + ", "); System.out.print(rs.getString(3) + ", "); System.out.println(rs.getString(4)); } stmt.executeUpdate("set @str='b2'"); rs = stmt.executeQuery("execute myfun using @str"); while(rs.next()) { System.out.print(rs.getString(1) + ", "); System.out.print(rs.getString(2) + ", "); System.out.print(rs.getString(3) + ", "); System.out.println(rs.getString(4)); } rs.close(); stmt.close(); con.close();
在持久層框架中存在的問題
很多主流持久層框架(MyBatis,Hibernate)其實都沒有真正的用上預編譯,預編譯是要我們自己在參數列表上面配置的,如果我們不手動開啟,JDBC驅動程序5.0.5以后版本 默認預編譯都是關閉的。
所以我們要在參數列表中配置,例如:
jdbc:mysql://localhost:3306/mybatis?&useServerPrepStmts=true&cachePrepStmts=true
注意:
在MySQL中,既要開啟預編譯也要開啟緩存。因為如果只是開啟預編譯的話效率還沒有不開啟預編譯效率高,大家可以做一下性能測試,其中性能測試結果在這篇博客中有寫到,探究mysql預編譯,而在MySQL中開啟預編譯和開啟緩存,其中的查詢效率和不開啟預編譯和不開啟緩存的效率是持平的。這里用的測試類是PreparedStatement。
參考資料:
探究mysql預編譯
PreparedStatement是如何大幅度提高性能的
參考中文文檔下載:MySQL預編譯功能
在寫這篇文章的時候發生了很多讓人惱火的事情,比如網上很多的答案基本上都是錯誤的,竟然還有人說好??不知道就不要亂說,亂發表博客,誤人子弟!!