遇到一個很奇怪的異常,通過 JDBC batch insert 時,會報 Unknown command(27)
的異常袍啡。
而且這個問題很容易復現(xiàn),復現(xiàn)例子:
- 建表語句
create table t_selection_test (
a VARCHAR(96),
b VARCHAR(20),
c VARCHAR(96),
d DECIMAL(38, 10)
) DUPLICATE KEY (a)
DISTRIBUTED BY HASH(a) BUCKETS 10
PROPERTIES (
"replication_num" = "1"
);
- 寫入代碼
Connection conn = DriverManager.getConnection(
"jdbc:mysql://127.0.0.1:9030/test?rewriteBatchedStatements=true",
"root", "");
String sql = "INSERT INTO t_select_test (a, b, c, d) VALUES(?, ?, ?, ?)";
PreparedStatement ps = conn.prepareStatement(sql);
for (int i = 0; i < 4; i++) {
ps.setString(1, "a");
ps.setString(2, "b");
ps.setString(3, "c");
ps.setBigDecimal(4, BigDecimal.TEN);
ps.addBatch();
}
ps.executeBatch();
最開始懷疑是 Doris 的問題却桶,查看 Fe 源碼發(fā)現(xiàn)境输,Mysql 解析的代碼在 org.apache.doris.qe.MysqlConnectProcessor#dispatch
方法
private void dispatch() throws IOException {
int code = packetBuf.get();
MysqlCommand command = MysqlCommand.fromCode(code);
if (command == null) {
ErrorReport.report(ErrorCode.ERR_UNKNOWN_COM_ERROR);
ctx.getState().setError(ErrorCode.ERR_UNKNOWN_COM_ERROR, "Unknown command(" + code + ")");
LOG.warn("Unknown command(" + code + ")");
return;
}
......
}
其中 MysqlCommand
中確實沒有 code 為 27 的命令定義,查看 MySQL 的命令報文定義颖系,發(fā)現(xiàn) 27(16 進制的 0x1B)為 COM_SET_OPTION
指令嗅剖,用于打開或關(guān)閉多語句執(zhí)行,具體定義如下:
只有兩個枚舉值
- MYSQL_OPTION_MULTI_STATEMENTS_ON - 1
- MYSQL_OPTION_MULTI_STATEMENTS_OFF - 0
這個時候就感覺可能是 mysql-connector-java 的問題嘁扼,又試了幾個版本的 connector 包信粮,最終發(fā)現(xiàn)在 8.0.28 以及之前的版本會有這個問題,然后就開始了我們的 debug 之旅趁啸。
問題分析
版本眾多强缘,我們就只來看看問題分界的兩個版本:8.0.28 和 8.0.29
8.0.28
首先我們通過測試代碼中的 ps.executeBatch()
方法進入
調(diào)用的是實現(xiàn)類方法 com.mysql.cj.jdbc.StatementImpl#executeBatch
public int[] executeBatch() throws SQLException {
return Util.truncateAndConvertToInt(executeBatchInternal());
}
進來直接調(diào)用 executeBatchInternal
方法督惰,實際是調(diào)用的實現(xiàn)類方法 com.mysql.cj.jdbc.ClientPreparedStatement#executeBatchInternal
protected long[] executeBatchInternal() throws SQLException {
synchronized (checkClosed().getConnectionMutex()) {
......
try {
......
if (!this.batchHasPlainStatements && this.rewriteBatchedStatements.getValue()) {
// line 425
if (getParseInfo().canRewriteAsMultiValueInsertAtSqlLevel()) {
return executeBatchedInserts(batchTimeout);
}
if (!this.batchHasPlainStatements && this.query.getBatchedArgs() != null
&& this.query.getBatchedArgs().size() > 3 /* cost of option setting rt-wise */) {
// line 431
return executePreparedBatchAsMultiStatement(batchTimeout);
}
}
return executeBatchSerially(batchTimeout);
} finally {
this.query.getStatementExecuting().set(false);
clearBatch();
}
}
}
在 425 行 canRewriteAsMultiValueInsertAtSqlLevel
判斷為 false,繼續(xù)向下執(zhí)行(這個地方很重要旅掂,我們后面分析)
然后會最終走到 431 行的 executePreparedBatchAsMultiStatement
方法中
protected long[] executePreparedBatchAsMultiStatement(int batchTimeout) throws SQLException {
synchronized (checkClosed().getConnectionMutex()) {
......
try {
......
try {
if (!multiQueriesEnabled) {
// line 494
((NativeSession) locallyScopedConn.getSession()).enableMultiQueries();
}
......
} finally {
if (batchedStatement != null) {
batchedStatement.close();
batchedStatement = null;
}
}
......
}
}
然后我們繼續(xù)執(zhí)行赏胚,直到執(zhí)行 494 行的 enableMultiQueries
異常發(fā)生
然后我們再次 debug 進入到這個方法,看看它干了什么
public void enableMultiQueries() {
sendCommand(this.commandBuilder.buildComSetOption(((NativeProtocol) this.protocol).getSharedSendPacket(), 0), false, 0);
((NativeServerSession) getServerSession()).preserveOldTransactionState();
}
誒商虐,就是它了觉阅,buildComSetOption
,顧名思義称龙,構(gòu)建了 COM_SET_OPTION
命令并發(fā)送留拾。
找到了罪魁禍首,我們就來看看為什么他要發(fā)送這個指令
回到 425 行的這個判斷鲫尊,為什么明明我們在 JDBC url 上面開啟了 rewriteBatchedStatements
痴柔,但是卻沒有執(zhí)行呢?
我們點開 canRewriteAsMultiValueInsertAtSqlLevel
方法疫向,發(fā)現(xiàn)只是簡單的返回了 ParseInfo
的 canRewriteAsMultiValueInsert
屬性咳蔚,默認值為 false
, 那我們看看這個屬性有沒有被賦值
public boolean canRewriteAsMultiValueInsertAtSqlLevel() {
return this.canRewriteAsMultiValueInsert;
}
經(jīng)過 Debug 發(fā)現(xiàn),在ClientPreparedStatement
初始化時搔驼,調(diào)用 com.mysql.cj.ParseInfo#ParseInfo(java.lang.String, com.mysql.cj.Session, java.lang.String)
構(gòu)造方法后再調(diào)用com.mysql.cj.ParseInfo#ParseInfo(java.lang.String, com.mysql.cj.Session, java.lang.String, boolean)
這個構(gòu)造方法構(gòu)建 ParseInfo
對象
public ParseInfo(String sql, Session session, String encoding) {
this(sql, session, encoding, true);
}
public ParseInfo(String sql, Session session, String encoding, boolean buildRewriteInfo) {
......
// line 185
if (buildRewriteInfo) {
this.canRewriteAsMultiValueInsert = this.numberOfQueries == 1 && !this.parametersInDuplicateKeyClause
&& canRewrite(sql, this.isOnDuplicateKeyUpdate, this.locationOfOnDuplicateKeyUpdate, this.statementStartPos);
if (this.canRewriteAsMultiValueInsert && session.getPropertySet().getBooleanProperty(PropertyKey.rewriteBatchedStatements).getValue()) {
buildRewriteBatchedParams(sql, session, encoding);
}
}
}
然后我們繼續(xù)向下看
在 185 行谈火,由于buildRewriteInfo
為 true
,進入到 if 語句中
canRewriteAsMultiValueInsert
為 3 個判斷條件的的與值, 其中:
this.numberOfQueries == 1
判斷是不是只有一個語句舌涨,這個當然為true
!this.parametersInDuplicateKeyClause
判斷參數(shù)不在ON DUPLICATE KEY
的語法中糯耍,這個也為true
-
canRewrite
這個方法我們跟進去看protected static boolean canRewrite(String sql, boolean isOnDuplicateKeyUpdate, int locationOfOnDuplicateKeyUpdate, int statementStartPos) { // Needs to be INSERT or REPLACE. // Can't have INSERT ... SELECT or INSERT ... ON DUPLICATE KEY UPDATE with an id=LAST_INSERT_ID(...). if (StringUtils.startsWithIgnoreCaseAndWs(sql, "INSERT", statementStartPos)) { // line 660 if (StringUtils.indexOfIgnoreCase(statementStartPos, sql, "SELECT", OPENING_MARKERS, CLOSING_MARKERS, SearchMode.__MRK_COM_MYM_HNT_WS) != -1) { return false; } if (isOnDuplicateKeyUpdate) { int updateClausePos = StringUtils.indexOfIgnoreCase(locationOfOnDuplicateKeyUpdate, sql, " UPDATE "); if (updateClausePos != -1) { return StringUtils.indexOfIgnoreCase(updateClausePos, sql, "LAST_INSERT_ID", OPENING_MARKERS, CLOSING_MARKERS, SearchMode.__MRK_COM_MYM_HNT_WS) == -1; } } return true; } return StringUtils.startsWithIgnoreCaseAndWs(sql, "REPLACE", statementStartPos) && StringUtils.indexOfIgnoreCase(statementStartPos, sql, "SELECT", OPENING_MARKERS, CLOSING_MARKERS, SearchMode.__MRK_COM_MYM_HNT_WS) == -1; }
有注釋提示,只有 INSERT 或 REPLACE 語句才能被重寫囊嘉,而 INSERT SELECT
和 INSERT ON DUPLICATE KEY UPDATE
語法不能被重寫
這個 SELECT
是不是有點似曾相識? 我們接著往下走
直到走到 660 行這個判斷温技,然后返回了 false
恍然大悟,這個地方判斷 sql 是不是 INSERT SELECT
語句扭粱,因為我們表名中包含 select 字串舵鳞,所以這個 StringUtils.indexOfIgnoreCase
方法返回值不是 -1
而 SearchMode.__MRK_COM_MYM_HNT_WS
配合第 4、5 兩個參數(shù)琢蛤,不匹配兩個標記所引用的內(nèi)容蜓堕,這也就是為什么表名通過重音標號引用后沒有出現(xiàn)異常
出現(xiàn)問題的地方找到了,我們再來看執(zhí)行正常的版本是如何處理的
8.0.29
還是通過測試代碼中的 ps.executeBatch()
方法進入
一樣走到com.mysql.cj.jdbc.ClientPreparedStatement#executeBatchInternal
protected long[] executeBatchInternal() throws SQLException {
synchronized (checkClosed().getConnectionMutex()) {
......
try {
......
if (!this.batchHasPlainStatements && this.rewriteBatchedStatements.getValue()) {
// line 408
if (getQueryInfo().isRewritableWithMultiValuesClause()) {
return executeBatchedInserts(batchTimeout);
}
if (!this.batchHasPlainStatements && this.query.getBatchedArgs() != null
&& this.query.getBatchedArgs().size() > 3 /* cost of option setting rt-wise */) {
return executePreparedBatchAsMultiStatement(batchTimeout);
}
}
return executeBatchSerially(batchTimeout);
} finally {
this.query.getStatementExecuting().set(false);
clearBatch();
}
}
}
走到 408 行博其,isRewritableWithMultiValuesClause
判斷為 true
套才,調(diào)用 executeBatchedInserts
執(zhí)行批量寫入,正常結(jié)束返回
這個地方和 8.0.28 版本明顯不一樣了慕淡,我們來看看 isRewritableWithMultiValuesClause
是如何判斷的
這塊也是相似的直接返回了 isRewritableWithMultiValuesClause
成員變量的值
public boolean isRewritableWithMultiValuesClause() {
return this.isRewritableWithMultiValuesClause;
}
debug 看到在構(gòu)建 ClientPreparedStatement
時, 通過 com.mysql.cj.QueryInfo#QueryInfo(java.lang.String, com.mysql.cj.Session, java.lang.String)
構(gòu)造方法構(gòu)造 QueryInfo
對象時進行了賦值
public QueryInfo(String sql, Session session, String encoding) {
......
// line 97
boolean rewriteBatchedStatements = session.getPropertySet().getBooleanProperty(PropertyKey.rewriteBatchedStatements).getValue();
......
// Skip comments at the beginning of queries.
this.queryStartPos = strInspector.indexOfNextAlphanumericChar();
if (this.queryStartPos == -1) {
this.queryStartPos = this.queryLength;
} else {
this.numberOfQueries = 1;
this.statementFirstChar = Character.toUpperCase(strInspector.getChar());
}
// Only INSERT and REPLACE statements support multi-values clause rewriting.
// line 127
boolean isInsert = strInspector.matchesIgnoreCase(INSERT_STATEMENT) != -1;
if (isInsert) {
strInspector.incrementPosition(INSERT_STATEMENT.length()); // Advance to the end of "INSERT".
}
boolean isReplace = !isInsert && strInspector.matchesIgnoreCase(REPLACE_STATEMENT) != -1;
if (isReplace) {
strInspector.incrementPosition(REPLACE_STATEMENT.length()); // Advance to the end of "REPLACE".
}
// Check if the statement has potential to be rewritten as a multi-values clause statement, i.e., if it is an INSERT or REPLACE statement and
// 'rewriteBatchedStatements' is enabled.
boolean rewritableAsMultiValues = (isInsert || isReplace) && rewriteBatchedStatements;
......
// complex grammar check
// line 271
this.isRewritableWithMultiValuesClause = rewritableAsMultiValues;
......
}
首先在 97 行背伴,獲取到 session 中設(shè)置的 rewriteBatchedStatements
屬性
在 127 行,判斷是不是 INSERT
或 REPLACE
語句,并且開啟了 rewriteBatchedStatements
屬性挂据,
然后進行了一系列復雜的匹配以清,來判斷是否為 INSERT INTO VALUES
這樣的語法,并且?guī)в?? 占位符
最后在 271 行把判斷結(jié)果賦值給 isRewritableWithMultiValuesClause
總結(jié)
所以崎逃,這個異常的問題就在于掷倔,判斷是否可以重寫 batch statement sql 的時候,對于 INSERT INTO VALUES
語法的判斷上个绍,8.0.28 以及更早的版本的邏輯不夠嚴謹勒葱,8.0.29 上面進行了更加復雜的判斷赏迟,從而避免了這樣的錯誤