JDBC:深入理解PreparedStatement和Statement


前言
最近聽一個老師講了公開課,在其中講到了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預編譯功能

在寫這篇文章的時候發生了很多讓人惱火的事情,比如網上很多的答案基本上都是錯誤的,竟然還有人說好??不知道就不要亂說,亂發表博客,誤人子弟!!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM