今天線上使用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
因爲採用了prepare binary協議執行SQL, 咱們先來分析一下prepare binary執行流程. 具體內容可參考MySQL Internal Manualsql
prepare binary協議包含5種命令:後端
MySQL處理一個prepare binary請求的流程以下:bash
注意以上每一步都會收到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遍歷綁定參數時, 跳過該字段便可.