1. 背景
本文重點講述MySQL中的預編譯語句並從MySQL的Connector/J源碼出發講述其在Java語言中相關使用。
注意:文中的描述與結論基於MySQL 5.7.16以及Connect/J 5.1.42版本。
2. 預編譯語句是什么
通常我們的一條sql在db接收到最終執行完畢返回可以分為下面三個過程:
- 詞法和語義解析
- 優化sql語句,制定執行計划
- 執行並返回結果
我們把這種普通語句稱作Immediate Statements。
但是很多情況,我們的一條sql語句可能會反復執行,或者每次執行的時候只有個別的值不同(比如query的where子句值不同,update的set子句值不同,insert的values值不同)。
如果每次都需要經過上面的詞法語義解析、語句優化、制定執行計划等,則效率就明顯不行了。
所謂預編譯語句就是將這類語句中的值用占位符替代,可以視為將sql語句模板化或者說參數化,一般稱這類語句叫Prepared Statements或者Parameterized Statements
預編譯語句的優勢在於歸納為:一次編譯、多次運行,省去了解析優化等過程;此外預編譯語句能防止sql注入。
當然就優化來說,很多時候最優的執行計划不是光靠知道sql語句的模板就能決定了,往往就是需要通過具體值來預估出成本代價。
3. MySQL的預編譯功能
注意MySQL的老版本(4.1之前)是不支持服務端預編譯的,但基於目前業界生產環境普遍情況,基本可以認為MySQL支持服務端預編譯。
下面我們來看一下MySQL中預編譯語句的使用。
首先我們有一張測試表t,結構如下所示:
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)
3.1 編譯
我們接下來通過 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
3.2 執行
我們通過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)
可以看到,數據已經被成功插入表中。
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會報如上所示的錯誤。
3.3 釋放
如果我們想要釋放一條預編譯語句,則可以使用{DEALLOCATE | DROP} PREPARE stmt_name
的語法進行操作:
mysql> deallocate prepare ins;
Query OK, 0 rows affected (0.00 sec)
4. 通過MySQL驅動進行預編譯
以上介紹了直接在MySQL上通過sql命令進行預編譯/緩存sql語句。接下來我們以MySQL Java驅動Connector/J(版本5.1.42)為例來介紹通過MySQL驅動進行預編譯。
4.1 客戶端預編譯
首先,簡要提一下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的類繼承關系圖如下所示:
以上介紹的是默認不開啟服務預編譯及緩存的情況。
4.2 通過服務端預編譯的情況
接下來,將上述程序中的連接串改為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
4.3 使用緩存的情況
在類似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這樣。
4.3.1 服務端預編譯+緩存
接下來,將測試程序中的連接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方法。
4.3.2 客戶端預編譯+緩存
接下來看看客戶端本地預編譯並且使用緩存的情況。
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帶到四參構造方法中構造初始化。
5. 性能測試
這里可以做一個簡易的性能測試。
首先寫個存儲過程向表中初始化大約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測試取平均值,情況如下:
- 本地預編譯:65769 ms
- 本地預編譯+緩存:63637 ms
- 服務端預編譯:100985 ms
- 服務端預編譯+緩存:57299 ms
從中我們可以看出本地預編譯加不加緩存其實差別不是太大,服務端預編譯不加緩存性能明顯會降低很多,但是服務端預編譯加緩存的話性能還是會比本地好很多。
主要原因是服務端預編譯不加緩存的話本身prepare也是有開銷的,另外多了大量的round-trip。
6. 總結
本文重點介紹了預編譯語句的概念及其在MySQL中的使用,並以介紹了預編譯語句在MySQL驅動源碼中的一些實現細節。
在實際生產環境中,如MyBatis等ORM框架大量使用了預編譯語句,最終底層調用都會走到MySQL驅動里,從驅動中了解相關實現細節有助於更好地理解預編譯語句。
一些網上的文章稱必須使用useServerPrepStmts才能開啟預編譯,這種說法是錯誤的。實際上JDBC規范里沒有說過預編譯語句這件事情由本地來做還是服務端來做。MySQL早期版本中由於不支持服務端預編譯,因此當時主要是通過本地預編譯。
經過實際測試,對於頻繁使用的語句,使用服務端預編譯+緩存效率還是能夠得到可觀的提升的。但是對於不頻繁使用的語句,服務端預編譯本身會增加額外的round-trip,因此在實際開發中可以視情況定奪使用本地預編譯還是服務端預編譯以及哪些sql語句不需要開啟預編譯等。
7. 參考
MySQL官方手冊預編譯語句
mysql-5-prepared-statement-syntax
MySQL Connector/J源碼