如果某个字段值相同则触发器新增_Thrift IDL新增字段导致版本不一致引发的惨案...
公司某業(yè)務(wù)出現(xiàn)嚴(yán)重的線上故障,復(fù)盤發(fā)現(xiàn)原因竟是某接口的 Thrift IDL 變更,未及時同步所有上游,導(dǎo)致上游某服務(wù) OOM 引發(fā) Crash。看似操作不規(guī)范是本次故障的根因,但進一步思考:該接口非主流程接口,即使 IDL 版本不統(tǒng)一,帶來“最壞”的后果不應(yīng)該是“僅僅報錯”嗎,為什么會產(chǎn)生 OOM 導(dǎo)致整個服務(wù) Crash?
實際上這樣的例子并不少見,很多公司內(nèi)部 RPC 使用 Thrift 協(xié)議,IDL 變更及版本不一致在所難免,極端情況下,安全團隊掃描 thrift 端口也會出現(xiàn)類似故障。為了避免更多團隊采坑,有必要研究 Thrift 何種情況下會觸發(fā),以及為什么會觸發(fā) OOM。
一. 從 IDL 的字段變更說起
化繁為簡,該故障的發(fā)生可以這樣描述:某 Thrift 服務(wù)的返回值是一個數(shù)組,數(shù)組中每個元素本來包含 5 個字段,某次調(diào)整后,在中間位置新增 1 個字段,其余保持不變。服務(wù)上線后,某上游調(diào)用方未收到變更通知,仍使用舊版本的 SDK。參照下圖所示:
看到這里,有經(jīng)驗的同學(xué)已經(jīng)瑟瑟發(fā)抖。在 Thrift 協(xié)議跨語言、高性能的背后,做了很多取舍,比如接收方在反序列化時,僅做了方法名等少量的校驗,通過序號、字段類型認(rèn)為該返回值屬于哪個字段,而并未校驗字段名,因此一旦上下游 IDL 版本不一致,極易產(chǎn)生字段錯位的情況。以上述 IDL 為例,id、image 字段正確解析,而 bg_image 被錯誤解析為 link、link 被錯誤解析為 title:
字段增加時,考慮到字段位置及新舊版本不匹配的各種場景,我們枚舉可能的后果:
再進一步,如果是刪除字段呢?從刪除字段的位置、是否 required,大家可自行思考,當(dāng)然結(jié)果類似,輕則被忽略,重則字段錯位,當(dāng)然更嚴(yán)重的會導(dǎo)致服務(wù) OOM。
小結(jié):Thrift IDL 不要變更已有字段的序列號,上下游版本不一致極易發(fā)生錯位現(xiàn)象。如需新增字段,應(yīng)放到最后并設(shè)置為 optional。
二. OOM 問題復(fù)現(xiàn)
回到 IDL 變更上來,為什么會引發(fā)上游服務(wù)的 OOM 呢?我們用一段很短的 IDL 就可以重現(xiàn)。
namespace java com.didiglobal.thrift.sample
struct Items{
1:required i64 id;
2:required list<Item> items;
}
struct Item {
1:required string name;
2:required string image; // 新增字段
3:required list<string> contents;
}
service Sample {
Items getItems(1:i64 id);
}
如果你也想嘗試,可以下載項目代碼,在本地搭建環(huán)境。
使用不同的 Thrift 版本,0.9.3 到最新的 0.13.0-snapshot 均可以重現(xiàn)。使用 jmap 命令,可以看到應(yīng)用創(chuàng)建了大量的字節(jié)數(shù)組。
小結(jié):只需要 10 個并發(fā)就可以重現(xiàn) OOM,該問題廣泛存在于 Thrift 版本 0.9.3 到最新的 0.13.0-snapshot。
三. 為什么會 OOM?
本次故障的根因是新增 String 字段,并且客戶端進程創(chuàng)建了大量的字節(jié)數(shù)組,首先懷疑字段錯位后 Thrift 未正確處理,但查看了下源碼,thrift 對字段類型不匹配、多余字段均作了 skip 處理:
查看 skip 方法的實現(xiàn),一度懷疑在類型為 String 時調(diào)用了錯誤的方法,但考慮 String 使用字節(jié)流傳輸,下圖中直接調(diào)用 readBinary()方法也無不妥,測試后發(fā)現(xiàn)也不是該問題:
思考了幾個其他方向,并做了多次嘗試,均發(fā)現(xiàn)思路不對,而且始終無法解釋的是,單個請求數(shù)據(jù)量只有上百字節(jié),為什么只需要 10 個并發(fā)、幾十個請求就會 OOM?這些請求的數(shù)據(jù)量累加起來也不過幾十 K,一度陷入僵局。
下班的路上反復(fù)思考,是不是和 Thrift 拋出的異常有關(guān),恰遇某個路口超長時間的紅燈,每每感嘆該路口浪費多少青春年華,此時卻從容拿出電腦,對 Thrift 拋出的異常做了臨時處理,運行測試,果然不再 OOM 了!
思路一下子清晰起來:雖然 Thrift 做了多余字段的 skip 處理,但由于拋出的異常,這些 skip 操作并未執(zhí)行到,甚至,List 中第一個元素校驗拋出異常后,后面所有字段都未繼續(xù)消費!
按該思路重新閱讀相關(guān)代碼,果然找到了可疑點:Thrift 接受服務(wù)端響應(yīng)時,會首先解析 TMessage 對象,前 4 個字節(jié)(I32)代表了某個字符串的長度,后面 readStringBody()方法會分配該長度的字節(jié)數(shù)組(byte[] buf = new byte[size]),該字符串實際上是 thrift 的方法名,而 debug 發(fā)現(xiàn),長度值是 184549632,大約 176M,這合理解釋了為什么 10 個并發(fā)就會觸發(fā) OOM。
小結(jié):Thrift 接收到請求后首先讀取 TMessage 結(jié)構(gòu),IDL 版本不一致的極端情況下,會分配 176M 的內(nèi)存空間,導(dǎo)致 10 個并發(fā)就占用上 G 內(nèi)存,觸發(fā) OOM。
四. 為什么會分配 176 兆的內(nèi)存空間?
解決這個問題,有助于了解哪些場景下會觸發(fā) OOM,從而更好地避免。
參考 Thrift TServiceClient 的 receiveBase()方法,詳細介紹了返回值的解析過程,參考下圖可以更好地理解,基本上是一個從前到后類似堆棧的反序列化:
前面已經(jīng)介紹過,如果返回值中多了參數(shù)、或者參數(shù)類型不對,Thrift 可以通過 skip()操作對該字段進行忽略。但 thrift 對 struct 結(jié)構(gòu)體有個額外的操作,就是解析完成后的調(diào)用 validate()方法,如果結(jié)構(gòu)不合法,會拋出異常:
正常情況下,這里拋出異常,請求失敗,應(yīng)該關(guān)閉該連接,或者想重用連接的話,要先把底層 Socket 的輸入流做清零處理。但 Thrift 未對輸入流做任何處理,直接重用了該 CLient 實例及其底層的 Socket 連接,導(dǎo)致下一次解析響應(yīng)數(shù)據(jù)時,讀取的是上一次請求失敗未處理完的數(shù)據(jù)。由于連接及底層 Socket 被重用,下一次發(fā)出請求,很快收到響應(yīng),Thrift 慣例開始讀取消息頭:readMessageBegin→readI32(),由于存在未清空的臟數(shù)據(jù),根據(jù)上圖的堆棧分析,readI32()時讀取的這 4 個字節(jié),分別是:
byte type = TType.STRING; // 字節(jié) 0:Item 第一個字段(name)的類型,String,值為 11
short id = 1; // 字節(jié) 1 和 2:Item 第一個字段(name)的位置,值為 1
int size = 6; // 字節(jié) 3:Item 第一個字段(name)的 size 的首字節(jié),值為 6
byte(1 個字節(jié))+short(2 個字節(jié))+int(第 1 個字節(jié)),按 big endian 編碼,其值恰好等于 184549632:
小結(jié):184549632 的出現(xiàn)有其必然性,不恰當(dāng)?shù)?IDL 變更,Thrift 客戶端拋出異常后未做輸入流清理,下一個請求會把之前的殘留數(shù)據(jù),錯誤解析成字符串大小,很快導(dǎo)致 OOM。
五. 沒遇到 OOM,我是不是很幸運?
當(dāng)然依 IDL 不同、異常拋出的位置不同,讀取消息頭時 readI32()讀取的數(shù)字不一定恰好是 184549632。項目中提供了一個叫做 sample2 的 IDL,舊 Client 訪問新 server 時,該值的大小是 8388864,大約等于 8M,因此 10 個并發(fā)甚至 100 個并發(fā)也不一定會觸發(fā) OOM。
所以當(dāng)有不恰當(dāng)?shù)?IDL 變更,你沒遇到 OOM,只是幸運而已。
但進一步分析,這種情況雖不會觸發(fā) OOM,但客戶端一直等待服務(wù)端返回 8388864 個字節(jié)的數(shù)據(jù),久等而不得,于是該連接被阻塞了,一直到超時。
小結(jié):不恰當(dāng)?shù)?IDL 變更,不會全部導(dǎo)致 OOM,也有可能導(dǎo)致大量連接被阻塞,直到超時錯誤。
六. 如何修復(fù) OOM?
參考 Thrift themissing guide 文檔,IDL 變更要有規(guī)范,并在團隊內(nèi)反復(fù)宣導(dǎo)。
諷刺的是,“通過規(guī)范、最佳實踐來避免 Crash 類問題”,本身就不是一種最佳實踐,至少使用 Http 協(xié)議不用遇到這種問題。
這可以認(rèn)為是 Thrift 自身的一個缺陷,作為當(dāng)前廣泛使用的 RPC 協(xié)議,需要優(yōu)雅地處理用戶必須遇到的 IDL 變更的問題。
思路一:讀取任何 struct 類型的字段時,catch 住異常,保障 thrift 把輸入流的所有數(shù)據(jù)讀完
Thrift 在反序列化階段,遇到任何 struct 類型的字段,讀取結(jié)束后都會調(diào)用 validate()方法,因此在 struct 類型字段的讀取時,可以添加 try-catch 校驗異常,保證 thrift 讀完所有的數(shù)據(jù)。
該思路經(jīng)測試驗證可以,但需要修改的地方較多,不具備可操作性。
思路二:無論是否拋出異常,從協(xié)議層面保證清空輸入流的殘留數(shù)據(jù)
該思路具備較好的收斂性,只需要修改 Thrift 源碼的 2 個文件:TServiceClient 修改如下圖
1,TSocket 類的修改如下圖 2:
經(jīng)壓測驗證,在 Macbook Air 上,使用 100 個并發(fā)進行驗證,持續(xù) 15 分鐘,累計發(fā)送~160 萬次請求,單次請求響應(yīng)數(shù)據(jù)量~0.5K,除了正常報錯,客戶端、服務(wù)端內(nèi)存無任何異常。
小結(jié):Thrift IDL 變更不可避免,上下游版本不一致也不可避免,協(xié)議其實可以做到優(yōu)雅地去處理,而不是 OOM 了事。
七. 有沒有臨時方案?
思路一:使用嚴(yán)格讀模式?
Thrift 眾多的配置項中,有嚴(yán)格讀(strictRead)、嚴(yán)格寫(strictWrite)兩個選項,由于嚴(yán)格讀默認(rèn)值為 False,改為 true 是否可以呢?如果 OK 的話,只需要修改配置而無需修改源碼,這將會是最輕量級的方案。查看 Thrift 源碼,如果命中嚴(yán)格讀會提前報錯,避免下方 readStringBody()方法分配太大的內(nèi)存空間:
嚴(yán)格讀(strictRead)的修改方式如下:
// 很多情況下大家如此使用 TProtocol 創(chuàng)建連接,此時 strictWrite 值為 true,而、strictRead 值為 false;
TProtocol protocol = new TBinaryProtocol(transport);
// 嚴(yán)格讀:直接創(chuàng)建 TBinaryProtocol,傳入?yún)?shù)
TProtocol protocol = new TBinaryProtocol(transport, true, true);
// 嚴(yán)格讀:使用 Protocol 工廠模式,傳入?yún)?shù)
TBinaryProtocol.Factory protFactory = new TBinaryProtocol.Factory(true, true);
經(jīng)測試驗證,使用嚴(yán)格讀之后,客戶端還會報錯,但 OOM 問題消失了!“貌似”這也是我們想要的結(jié)果,壓測效果如何呢?
在 MacbookAir 上,使用 100 個并發(fā)進行驗證,5 分鐘之后,發(fā)送大約 56 萬請求,客戶端出現(xiàn)大量的 SocketException(Broken pipe),甚至客戶端開始宕死。
究其原因,strictRead 雖然避免了創(chuàng)建大量字節(jié)數(shù)組,但拋異常時 thrift 也未對輸入流做任何清理,會產(chǎn)生誤讀取殘余數(shù)據(jù),甚至引起連接阻塞。
所以使用嚴(yán)格讀,并不能解決這一問題,同樣 thrift 提供了另外一個配置項“讀取字符串或字節(jié)的最大長度(stringLengthLimit_)”,也不能解決該問題。
思路二:使用短連接?
每個請求創(chuàng)建一個短連接,用完就關(guān)閉?這樣雖可以避免請求之間的數(shù)據(jù)污染,但毫無疑問也會帶來較大的性能損失,通常不建議。
思路三:使用 TFramedTransport?
TFramedTransport 對整幀數(shù)據(jù)使用了緩存,一次性把底層 Socket 中 Inputstream 中的數(shù)據(jù)完全讀到了 readBuffer,理論上不會出現(xiàn) OOM 了。
驗證了一把,很可惜還是會 OOM,原因是拋異常之后,TFramedTransport 中的 readBuffer 中的數(shù)據(jù)未做清理,下次請求會錯誤讀取 readBuffer 中的殘余數(shù)據(jù)。
如果你想驗證,可以運行 OldClientNewServerTest 中的測試用例:oldclient_should_oom_if_use_TFramedTransport_at_concurrency_10
小結(jié):使用嚴(yán)格讀、限制字符串長度等配置方式,使用 TFramedTransport 都不能完美解決問題,要么 OOM,要么連接被阻塞。而短連接對性能影響較大,在 Thrift 中也很少使用。
八. 總結(jié)
Thrift IDL 不恰當(dāng)?shù)刈兏?#xff0c;當(dāng)上下游 IDL 版本不一致時,極易引發(fā)字段錯位、連接阻塞、甚至 OOM。Java、Go 等語言實現(xiàn)中均存在該問題,并廣泛存在于 Thrift 0.9 到最新的 0.13.0-snapshot 等各個版本。
該問題觸發(fā)的根本原因是:客戶端接收數(shù)據(jù)后,對 struct 類型做 validate()時拋出異常,并未對底層的 socket 輸入流做清零處理,后續(xù)請求誤把殘留數(shù)據(jù)讀作字節(jié)長度,極易引發(fā) OOM,或者導(dǎo)致 Socket 連接阻塞超時。
Thrift IDL 變更不可避免,上下游版本不一致也不可避免,從協(xié)議層面提供優(yōu)雅支持是完全可能的。已向 Thrift 提交 Issue,以及部分語言實現(xiàn)的 Pull Request,但爭吵幾輪之后,Thrift 維護人員拒絕合并,認(rèn)為“遵守最佳實踐就夠了”,他們敷衍的態(tài)度太讓人 piss off 了。
本文轉(zhuǎn)載自技術(shù)鎖話公眾號。
原文鏈接:https://mp.weixin.qq.com/s/aqHoM7-hKQOFHRzWipfa8g
與50位技術(shù)專家面對面20年技術(shù)見證,附贈技術(shù)全景圖總結(jié)
以上是生活随笔為你收集整理的如果某个字段值相同则触发器新增_Thrift IDL新增字段导致版本不一致引发的惨案...的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 三菱fx5u编程手册_实用分享 | 三菱
- 下一篇: 批处理dir 保存到变量中_批处理|看大