奇妙的 JDBC batch insert 到 Doris 異常的問題

遇到一個很奇怪的異常,通過 JDBC batch insert 時,會報 Unknown command(27) 的異常袍啡。

exception.png

而且這個問題很容易復現(xiàn),復現(xiàn)例子:

  1. 建表語句
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"
);
  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)只是簡單的返回了 ParseInfocanRewriteAsMultiValueInsert 屬性咳蔚,默認值為 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 行谈火,由于buildRewriteInfotrue,進入到 if 語句中

canRewriteAsMultiValueInsert 為 3 個判斷條件的的與值, 其中:

  1. this.numberOfQueries == 1 判斷是不是只有一個語句舌涨,這個當然為 true

  2. !this.parametersInDuplicateKeyClause 判斷參數(shù)不在 ON DUPLICATE KEY 的語法中糯耍,這個也為 true

  3. 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 SELECTINSERT 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 行,判斷是不是 INSERTREPLACE 語句,并且開啟了 rewriteBatchedStatements 屬性挂据,

然后進行了一系列復雜的匹配以清,來判斷是否為 INSERT INTO VALUES 這樣的語法,并且?guī)в?? 占位符

最后在 271 行把判斷結(jié)果賦值給 isRewritableWithMultiValuesClause

總結(jié)

所以崎逃,這個異常的問題就在于掷倔,判斷是否可以重寫 batch statement sql 的時候,對于 INSERT INTO VALUES 語法的判斷上个绍,8.0.28 以及更早的版本的邏輯不夠嚴謹勒葱,8.0.29 上面進行了更加復雜的判斷赏迟,從而避免了這樣的錯誤

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末轨域,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子偿短,更是在濱河造成了極大的恐慌广恢,老刑警劉巖凯旋,帶你破解...
    沈念sama閱讀 219,427評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異钉迷,居然都是意外死亡至非,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,551評論 3 395
  • 文/潘曉璐 我一進店門糠聪,熙熙樓的掌柜王于貴愁眉苦臉地迎上來荒椭,“玉大人,你說我怎么就攤上這事舰蟆∪せ荩” “怎么了?”我有些...
    開封第一講書人閱讀 165,747評論 0 356
  • 文/不壞的土叔 我叫張陵身害,是天一觀的道長味悄。 經(jīng)常有香客問我,道長题造,這世上最難降的妖魔是什么傍菇? 我笑而不...
    開封第一講書人閱讀 58,939評論 1 295
  • 正文 為了忘掉前任猾瘸,我火速辦了婚禮界赔,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘牵触。我一直安慰自己淮悼,他們只是感情好,可當我...
    茶點故事閱讀 67,955評論 6 392
  • 文/花漫 我一把揭開白布揽思。 她就那樣靜靜地躺著袜腥,像睡著了一般。 火紅的嫁衣襯著肌膚如雪钉汗。 梳的紋絲不亂的頭發(fā)上羹令,一...
    開封第一講書人閱讀 51,737評論 1 305
  • 那天鲤屡,我揣著相機與錄音,去河邊找鬼福侈。 笑死酒来,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的肪凛。 我是一名探鬼主播堰汉,決...
    沈念sama閱讀 40,448評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼伟墙!你這毒婦竟也來了翘鸭?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,352評論 0 276
  • 序言:老撾萬榮一對情侶失蹤戳葵,失蹤者是張志新(化名)和其女友劉穎就乓,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體拱烁,經(jīng)...
    沈念sama閱讀 45,834評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡档址,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,992評論 3 338
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了邻梆。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片守伸。...
    茶點故事閱讀 40,133評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖浦妄,靈堂內(nèi)的尸體忽然破棺而出尼摹,到底是詐尸還是另有隱情,我是刑警寧澤剂娄,帶...
    沈念sama閱讀 35,815評論 5 346
  • 正文 年R本政府宣布蠢涝,位于F島的核電站,受9級特大地震影響阅懦,放射性物質(zhì)發(fā)生泄漏和二。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,477評論 3 331
  • 文/蒙蒙 一耳胎、第九天 我趴在偏房一處隱蔽的房頂上張望惯吕。 院中可真熱鬧,春花似錦怕午、人聲如沸废登。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,022評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽堡距。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間羽戒,已是汗流浹背缤沦。 一陣腳步聲響...
    開封第一講書人閱讀 33,147評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留易稠,地道東北人疚俱。 一個月前我還...
    沈念sama閱讀 48,398評論 3 373
  • 正文 我出身青樓,卻偏偏與公主長得像缩多,于是被迫代替她去往敵國和親呆奕。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,077評論 2 355

推薦閱讀更多精彩內(nèi)容

  • 1.Failed to execute goal org.apache.maven.plugins:maven-c...
    WY長河閱讀 1,837評論 0 1
  • 預講內(nèi)容 1衬吆、如何利用Spring進行攔截對象的注入梁钾; 2、利用jdbcTemplate實現(xiàn)數(shù)據(jù)表的CRUD操作逊抡;...
    Scalelength閱讀 313評論 0 0
  • 第6章: 數(shù)據(jù)庫事務(wù) 6.1 數(shù)據(jù)庫事務(wù)介紹 事務(wù):一組邏輯操作單元,使數(shù)據(jù)從一種狀態(tài)變換到另一種狀態(tài)姆泻。 事務(wù)處理...
    是小豬童鞋啦閱讀 595評論 0 0
  • 1. Java基礎(chǔ)部分 基礎(chǔ)部分的順序:基本語法,類相關(guān)的語法冒嫡,內(nèi)部類的語法拇勃,繼承相關(guān)的語法,異常的語法孝凌,線程的語...
    子非魚_t_閱讀 31,644評論 18 399
  • JVM 說一下 jvm 的主要組成部分方咆?及其作用? JVM包括類加載子系統(tǒng)蟀架、堆瓣赂、方法區(qū)、棧片拍、本地方法棧煌集、程序計數(shù)器...
    文刀雨木同閱讀 430評論 0 1