Mycat處理prepare binary協議的bug

今天線上使用Mycat的業務忽然反饋經過golang MySQL客戶端執行SQL報異常:html

Error 3344: StringIndexOutOfBoundsException: String index out of range: 237
複製代碼

查看Mycat日誌也只有異常堆棧信息, 沒有打出錯誤SQL和參數. 業務方很快定位到問題: 經過prepare binary協議執行一條INSERT語句時, 某個VARCHAR字段傳入的數據長度超過600K而報出的錯誤. 經過源碼調試找到了問題緣由, 是mycat的一個bug, 該bug在1.6分支依然存在. 本文會詳細分析該問題.mysql

背景

業務方使用的是golang的github.com/go-sql-driver/mysql這個官方mysql客戶端訪問的Mycat代理服務器, 涉及到的數據表結構以下 (結構相似, 字段脫敏):git

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協議執行INSERT SQL語句:github

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啓動端口爲8066, 直連後端mysql, tbl_test沒有配置分表. 當data變量長度超過600K時, 執行這段代碼就會報Error 3344: StringIndexOutOfBoundsException.golang

MySQL prepare binary協議

因爲採用了prepare binary協議執行SQL, 咱們先來分析一下prepare binary執行流程. 具體內容可參考MySQL Internal Manualsql

prepare binary協議包含5種命令:後端

  • COM_STMT_PREPARE
  • COM_STMT_EXECUTE
  • COM_STMT_CLOSE
  • COM_STMT_RESET
  • COM_STMT_SEND_LONG_DATA

MySQL處理一個prepare binary請求的流程以下:bash

  • 客戶端向MySQL發送COM_STMT_PREPARE命令發送SQL語句, 從而在MySQL中建立一個PrepareStmt, 客戶端從響應中獲取StmtId.
  • 客戶端向MySQL發送COM_STMT_EXECUTE命令傳入參數, 並將上一步得到的StmtId一併傳入, 執行PrepareStmt, 從響應中獲取執行結果.
  • 客戶端向MySQL發送COM_STMT_RESET命令清除PrepareStmt中綁定的參數.
  • 客戶端向MySQL發送COM_STMT_CLOSE命令關閉PrepareStmt.

注意以上每一步都會收到MySQL的響應. 另一個COM_STMT_SEND_LONG_DATA命令, 是用來發送某一列的參數值的. 該命令沒有返回值.服務器

問題剖析

以上是針對MySQL而言的. Mycat做爲MySQL的代理中間件, 處理prepare binary的方式與上述相似, 可是由於處理COM_STMT_SEND_LONG_DATA和COM_STMT_EXECUTE命令的方式不對, 致使了這個bug. 直接上代碼:app

public class ExecutePacket extends MySQLPacket {
    public void read(byte[] data, String charset) throws UnsupportedEncodingException {
        ...
        // 設置參數類型和讀取參數值
        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類型時, 直接讀取packet中的數據, 而當是FIELD_TYPE_BLOB類型時, 會作一個標記, 不讀取packet中的數據. 這種實現方式的背後邏輯是: 對FIELD_TYPE_BLOB類型, 客戶端會使用COM_STMT_SEND_LONG_DATA命令發送數據, 而在COM_STMT_EXECUTE會忽略對應列, 再也不發送該列數據.

然而, Mycat的實現方式忽略了一種狀況: 對於VARCHAR, TEXT等類型, 客戶端一樣能夠用COM_STMT_SEND_LONG_DATA命令發送數據. 考慮這種狀況: 客戶端對VARCHAR類型的列的數據, 使用COM_STMT_SEND_LONG_DATA命令發送, 而其餘類型仍使用COM_STMT_EXECUTE發送, 按照Mycat的這種實現方式, 會按照SQL中參數綁定的順序, 處理那些本應忽略的列, 而一旦用錯誤的類型處理數據, 至關於協議解析錯誤, 就確定會致使StringIndexOutOfBoundsException等問題了.

那麼, 爲何VARCHAR字段長度超過600K會觸發這個bug呢? 原來是golang mysql客戶端在執行prepare binary SQL時, 若是一個字符串數據的長度超過了longDataSize的值, 就會把該數據經過COM_STMT_SEND_LONG_DATA發送. 相關代碼在這個文件中, 簡要給出相關內容:

// 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語句中綁定的參數個數. maxAllowedPacket與MySQL系統參數max_allowed_packet有關. 這個文檔給出了客戶端maxAllowedPacket的設置方式. 若是不設置, 默認值是4194304, 若是設置爲0, 則經過SELECT @@max_allowed_packet從MySQL獲取, 若是設置值超過1<<24-1, 則設置爲1<<24-1.

到此, 問題緣由終於明晰了: golang mysql客戶端使用了maxAllowedPacket的默認值4194304, 而且在執行prepare binary語句時, 遇到了長度超過longDataSize的數據, 執行COM_STMT_SEND_LONG_DATA發送給Mycat後, 再執行COM_STMT_EXECUTE時未發送該字段數據, 而Mycat仍在COM_STMT_EXECUTE時處理該數據, 致使協議解析錯誤. 至於600K觸發這個bug, 執行的SQL中有6個綁定參數, 則longDataSize = 4194304 / (6 + 1) = 599186, 約等於600K.

解決方案

咱們已經跟業務同窗協商, 由他們優化該字段, 減少字段長度. 至於Mycat如何修復這個bug, 也很是簡單: 在執行COM_STMT_SEND_LONG_DATA後, 把對應字段作一個標記, 在後續執行COM_STMT_EXECUTE遍歷綁定參數時, 跳過該字段便可.

相關文章
相關標籤/搜索