Mycat處理prepare binary協(xié)議的bug

今天線上使用Mycat的業(yè)務(wù)突然反饋通過golang MySQL客戶端執(zhí)行SQL報(bào)異常:

Error 3344: StringIndexOutOfBoundsException: String index out of range: 237

查看Mycat日志也只有異常堆棧信息, 沒有打出錯(cuò)誤SQL和參數(shù). 業(yè)務(wù)方很快定位到問題: 通過prepare binary協(xié)議執(zhí)行一條INSERT語句時(shí), 某個(gè)VARCHAR字段傳入的數(shù)據(jù)長度超過600K而報(bào)出的錯(cuò)誤. 通過源碼調(diào)試找到了問題原因, 是mycat的一個(gè)bug, 該bug在1.6分支依然存在. 本文會(huì)詳細(xì)分析該問題.

背景

業(yè)務(wù)方使用的是golang的github.com/go-sql-driver/mysql這個(gè)官方mysql客戶端訪問的Mycat代理服務(wù)器, 涉及到的數(shù)據(jù)表結(jié)構(gòu)如下 (結(jié)構(gòu)類似, 字段脫敏):

CREATE TABLE `tbl_test` (
    `id` bigint(20),
    `name` varchar(32),
    `password` varchar(32),
    `group` varchar(32),
    `data` longtext,
    `create_time` int(11),
    PRIMARY KEY (`id`)
) ENGINE=InnoDB

采用prepare binary協(xié)議執(zhí)行INSERT SQL語句:

package main

import (
   "database/sql"
   "fmt"

   _ "github.com/go-sql-driver/mysql"
)

var data="abcdefg"

func main() {
   db, err := sql.Open("mysql", "test:test@tcp(127.0.0.1:8066)/test?charset=utf8")
   if err != nil {
      fmt.Printf("open error: %v\n", err)
      return
   }
   defer db.Close()
   sql := "INSERT INTO tbl_test (id,name,password,group,data,create_time) VALUES (?,?,?,?,?,?)"
   stmt, err := db.Prepare(sql)
   if err != nil {
      fmt.Printf("prepare error: %v\n", err)
      return
   }
   defer stmt.Close()
   args := []interface{}{1,"doggy","catty","animal",data,0}
   result, err := stmt.Exec(args...)
   if err != nil {
      fmt.Printf("execute error: %v\n", err)
      return
   }
   rowAffected, err := result.RowsAffected()
   if err != nil {
      fmt.Printf("get rowAffected error: %v\n", err)
      return
   }
   fmt.Printf("rowAffected: %d\n", rowAffected)
}

mycat啟動(dòng)端口為8066, 直連后端mysql, tbl_test沒有配置分表. 當(dāng)data變量長度超過600K時(shí), 執(zhí)行這段代碼就會(huì)報(bào)Error 3344: StringIndexOutOfBoundsException.

MySQL prepare binary協(xié)議

由于采用了prepare binary協(xié)議執(zhí)行SQL, 我們先來分析一下prepare binary執(zhí)行流程. 具體內(nèi)容可參考MySQL Internal Manual

prepare binary協(xié)議包含5種命令:

  • COM_STMT_PREPARE
  • COM_STMT_EXECUTE
  • COM_STMT_CLOSE
  • COM_STMT_RESET
  • COM_STMT_SEND_LONG_DATA

MySQL處理一個(gè)prepare binary請求的流程如下:

  • 客戶端向MySQL發(fā)送COM_STMT_PREPARE命令發(fā)送SQL語句, 從而在MySQL中創(chuàng)建一個(gè)PrepareStmt, 客戶端從響應(yīng)中獲取StmtId.
  • 客戶端向MySQL發(fā)送COM_STMT_EXECUTE命令傳入?yún)?shù), 并將上一步獲得的StmtId一并傳入, 執(zhí)行PrepareStmt, 從響應(yīng)中獲取執(zhí)行結(jié)果.
  • 客戶端向MySQL發(fā)送COM_STMT_RESET命令清除PrepareStmt中綁定的參數(shù).
  • 客戶端向MySQL發(fā)送COM_STMT_CLOSE命令關(guān)閉PrepareStmt.

注意以上每一步都會(huì)收到MySQL的響應(yīng). 另外一個(gè)COM_STMT_SEND_LONG_DATA命令, 是用來發(fā)送某一列的參數(shù)值的. 該命令沒有返回值.

問題剖析

以上是針對MySQL而言的. Mycat作為MySQL的代理中間件, 處理prepare binary的方式與上述類似, 但是因?yàn)樘幚鞢OM_STMT_SEND_LONG_DATA和COM_STMT_EXECUTE命令的方式不對, 導(dǎo)致了這個(gè)bug. 直接上代碼:

public class ExecutePacket extends MySQLPacket {
    public void read(byte[] data, String charset) throws UnsupportedEncodingException {
        ...
        // 設(shè)置參數(shù)類型和讀取參數(shù)值
        byte[] nullBitMap = this.nullBitMap;
        for (int i = 0; i < parameterCount; i++) {
            BindValue bv = new BindValue();
            bv.type = pstmt.getParametersType()[i];
            if ((nullBitMap[i / 8] & (1 << (i & 7))) != 0) {
                bv.isNull = true;
            } else {
                BindValueUtil.read(mm, bv, charset);
                if(bv.isLongData) {
                    bv.value = pstmt.getLongData(i);
                }
            }
            values[i] = bv;
        }
        ...
    }
}
public class BindValueUtil {
    public static final void read(MySQLMessage mm, BindValue bv, String charset) throws UnsupportedEncodingException {
        switch (bv.type & 0xff) {
            ...
        case Fields.FIELD_TYPE_VAR_STRING:
        case Fields.FIELD_TYPE_STRING:
        case Fields.FIELD_TYPE_VARCHAR:
            bv.value = mm.readStringWithLength(charset);
//            if (bv.value == null) {
//                bv.isNull = true;
//            }
            break;
            ...
        case Fields.FIELD_TYPE_BLOB:
            bv.isLongData = true;
            break;
        }
    }
}

可以看到, 在處理VARCHAR類型時(shí), 直接讀取packet中的數(shù)據(jù), 而當(dāng)是FIELD_TYPE_BLOB類型時(shí), 會(huì)做一個(gè)標(biāo)記, 不讀取packet中的數(shù)據(jù). 這種實(shí)現(xiàn)方式的背后邏輯是: 對FIELD_TYPE_BLOB類型, 客戶端會(huì)使用COM_STMT_SEND_LONG_DATA命令發(fā)送數(shù)據(jù), 而在COM_STMT_EXECUTE會(huì)忽略對應(yīng)列, 不再發(fā)送該列數(shù)據(jù).

然而, Mycat的實(shí)現(xiàn)方式忽略了一種情況: 對于VARCHAR, TEXT等類型, 客戶端同樣可以用COM_STMT_SEND_LONG_DATA命令發(fā)送數(shù)據(jù). 考慮這種情況: 客戶端對VARCHAR類型的列的數(shù)據(jù), 使用COM_STMT_SEND_LONG_DATA命令發(fā)送, 而其他類型仍使用COM_STMT_EXECUTE發(fā)送, 按照Mycat的這種實(shí)現(xiàn)方式, 會(huì)按照SQL中參數(shù)綁定的順序, 處理那些本應(yīng)忽略的列, 而一旦用錯(cuò)誤的類型處理數(shù)據(jù), 相當(dāng)于協(xié)議解析錯(cuò)誤, 就肯定會(huì)導(dǎo)致StringIndexOutOfBoundsException等問題了.

那么, 為什么VARCHAR字段長度超過600K會(huì)觸發(fā)這個(gè)bug呢? 原來是golang mysql客戶端在執(zhí)行prepare binary SQL時(shí), 如果一個(gè)字符串?dāng)?shù)據(jù)的長度超過了longDataSize的值, 就會(huì)把該數(shù)據(jù)通過COM_STMT_SEND_LONG_DATA發(fā)送. 相關(guān)代碼在這個(gè)文件中, 簡要給出相關(guān)內(nèi)容:

// Execute Prepared Statement
// http://dev.mysql.com/doc/internals/en/com-stmt-execute.html
func (stmt *mysqlStmt) writeExecutePacket(args []driver.Value) error {
    ...
    // Determine threshold dynamically to avoid packet size shortage.
    longDataSize := mc.maxAllowedPacket / (stmt.paramCount + 1)
    if longDataSize < 64 {
        longDataSize = 64
    }
    ...
    if len(args) > 0 {
        ...
        for i, arg := range args {
            ...
            switch v := arg.(type) {
            case []byte: // 與string類似
                ...
            case string:
                paramTypes[i+i] = byte(fieldTypeString)
                paramTypes[i+i+1] = 0x00

                if len(v) < longDataSize {
                    paramValues = appendLengthEncodedInteger(paramValues,
                        uint64(len(v)),
                    )
                    paramValues = append(paramValues, v...)
                } else {
                    if err := stmt.writeCommandLongData(i, []byte(v)); err != nil {
                        return err
                    }
                }
                ...
            }
            ...
        }
        ...
    }
    ...
}

可以看到, longDataSize := mc.maxAllowedPacket / (stmt.paramCount + 1)

其中paramCount為prepare語句中綁定的參數(shù)個(gè)數(shù). maxAllowedPacket與MySQL系統(tǒng)參數(shù)max_allowed_packet有關(guān). 這個(gè)文檔給出了客戶端maxAllowedPacket的設(shè)置方式. 如果不設(shè)置, 默認(rèn)值是4194304, 如果設(shè)置為0, 則通過SELECT @@max_allowed_packet從MySQL獲取, 如果設(shè)置值超過1<<24-1, 則設(shè)置為1<<24-1.

到此, 問題原因終于明晰了: golang mysql客戶端使用了maxAllowedPacket的默認(rèn)值4194304, 并且在執(zhí)行prepare binary語句時(shí), 遇到了長度超過longDataSize的數(shù)據(jù), 執(zhí)行COM_STMT_SEND_LONG_DATA發(fā)送給Mycat后, 再執(zhí)行COM_STMT_EXECUTE時(shí)未發(fā)送該字段數(shù)據(jù), 而Mycat仍在COM_STMT_EXECUTE時(shí)處理該數(shù)據(jù), 導(dǎo)致協(xié)議解析錯(cuò)誤. 至于600K觸發(fā)這個(gè)bug, 執(zhí)行的SQL中有6個(gè)綁定參數(shù), 則longDataSize = 4194304 / (6 + 1) = 599186, 約等于600K.

解決方案

我們已經(jīng)跟業(yè)務(wù)同學(xué)協(xié)商, 由他們優(yōu)化該字段, 減小字段長度. 至于Mycat如何修復(fù)這個(gè)bug, 也非常簡單: 在執(zhí)行COM_STMT_SEND_LONG_DATA后, 把對應(yīng)字段做一個(gè)標(biāo)記, 在后續(xù)執(zhí)行COM_STMT_EXECUTE遍歷綁定參數(shù)時(shí), 跳過該字段即可.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。