遇到一个很奇怪的异常,通过 JDBC batch insert 时,会报 Unknown command(27)
的异常。
而且这个问题很容易复现,复现例子:
- 建表语句
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 源码发现,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 的命令报文定义,发现 27(16 进制的 0x1B)为 COM_SET_OPTION
指令,用于打开或关闭多语句执行,具体定义如下:
只有两个枚举值
- MYSQL_OPTION_MULTI_STATEMENTS_ON - 1
- MYSQL_OPTION_MULTI_STATEMENTS_OFF - 0
这个时候就感觉可能是 mysql-connector-java 的问题,又试了几个版本的 connector 包,最终发现在 8.0.28 以及之前的版本会有这个问题,然后就开始了我们的 debug 之旅。
问题分析
版本众多,我们就只来看看问题分界的两个版本:8.0.28 和 8.0.29
8.0.28
首先我们通过测试代码中的 ps.executeBatch()
方法进入
调用的是实现类方法 com.mysql.cj.jdbc.StatementImpl#executeBatch
public int[] executeBatch() throws SQLException {return Util.truncateAndConvertToInt(executeBatchInternal());
}
进来直接调用 executeBatchInternal
方法,实际是调用的实现类方法 com.mysql.cj.jdbc.ClientPreparedStatement#executeBatchInternal
protected long[] executeBatchInternal() throws SQLException {synchronized (checkClosed().getConnectionMutex()) {......try {......if (!this.batchHasPlainStatements && this.rewriteBatchedStatements.getValue()) {// line 425if (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,继续向下执行(这个地方很重要,我们后面分析)
然后会最终走到 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;}}......}
}
然后我们继续执行,直到执行 494 行的 enableMultiQueries
异常发生
然后我们再次 debug 进入到这个方法,看看它干了什么
public void enableMultiQueries() {sendCommand(this.commandBuilder.buildComSetOption(((NativeProtocol) this.protocol).getSharedSendPacket(), 0), false, 0);((NativeServerSession) getServerSession()).preserveOldTransactionState();
}
诶,就是它了,buildComSetOption
,顾名思义,构建了 COM_SET_OPTION
命令并发送。
找到了罪魁祸首,我们就来看看为什么他要发送这个指令
回到 425 行的这个判断,为什么明明我们在 JDBC url 上面开启了 rewriteBatchedStatements
,但是却没有执行呢?
我们点开 canRewriteAsMultiValueInsertAtSqlLevel
方法,发现只是简单的返回了 ParseInfo
的 canRewriteAsMultiValueInsert
属性,默认值为 false
, 那我们看看这个属性有没有被赋值
public boolean canRewriteAsMultiValueInsertAtSqlLevel() {return this.canRewriteAsMultiValueInsert;
}
经过 Debug 发现,在ClientPreparedStatement
初始化时,调用 com.mysql.cj.ParseInfo#ParseInfo(java.lang.String, com.mysql.cj.Session, java.lang.String)
构造方法后再调用com.mysql.cj.ParseInfo#ParseInfo(java.lang.String, com.mysql.cj.Session, java.lang.String, boolean)
这个构造方法构建 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 185if (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);}}
}
然后我们继续向下看
在 185 行,由于buildRewriteInfo
为 true
,进入到 if 语句中
canRewriteAsMultiValueInsert
为 3 个判断条件的的与值, 其中:
-
this.numberOfQueries == 1
判断是不是只有一个语句,这个当然为true
-
!this.parametersInDuplicateKeyClause
判断参数不在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 660if (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 两个参数,不匹配两个标记所引用的内容,这也就是为什么表名通过重音标号引用后没有出现异常
出现问题的地方找到了,我们再来看执行正常的版本是如何处理的
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 408if (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
,调用 executeBatchedInserts
执行批量写入,正常结束返回
这个地方和 8.0.28 版本明显不一样了,我们来看看 isRewritableWithMultiValuesClause
是如何判断的
这块也是相似的直接返回了 isRewritableWithMultiValuesClause
成员变量的值
public boolean isRewritableWithMultiValuesClause() {return this.isRewritableWithMultiValuesClause;
}
debug 看到在构建 ClientPreparedStatement
时, 通过 com.mysql.cj.QueryInfo#QueryInfo(java.lang.String, com.mysql.cj.Session, java.lang.String)
构造方法构造 QueryInfo
对象时进行了赋值
public QueryInfo(String sql, Session session, String encoding) {......// line 97boolean 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 127boolean 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 271this.isRewritableWithMultiValuesClause = rewritableAsMultiValues;......
}
首先在 97 行,获取到 session 中设置的 rewriteBatchedStatements
属性
在 127 行,判断是不是 INSERT
或 REPLACE
语句,并且开启了 rewriteBatchedStatements
属性,
然后进行了一系列复杂的匹配,来判断是否为 INSERT INTO VALUES
这样的语法,并且带有 ? 占位符
最后在 271 行把判断结果赋值给 isRewritableWithMultiValuesClause
总结
所以,这个异常的问题就在于,判断是否可以重写 batch statement sql 的时候,对于 INSERT INTO VALUES
语法的判断上,8.0.28 以及更早的版本的逻辑不够严谨,8.0.29 上面进行了更加复杂的判断,从而避免了这样的错误