本文重點講述MySQL中的預編譯語句並從MySQL的Connector/J源碼出發講述其在Java語言中相關使用。
注意:文中的描述與結論基於MySQL 5.7.16以及Connect/J 5.1.42版本。html
一般咱們的一條sql在db接收到最終執行完畢返回能夠分爲下面三個過程:java
咱們把這種普通語句稱做Immediate Statements。mysql
可是不少狀況,咱們的一條sql語句可能會反覆執行,或者每次執行的時候只有個別的值不一樣(好比query的where子句值不一樣,update的set子句值不一樣,insert的values值不一樣)。
若是每次都須要通過上面的詞法語義解析、語句優化、制定執行計劃等,則效率就明顯不行了。git
所謂預編譯語句就是將這類語句中的值用佔位符替代,能夠視爲將sql語句模板化或者說參數化,通常稱這類語句叫Prepared Statements或者Parameterized Statements
預編譯語句的優點在於概括爲:一次編譯、屢次運行,省去了解析優化等過程;此外預編譯語句能防止sql注入。
固然就優化來講,不少時候最優的執行計劃不是光靠知道sql語句的模板就能決定了,每每就是須要經過具體值來預估出成本代價。github
注意MySQL的老版本(4.1以前)是不支持服務端預編譯的,但基於目前業界生產環境廣泛狀況,基本能夠認爲MySQL支持服務端預編譯。sql
下面咱們來看一下MySQL中預編譯語句的使用。
首先咱們有一張測試表t,結構以下所示:apache
mysql> show create table t\G *************************** 1. row *************************** Table: t Create Table: CREATE TABLE `t` ( `a` int(11) DEFAULT NULL, `b` varchar(20) DEFAULT NULL, UNIQUE KEY `ab` (`a`,`b`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 1 row in set (0.00 sec)
咱們接下來經過 PREPARE stmt_name FROM preparable_stm
的語法來預編譯一條sql語句緩存
mysql> prepare ins from 'insert into t select ?,?'; Query OK, 0 rows affected (0.00 sec) Statement prepared
咱們經過EXECUTE stmt_name [USING @var_name [, @var_name] ...]
的語法來執行預編譯語句服務器
mysql> set @a=999,@b='hello'; Query OK, 0 rows affected (0.00 sec) mysql> execute ins using @a,@b; Query OK, 1 row affected (0.01 sec) Records: 1 Duplicates: 0 Warnings: 0 mysql> select * from t; +------+-------+ | a | b | +------+-------+ | 999 | hello | +------+-------+ 1 row in set (0.00 sec)
能夠看到,數據已經被成功插入表中。session
MySQL中的預編譯語句做用域是session級,但咱們能夠經過max_prepared_stmt_count變量來控制全局最大的存儲的預編譯語句。
mysql> set @@global.max_prepared_stmt_count=1; Query OK, 0 rows affected (0.00 sec) mysql> prepare sel from 'select * from t'; ERROR 1461 (42000): Can't create more than max_prepared_stmt_count statements (current value: 1)
當預編譯條數已經達到閾值時能夠看到MySQL會報如上所示的錯誤。
若是咱們想要釋放一條預編譯語句,則可使用{DEALLOCATE | DROP} PREPARE stmt_name
的語法進行操做:
mysql> deallocate prepare ins; Query OK, 0 rows affected (0.00 sec)
以上介紹了直接在MySQL上經過sql命令進行預編譯/緩存sql語句。接下來咱們以MySQL Java驅動Connector/J(版本5.1.42)爲例來介紹經過MySQL驅動進行預編譯。
首先,簡要提一下JDBC中java.sql.PreparedStatement是java.sql.Statement的子接口,它主要提供了無參數執行方法如executeQuery和executeUpdate等,以及大量形如set{Type}(int, {Type})形式的方法用於設置參數。
在Connector/J中,java.sql.connection的底層實現類爲com.mysql.jdbc.JDBC4Connection,它的類層次結構以下圖所示:
下面是我編寫以下測試類,程序中作的事情很簡單,就是往test.t表中插入一條記錄。
test.t表的結構在上述服務端預編譯語句中已經有展現,此處再也不贅述。
import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; /** * Test for PreparedStatement. * * @author Robin Wang */ public class PreparedStatementTest { public static void main(String[] args) throws Throwable { Class.forName("com.mysql.jdbc.Driver"); String url = "jdbc:mysql://localhost/test"; try (Connection con = DriverManager.getConnection(url, "root", null)) { String sql = "insert into t select ?,?"; PreparedStatement statement = con.prepareStatement(sql); statement.setInt(1, 123456); statement.setString(2, "abc"); statement.executeUpdate(); statement.close(); } } }
執行main方法後,經過MySQL通用日誌查看到相關log:
2017-07-04T16:39:17.608548Z 19 Connect root@localhost on test using SSL/TLS 2017-07-04T16:39:17.614299Z 19 Query /* mysql-connector-java-5.1.42 ( Revision: 1f61b0b0270d9844b006572ba4e77f19c0f230d4 ) */SELECT @@session.auto_increment_increment AS auto_increment_increment, @@character_set_client AS character_set_client, @@character_set_connection AS character_set_connection, @@character_set_results AS character_set_results, @@character_set_server AS character_set_server, @@init_connect AS init_connect, @@interactive_timeout AS interactive_timeout, @@license AS license, @@lower_case_table_names AS lower_case_table_names, @@max_allowed_packet AS max_allowed_packet, @@net_buffer_length AS net_buffer_length, @@net_write_timeout AS net_write_timeout, @@query_cache_size AS query_cache_size, @@query_cache_type AS query_cache_type, @@sql_mode AS sql_mode, @@system_time_zone AS system_time_zone, @@time_zone AS time_zone, @@tx_isolation AS tx_isolation, @@wait_timeout AS wait_timeout 2017-07-04T16:39:17.642476Z 19 Query SET character_set_results = NULL 2017-07-04T16:39:17.643212Z 19 Query SET autocommit=1 2017-07-04T16:39:17.692708Z 19 Query insert into t select 123456,'abc' 2017-07-04T16:39:17.724803Z 19 Quit
從MySQL驅動源碼中咱們能夠看到程序中對prepareStatement方法的調用最終會走到以下所示的代碼段中:
上圖截自com.mysql.jdbc.ConnectionImpl#prepareStatement(java.lang.String, int, int)
這裏有兩個很重要的參數useServerPrepStmts以及emulateUnsupportedPstmts用於控制是否使用服務端預編譯語句。
因爲上述程序中咱們沒有啓用服務端預編譯,所以MySQL驅動在上面的prepareStatement方法中會進入使用客戶端本地預編譯的分支進入以下所示的clientPrepareStatement方法。
上圖截自com.mysql.jdbc.ConnectionImpl#clientPrepareStatement(java.lang.String, int, int, boolean)
而咱們上面的程序中也沒有經過cachePrepStmts參數啓用緩存,所以會經過com.mysql.jdbc.JDBC42PreparedStatement的三參構造方法初始化出一個PreparedStatement對象。
上圖截自com.mysql.jdbc.PreparedStatement#getInstance(com.mysql.jdbc.MySQLConnection, java.lang.String, java.lang.String)
com.mysql.jdbc.JDBC42PreparedStatement的類繼承關係圖以下所示:
以上介紹的是默認不開啓服務預編譯及緩存的狀況。
接下來,將上述程序中的鏈接串改成jdbc:mysql://localhost/test?useServerPrepStmts=true,其他部分不做變化,清理表數據,從新執行上述程序,咱們會在MySQL日誌中看到以下信息:
2017-07-04T16:42:23.228297Z 22 Connect root@localhost on test using SSL/TLS 2017-07-04T16:42:23.233854Z 22 Query /* mysql-connector-java-5.1.42 ( Revision: 1f61b0b0270d9844b006572ba4e77f19c0f230d4 ) */SELECT @@session.auto_increment_increment AS auto_increment_increment, @@character_set_client AS character_set_client, @@character_set_connection AS character_set_connection, @@character_set_results AS character_set_results, @@character_set_server AS character_set_server, @@init_connect AS init_connect, @@interactive_timeout AS interactive_timeout, @@license AS license, @@lower_case_table_names AS lower_case_table_names, @@max_allowed_packet AS max_allowed_packet, @@net_buffer_length AS net_buffer_length, @@net_write_timeout AS net_write_timeout, @@query_cache_size AS query_cache_size, @@query_cache_type AS query_cache_type, @@sql_mode AS sql_mode, @@system_time_zone AS system_time_zone, @@time_zone AS time_zone, @@tx_isolation AS tx_isolation, @@wait_timeout AS wait_timeout 2017-07-04T16:42:23.261345Z 22 Query SET character_set_results = NULL 2017-07-04T16:42:23.262127Z 22 Query SET autocommit=1 2017-07-04T16:42:23.286449Z 22 Prepare insert into t select ?,? 2017-07-04T16:42:23.288361Z 22 Execute insert into t select 123456,'abc' 2017-07-04T16:42:23.301597Z 22 Close stmt 2017-07-04T16:42:23.302188Z 22 Quit
從上面的日誌中,咱們能夠很清楚地看到Prepare, Execute, Close幾個command,顯然MySQL服務器爲咱們預編譯了語句。
咱們僅僅經過useServerPrepStmts開啓了服務端預編譯,因爲未開啓緩存,所以prepareStatement方法會向MySQL服務器請求對語句進行預編譯。
上圖截自com.mysql.jdbc.ConnectionImpl#prepareStatement(java.lang.String, int, int)
若是咱們對代碼稍做調整,在其中再向表中作對同一個sql模板語句進行prepare->set->execute->close操做,能夠看到以下所示的日誌,因爲沒有緩存後面即便對同一個模板的sql進行預編譯,仍然會向MySQL服務器請求編譯、執行、釋放。
2017-07-05T16:04:45.801650Z 76 Connect root@localhost on test using SSL/TLS 2017-07-05T16:04:45.807448Z 76 Query /* mysql-connector-java-5.1.42 ( Revision: 1f61b0b0270d9844b006572ba4e77f19c0f230d4 ) */SELECT @@session.auto_increment_increment AS auto_increment_increment, @@character_set_client AS character_set_client, @@character_set_connection AS character_set_connection, @@character_set_results AS character_set_results, @@character_set_server AS character_set_server, @@init_connect AS init_connect, @@interactive_timeout AS interactive_timeout, @@license AS license, @@lower_case_table_names AS lower_case_table_names, @@max_allowed_packet AS max_allowed_packet, @@net_buffer_length AS net_buffer_length, @@net_write_timeout AS net_write_timeout, @@query_cache_size AS query_cache_size, @@query_cache_type AS query_cache_type, @@sql_mode AS sql_mode, @@system_time_zone AS system_time_zone, @@time_zone AS time_zone, @@tx_isolation AS tx_isolation, @@wait_timeout AS wait_timeout 2017-07-05T16:04:45.834672Z 76 Query SET character_set_results = NULL 2017-07-05T16:04:45.835183Z 76 Query SET autocommit=1 2017-07-05T16:04:45.868532Z 76 Prepare insert into t select ?,? 2017-07-05T16:04:45.869961Z 76 Execute insert into t select 1234546,'ab33c' 2017-07-05T16:04:45.891609Z 76 Close stmt 2017-07-05T16:04:45.892015Z 76 Prepare insert into t select ?,? 2017-07-05T16:04:45.892454Z 76 Execute insert into t select 6541321,'de22f' 2017-07-05T16:04:45.904014Z 76 Close stmt 2017-07-05T16:04:45.904312Z 76 Quit
在相似MyBatis等ORM框架中,每每會大量用到預編譯語句。例如MyBatis中語句的statementType默認爲PREPARED,所以一般語句查詢時都會委託connection調用prepareStatement來獲取一個java.sql.PreparedStatement對象。
上圖截自org.apache.ibatis.executor.statement.PreparedStatementHandler#instantiateStatement
若是不進行緩存,則MySQL服務端預編譯也好,本地預編譯也好,都會對同一種語句重複預編譯。所以爲了提高效率,每每咱們須要啓用緩存,經過設置鏈接中cachePrepStmts參數就能夠控制是否啓用緩存。此外經過prepStmtCacheSize參數能夠控制緩存的條數,MySQL驅動默認是25,一般實踐中都在250-500左右;經過prepStmtCacheSqlLimit能夠控制長度多大的sql能夠被緩存,MySQL驅動默認是256,一般實踐中每每設置爲2048這樣。
接下來,將測試程序中的鏈接url串改成jdbc:mysql://localhost/test?useServerPrepStmts=true&cachePrepStmts=true,並嘗試向表中插入兩條語句。
public class PreparedStatementTest { public static void main(String[] args) throws Throwable { Class.forName("com.mysql.jdbc.Driver"); String url = "jdbc:mysql://localhost/test?useServerPrepStmts=true&cachePrepStmts=true"; try (Connection con = DriverManager.getConnection(url, "root", null)) { insert(con, 123, "abc"); insert(con, 321, "def"); } } private static void insert(Connection con, int arg1, String arg2) throws SQLException { String sql = "insert into t select ?,?"; try (PreparedStatement statement = con.prepareStatement(sql)) { statement.setInt(1, arg1); statement.setString(2, arg2); statement.executeUpdate(); } } }
觀察到此時的MySQL日誌以下所示,能夠看到因爲啓用了緩存,在MySQL服務端只會預編譯一次,以後每次由驅動從本地緩存中讀取:
2017-07-05T14:11:08.967038Z 45 Query /* mysql-connector-java-5.1.42 ( Revision: 1f61b0b0270d9844b006572ba4e77f19c0f230d4 ) */SELECT @@session.auto_increment_increment AS auto_increment_increment, @@character_set_client AS character_set_client, @@character_set_connection AS character_set_connection, @@character_set_results AS character_set_results, @@character_set_server AS character_set_server, @@init_connect AS init_connect, @@interactive_timeout AS interactive_timeout, @@license AS license, @@lower_case_table_names AS lower_case_table_names, @@max_allowed_packet AS max_allowed_packet, @@net_buffer_length AS net_buffer_length, @@net_write_timeout AS net_write_timeout, @@query_cache_size AS query_cache_size, @@query_cache_type AS query_cache_type, @@sql_mode AS sql_mode, @@system_time_zone AS system_time_zone, @@time_zone AS time_zone, @@tx_isolation AS tx_isolation, @@wait_timeout AS wait_timeout 2017-07-05T14:11:09.014069Z 45 Query SET character_set_results = NULL 2017-07-05T14:11:09.016009Z 45 Query SET autocommit=1 2017-07-05T14:11:09.060693Z 45 Prepare insert into t select ?,? 2017-07-05T14:11:09.061870Z 45 Execute insert into t select 123,'abc' 2017-07-05T14:11:09.086018Z 45 Execute insert into t select 321,'def' 2017-07-05T14:11:09.107963Z 45 Quit
MySQL驅動裏對於server預編譯的狀況維護了兩個基於LinkedHashMap使用LRU策略的cache,分別是serverSideStatementCheckCache用於緩存sql語句是否能夠由服務端來緩存以及serverSideStatementCache用於緩存服務端預編譯sql語句,這兩個緩存的大小由prepStmtCacheSize參數控制。
接下來,咱們來看一下MySQL驅動是如何經過這樣的緩存來實現預編譯結果複用的。
上圖截自com.mysql.jdbc.ConnectionImpl#prepareStatement(java.lang.String, int, int)
如上圖所示,在啓用服務端緩存的狀況下,MySQL驅動會嘗試從LRU緩存中讀取預編譯sql,若是命中緩存的話,則會置Statement對象的close狀態爲false,複用此對象;
而若是未命中緩存的話,則會根據sql長度是否小於prepStmtCacheSqlLimit參數的值來爲設置是否須要緩存,能夠理解爲是打個緩存標記,並延遲到語句close時進行緩存。
而在Statement對象執行close方法時,MySQL驅動中的ServerPreparedStatement會根據isCached標記、是否可池化、是否已經關閉等來判斷是否要把預編譯語句放到緩存中以複用。
上圖截自com.mysql.jdbc.ServerPreparedStatement#close
在鏈接初始化時,若是啓用了useServerPrepStmts,則serverSideStatementCheckCache和serverSideStatementCache這兩個LRU緩存也將隨之初始化。
上圖截自com.mysql.jdbc.ConnectionImpl#createPreparedStatementCaches
其中serverSideStatementCache對於被待移除元素有更進一步的處理:對於被緩存淘汰的預編譯語句,給它緩存標記置爲false,而且調用其close方法。
接下來看看客戶端本地預編譯而且使用緩存的狀況。
MySQL驅動源碼中使用cachedPreparedStatementParams來緩存sql語句的ParseInfo,ParseInfo是com.mysql.jdbc.PreparedStatement的一個內部類,用於存儲預編譯語句的一些結構和狀態基本信息。cachedPreparedStatementParams的類型是com.mysql.jdbc.CacheAdapter,這是MySQL驅動源碼中的一個緩存適配器接口,在鏈接初始化的時候會經過parseInfoCacheFactory來初始化一個做用域爲sql鏈接的緩存類(com.mysql.jdbc.PerConnectionLRUFactory)出來,其實就是對LRUCache和sql鏈接的一個封裝組合。
上圖截自com.mysql.jdbc.ConnectionImpl#clientPrepareStatement(java.lang.String, int, int, boolean)
在緩存未命中的狀況下,驅動會本地prepare出來一個預編譯語句,而且將parseInfo放入緩存中;而緩存命中的話,則會把緩存中的parseInfo帶到四參構造方法中構造初始化。
這裏能夠作一個簡易的性能測試。
首先寫個存儲過程向表中初始化大約50萬條數據,而後使用同一個鏈接作select查詢(查詢條件走索引)。
CREATE PROCEDURE init(cnt INT) BEGIN DECLARE i INT DEFAULT 1; TRUNCATE t; INSERT INTO t SELECT 1, 'stmt 1'; WHILE i <= cnt DO BEGIN INSERT INTO t SELECT a+i, concat('stmt ',a+i) FROM t; SET i = i << 1; END; END WHILE; END;
mysql> call init(1<<18); Query OK, 262144 rows affected (3.60 sec) mysql> select count(0) from t; +----------+ | count(0) | +----------+ | 524288 | +----------+ 1 row in set (0.14 sec)
public static void main(String[] args) throws Throwable { Class.forName("com.mysql.jdbc.Driver"); String url = ""; long start = System.currentTimeMillis(); try (Connection con = DriverManager.getConnection(url, "root", null)) { for (int i = 1; i <= (1<<19); i++) { query(con, i, "stmt " + i); } } long end = System.currentTimeMillis(); System.out.println(end - start); } private static void query(Connection con, int arg1, String arg2) throws SQLException { String sql = "select a,b from t where a=? and b=?"; try (PreparedStatement statement = con.prepareStatement(sql)) { statement.setInt(1, arg1); statement.setString(2, arg2); statement.executeQuery(); } }
如下幾種狀況,通過3測試取平均值,狀況以下:
從中咱們能夠看出本地預編譯加不加緩存其實差異不是太大,服務端預編譯不加緩存性能明顯會下降不少,可是服務端預編譯加緩存的話性能仍是會比本地好不少。
主要緣由是服務端預編譯不加緩存的話自己prepare也是有開銷的,另外多了大量的round-trip。
本文重點介紹了預編譯語句的概念及其在MySQL中的使用,並以介紹了預編譯語句在MySQL驅動源碼中的一些實現細節。
在實際生產環境中,如MyBatis等ORM框架大量使用了預編譯語句,最終底層調用都會走到MySQL驅動裏,從驅動中瞭解相關實現細節有助於更好地理解預編譯語句。
一些網上的文章稱必須使用useServerPrepStmts才能開啓預編譯,這種說法是錯誤的。實際上JDBC規範裏沒有說過預編譯語句這件事情由本地來作仍是服務端來作。MySQL早期版本中因爲不支持服務端預編譯,所以當時主要是經過本地預編譯。
通過實際測試,對於頻繁使用的語句,使用服務端預編譯+緩存效率仍是可以獲得可觀的提高的。可是對於不頻繁使用的語句,服務端預編譯自己會增長額外的round-trip,所以在實際開發中能夠視狀況定奪使用本地預編譯仍是服務端預編譯以及哪些sql語句不須要開啓預編譯等。
MySQL官方手冊預編譯語句
mysql-5-prepared-statement-syntax
MySQL Connector/J源碼