前言
我們的分庫分表中間件線上上運作了兩年多,到目前為止還算穩定。在筆者将精力放在處理各種災難性事件(例如中間件實體機當機/資料庫當機/網絡隔離等突發事件)時。竟然發現還有一些奇怪的corner case。現在就将排查思路寫成文章分享出來。
Bug現場
應用拓撲
應用通過中間件連後端多個資料庫,sql會根據路由規則路由到指定的節點,如下圖所示:

錯誤現象
應用在做某些資料庫操作時,會發現有比較大的機率失敗。他們的代碼邏輯是這樣:
int count = updateSql(sql1);
...
// 僞代碼
int count = updateSql("update test set value =1 where id in ("100","200") and status = 1;
if( 0 == count ){
throw new RuntimeException("更新失敗");
}
......
int count = updateSql(sql3);
...
即每做一次update之後都檢查下是否更新成功,如果不成功則復原并抛異常。
在實際測試的過程中,發現經常報錯,更新為0。而實際那條sql确實是可以更新到的(即報錯復原後,我們手動執行sql可以執行并update count>0)。
中間件日志
筆者根據sql去中間件日志裡面搜尋。發現了非常奇怪的結果,日志如下:
2020-03-13 11:21:01:440 [NIOREACTOR-20-RW] frontIP=>ip1;sqlID=>12345678;rows=>0;sql=>update test set value =1 where id in ("1","2") and status = 1;start=>11:21:01:403;time=>24266;
2020-03-13 11:21:01:440 [NIOREACTOR-20-RW] frontIP=>ip1;sqlID=>12345678;rows=>2;sql=>update test set value =1 where id in ("1","2") and status = 1;start=>11:21:01:403;time=>24591;
由于中間件對每條sql都辨別了唯一的一個sqlID,在日志表現看來就好像sql執行了兩遍!由于sql中有一個in,很容易想到是否被拆成了兩條執行了。如下圖所示:
這條思路很快被筆者否決了,因為筆者explain并手動執行了一下,這條sql确實隻路由到了一個節點。真正完全否決掉這條思路的是筆者在日志裡面還發現,同樣的SQL會列印三遍!即看上去像執行了三次,這就和僅僅隻in了兩個id的sql在思路上相沖突了。
資料庫日志
那到底資料真正執行了多少條呢?找DBA去撈一下其中的sql日志,由于線下環境沒有日志切割,日志量巨大,搜尋時間太慢。沒辦法,就按照現有的資料進行分析吧。
日志如何被觸發
由于目前沒有任何思路,于是筆者翻看中間件的代碼,發現在update語句執行後,中間件會在收到mysql okay包後列印上述日志。如下圖所示:
注意到所有出問題的update出問題的時候都是同一個NIOREACTOR線程先後列印了兩條日志,是以筆者推斷這兩個okay大機率是同一個後端連接配接傳回的。
什麼情況會傳回多個okay?
這個問題筆者思索了很久,因為在筆者的實際重新執行出問題的sql并debug時,永遠隻有一個okay傳回。于是筆者聯想到,我們中間件有個狀态同步的部分,而這些狀态同步是将set auto_commit=0等sql拼接到應用發送的sql前面。即變成如下所示:
sql可能為
set auto_commit=0;set charset=gbk;>update test set value =1 where id in ("1","2") and status = 1;
于是筆者細細讀了這部分的代碼,發現處理的很好。其通過計算出前面拼接出的sql數量,再在接收okay包的時候進行遞減,最後将真正執行的那條sql處理傳回。其處理如下圖所示:
但這裡确給了筆者一個靈感,即一條sql文本确實是有可能傳回多個okay包的。
真相大白
在筆者發現(sql1;sql2;)這樣的拼接sql會傳回多個okay包後,就立刻聯想到,該不會業務自己寫了這樣的sql發給中間件,造成中間件的sql處理邏輯錯亂吧。因為我們的中間件隻有在對自己拼接(同步狀态)的sql做處理,明顯是無法處理應用傳過來即為拼接sql的情況。
由于看上去有問題的那條sql并沒有拼接,于是筆者憑借這條sql列印所在的reactor線程往上搜尋,發現其上面真的有拼接sql!
2020-03-1311:21:01:040[NIOREACTOR-20RW]frontIP=>ip1;sqlID=>12345678;rows=>1;
sql=>update test_2 set value =1 where id=1 and status = 1;update test_2 set value =1 where id=2 and status = 1;
如上圖所示,(update1;update2)中update1的okay傳回被驅動認為是所有的傳回。然後應用立即發送了update3。前腳剛發送,update2的okay傳回就回來了而其剛好是0,應用就報錯了(要不是0,這個錯亂邏輯還不會提前暴露)。那三條"重複執行"也很好解釋了,就是之前的拼接sql會有三條。
為何是機率出現
但奇怪的是,并不是每次拼接sql都會造成update3"重複執行"的現象,按照筆者的推斷應該前面隻要是多條拼接sql就會必現才對。于是筆者翻了下jdbc驅動源碼,發現其在發送指令之前會清理下接收buffer,如下所示:
MysqlIO.java
final Buffer sendCommand(......){
......
// 清理接收buffer,會将殘存的okay包清除掉
clearInputStream();
......
send(this.sendPacket, this.sendPacket.getPosition());
......
}
正是由于clearInputStream()使得錯誤非必現(暴露),如果okay(update2)在應用發送第三條sql前先到jdbc驅動會被驅動忽略!
讓我們再看一下不會讓update3"重複執行"的時序圖:
即根據okay(update2)傳回的快慢來決定是否暴露這個問題,如下圖所示:
同時筆者觀察日志,确實這種情況下"update1;update2"這條語句在中間件裡面日志有兩條。
臨時解決方案
讓業務開發不用這些拼接sql的寫法後,再也沒出過問題。
為什麼不連中間件是okay的
業務開發這些sql是就線上上運作了好久,用了中間件後才出現問題。
既然不連中間件是okay的,那麼jdbc必然有這方面的完善處理,筆者去翻了下mysql-connect-java(5.1.46)。由于jdbc裡面存在大量的相容細節處理,筆者這邊隻列出一些關鍵代碼路徑:
MySQL JDBC 源碼
MySQLIO
stack;
executeUpdate
|->executeUpdateInternel
|->executeInternal
|->execSQL
|->sqlQueryDirect
|->readAllResults (MysqlIO.java)
readAllResults: //核心在這個函數的處理裡面
ResultSetImpl readAllResults(......){
......
while (moreRowSetsExist) {
......
// 在傳回okay包的保中其serverStatus字段中如果SERVER_MORE_RESULTS_EXISTS置位
// 表明還有更多的okay packet
moreRowSetsExist = (this.serverStatus & SERVER_MORE_RESULTS_EXISTS) != 0;
}
......
}
正确的處理流程如下圖所示:
而我們中間件的源碼确實這麼處理的:
@Override
public void okResponse(byte[] data, BackendConnection conn) {
......
// 這邊僅僅處理了autocommit的狀态,沒有處理SERVER_MORE_RESULTS_EXISTS
// 是以導緻了不相容拼接sql的現象
ok.serverStatus = source.isAutocommit() ? 2 : 1;
ok.write(source);
......
}
select也"重複執行"了
解決完上面的問題後,筆者在日志裡竟然發現select盡然也有重複的,這邊并不會牽涉到okay包的處理,難道還有問題?日志如下所示:
2020-03-13 12:21:01:040[NIOREACTOR-20RW]frontIP=>ip1;sqlID=>12345678;rows=>1;select abc;
2020-03-13 12:21:01:045[NIOREACTOR-21RW]frontIP=>ip2;sqlID=>12345678;rows=>1;select abc;
從不同的REACTOR線程号(20RW/21RW)和不同的frontIP(ip1,ip2)來看是兩個連接配接執行了同樣的sql,但為何sqlID是一樣的?任何一個詭異的現象都必須一查到底。于是筆者登入到應用上看了下應用日志,确實應用有兩個不同的線程運作了同一條sql。
那肯定是中間件日志列印的問題了,筆者很快就想通了其中的關竅,我們中間件有個對同樣sql緩存其路由節點結構體的功能(這樣下一次同樣sql就不必解析,降低了CPU),而sqlID資訊正好也在那個路由節點結構體裡面。如下圖所示:
這個緩存功能感覺沒啥用(因為線上基本是沒有相同sql的),于是筆者在筆者優化的閃電模式下(大幅度提高中間件性能)将這個功能禁用掉了,沒想到為了排查問題而開啟的詳細日志碰巧将這個功能開啟了。
總結
任何系統都不能說百分之百穩定可靠,尤其是不能立flag。線上上運作了好幾年的系統也是如此。隻有對所有預料外的現象進行細緻的追查與深入的分析并解決,才能讓我們的系統越來越可靠。