今天線上使用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í), 跳過該字段即可.