引文 4月份的時候看到一道面試題,據說是騰訊校招面試官提的:在多線程和高并發環境下,如果有一個平均運作一百萬次才出現一次的bug,你如何調試這個bug?知乎原貼位址如下: https://www.zhihu.com/question/43416744 . 遺憾的是知乎很多答案在抨擊這道題本身的正确性,雖然我不是這次的面試官,但我認為這是一道非常好的面試題。當然,隻是道加分題,答不上,不扣分。答得不錯,說明解決問題的思路和能力要超過應屆生平均水準。 之是以寫上面這段,是因為我覺得大部分背景服務端開發都有可能遇到這樣的BUG,即使沒有遇到,這樣的題目也能夠激發大家不斷思考和總結。非常湊巧的是,我在4月份也遇到了一個類似的而且要更加嚴重的BUG,這是我自己挖的一個很深的坑,不填好,整個項目就無法上線。現在已經過去了一個多月,趁着有時間,自己好好總結一下,希望裡面提到的一些經驗和工具能夠帶給大家一點幫助。
我們針對nginx事件架構和openssl協定棧進行了一些深度改造,以提升nginx的HTTPS完全握手計算性能。
由于原生nginx使用本地CPU做RSA計算,ECDHE_RSA算法的單核處理能力隻有400 qps左右。前期測試時的并發性能很低,就算開了24核,性能也無法超過1萬。
核心功能在去年底就完成了開發,線下測試也沒有發現問題。經過優化後的性能提升了幾倍,為了測試最大性能,使用了很多用戶端并發測試https性能。很快就遇到了一些問題:
第一個問題是nginx有極低機率(億分之一)在不同地方 core dump。白天線下壓力測試2W qps一般都要兩三個小時才出一次core。每次晚上睡覺之前都會将最新的調試代碼編譯好并啟動測試,到早上醒來第一眼就會去檢視機器并祈禱不要出core,不幸的是,一般都會有幾個到幾十個core,并且會發現經常是在一個時間點集中core dump。線上灰階測試運作了6天,在第6天的早上才集中core dump了幾十次。這樣算來,這個core dump的機率至少是億分之一了。
不過和面試題目中多線程不同的是,nginx采用的是多程序+全異步事件驅動的程式設計模式(目前也支援了多線程,但隻是針對IO的優化,核心機制還是多程序加異步)。在webserver的實作背景下,多程序異步相比多線程的優點是性能高,沒有太多線程間的切換,而且記憶體空間獨立,省去線程間鎖的競争。當然也有缺點,就是異步模式程式設計非常複雜,将一些邏輯上連續的事件從空間和時間切割,不符合人的正常思考習慣,出了問題後比較難追查。另外異步事件對網絡和作業系統的底層知識要求較高,稍不小心就容易挖坑。
第二個問題是高并發時nginx存在記憶體洩漏。在流量低的時候沒有問題,加大測試流量就會出現記憶體洩漏。
第三個問題,因為我們對nginx和openssl的關鍵代碼都做了一些改造,希望提升它的性能。那麼如何找到性能熱點和瓶頸并持續優化呢?
其中第一和第二個問題的背景都是,隻有并發上萬qps以上時才有可能出現,幾百或者一兩千QPS時,程式沒有任何問題。
首先說一下core的解決思路,主要是如下幾點:
1.gdb及debug log定位,發現作用不大。
2.如何重制bug?
3.構造高并發壓力測試系統。
4.構造穩定的異常請求。
因為有core dump ,是以這個問題初看很容易定位。gdb 找到core dump點,btrace就能知道基本的原因和上下文了。
core的直接原因非常簡單和常見,全部都是NULL指針引用導緻的。不過從函數上下文想不通為什麼會出現NULL值,因為這些指針在原生nginx的事件和子產品中都是這麼使用的,不應該在這些地方變成NULL。由于暫時找不到根本原因,還是先解決CORE dump吧,修複辦法也非常簡單,直接判斷指針是否NULL,如果是NULL就直接傳回,不引用不就完事了,這個地方以後肯定不會出CORE了。
這樣的防守式程式設計并不提倡,指針NULL引用如果不core dump,而是直接傳回,那麼這個錯誤很有可能會影響使用者的通路,同時這樣的BUG還不知道什麼時候能暴露。是以CORE DUMP 在NULL處,其實是非常負責任和有效的做法。
在NULL處傳回,确實避免了在這個地方的CORE,但是過幾個小時又core 在了另外一個NULL指針引用上。于是我又繼續加個判斷并避免NULL指針的引用。悲劇的是,過了幾個小時,又CORE在了其他地方,就這樣過了幾天,我一直在想為什麼會出現一些指針為NULL的情況?為什麼會CORE在不同地方?為什麼我用浏覽器和curl這樣的指令工具通路卻沒有任何問題?
熟悉nginx代碼的同學應該很清楚,nginx極少在函數入口及其他地方判斷指針是否為NULL值。特别是一些關鍵資料結構,比如‘ngx_connection_t’及SSL_CTX等,在請求接收的時候就完成了初始化,是以不可能在後續正常處理過程中出現NULL的情況。
于是我更加迷惑,顯然NULL值導緻出CORE隻是表象,真正的問題是,這些關鍵指針為什麼會被指派成NULL?
這個時候異步事件程式設計的缺點和複雜性就暴露了,好好的一個用戶端的請求,從邏輯上應該是連續的,但是被讀寫及時間事件拆成了多個片斷。雖然GDB能準确地記錄core dump時的函數調用棧,但是卻無法準确記錄一條請求完整的事件處理棧。根本就不知道上次是哪個事件的哪些函數将這個指針指派為NULL的,甚至都不知道這些資料結構上次被哪個事件使用了。
舉個例子:用戶端發送一個正常的get請求,由于網絡或者用戶端行為,需要發送兩次才完成。服務端第一次read沒有讀取完全部資料,這次讀事件中調用了 A,B函數,然後事件傳回。第二次資料來臨時,再次觸發read事件,調用了A,C函數。并且core dump在了C函數中。這個時候,btrace的stack frame已經沒有B函數調用的資訊了。
是以通過GDB無法準确定位 core 的真正原因。
這時候強大的GDB已經派不上用場了。怎麼辦?列印nginx調試日志。
但是列印日志也很郁悶,隻要将nginx的日志級别調整到DEBUG,CORE就無法重制。為什麼?因為DEBUG的日志資訊量非常大,頻繁地寫磁盤嚴重影響了NGINX的性能,打開DEBUG後性能由幾十萬直線下降到幾百qps。
調整到其他級别比如 INFO,性能雖然好了,但是日志資訊量太少,沒有幫助。盡管如此,日志卻是個很好的工具,于是又嘗試過以下辦法:
1.針對特定用戶端IP開啟debug日志,比如IP是10.1.1.1就列印DEBUG,其他IP就列印最進階别的日志,nginx本身就支援這樣的配置。
2.關閉DEBUG日志,自己在一些關鍵路徑添加進階别的調試日志,将調試資訊通過EMERG級别列印出來。
3.nginx隻開啟一個程序和少量的connection數。抽樣列印連接配接編号(比如尾号是1)的調試日志。
總體思路依然是在不明顯降低性能的前提下列印盡量詳細的調試日志,遺憾的是,上述辦法還是不能幫助問題定位,當然了,在不斷的日志調試中,對代碼和邏輯越來越熟悉。
這時候的調試效率已經很低了,幾萬QPS連續壓力測試,幾個小時才出一次CORE,然後修改代碼,添加調試日志。幾天過去了,毫無進展。是以必須要線上下構造出穩定的core dump環境,這樣才能加快debug效率。
雖然還沒有發現根本原因,但是發現了一個很可疑的地方:
出CORE比較集中,經常是在淩晨4,5點,早上7,8點的時候 dump幾十個CORE。
聯想到夜間有很多的網絡硬體調整及故障,我猜測這些core dump可能跟網絡品質相關。特别是網絡瞬時不穩定,很容易觸發BUG導緻大量的CORE DUMP。
最開始我考慮過使用TC(traffic control)工具來構造弱網絡環境,但是轉念一想,弱網絡環境導緻的結果是什麼?顯然是網絡請求的各種異常啊,是以還不如直接構造各種異常請求來複現問題。于是準備構造測試工具和環境,需要滿足兩個條件:
1.并發性能強,能夠同時發送數萬甚至數十萬級以上qps。
2.請求需要一定機率的異常。特别是TCP握手及SSL握手階段,需要異常中止。
traffic control是一個很好的構造弱網絡環境的工具,我之前用過測試SPDY協定性能。能夠控制網絡速率、丢包率、延時等網絡環境,作為iproute工具集中的一個工具,由linux系統自帶。但比較麻煩的是TC的配置規則很複雜,facebook在tc的基礎上封裝成了一個開源工具apc,有興趣的可以試試。
由于高并發流量時才可能出core,是以首先就需要找一個性能強大的壓測工具。
WRK是一款非常優秀的開源HTTP壓力測試工具,采用多線程 + 異步事件驅動的架構,其中事件機制使用了redis的ae事件架構,協定解析使用了nginx的相關代碼。
相比ab(apache bench)等傳統壓力測試工具的優點就是性能好,基本上單台機器發送幾百萬pqs,打滿網卡都沒有問題。
wrk的缺點就是隻支援HTTP類協定,不支援其他協定類測試,比如protobuf,另外資料顯示也不是很友善。
由于是HTTPS請求,使用ECDHE_RSA密鑰交換算法時,用戶端的計算消耗也比較大,單機也就10000多qps。也就是說如果server的性能有3W qps,那麼一台用戶端是無法發送這麼大的壓力的,是以需要建構一個多機的分布式測試系統,即通過中控機同時控制多台測試機用戶端啟動和停止測試。
之前也提到了,調試效率太低,整個測試過程需要能夠自動化運作,比如晚上睡覺前,可以控制多台機器在不同的協定,不同的端口,不同的cipher suite運作整個晚上。白天因為一直在盯着,運作幾分鐘就需要檢視結果。
這個系統有如下功能:
1.并發控制多台測試用戶端的啟停,最後彙總輸出總的測試結果。
2.支援https,http協定測試,支援webserver及revers proxy性能測試。
3.支援配置不同的測試時間、端口、URL。
4.根據端口選擇不同的SSL協定版本,不同的cipher suite。
5.根據URL選擇webserver、revers proxy模式。
壓力測試工具和系統都準備好了,還是不能準确複現core dump的環境。接下來還要完成異常請求的構造。構造哪些異常請求呢?
由于新增的功能代碼主要是和SSL握手相關,這個過程是緊接着TCP握手發生的,是以異常也主要發生在這個階段。于是我考慮構造了如下三種異常情形:
1.異常的tcp連接配接。即在用戶端tcp connent系統調用時,10%機率直接close這個socket。
2.異常的ssl連接配接。考慮兩種情況,full handshake第一階段時,即發送 client hello時,用戶端10%機率直接close連接配接。full handshake第二階段時,即發送clientKeyExchange時,用戶端10%機率直接直接關閉TCP連接配接。
3.異常的HTTPS請求,用戶端10%的請求使用錯誤的公鑰加密資料,這樣nginx解密時肯定會失敗。
構造好了上述高并發壓力異常測試系統,果然,幾秒鐘之内必然出CORE。有了穩定的測試環境,那bug fix的效率自然就會快很多。
雖然此時通過gdb還是不友善定位根本原因,但是測試請求已經滿足了觸發CORE的條件,打開debug調試日志也能觸發core dump。于是可以不斷地修改代碼,不斷地GDB調試,不斷地增加日志,一步步地追蹤根源,一步步地接近真相。
最終通過不斷地重複上述步驟找到了core dump的根本原因。其實在寫總結文檔的時候,core dump的根本原因是什麼已經不太重要,最重要的還是解決問題的思路和過程,這才是值得分享和總結的。很多情況下,千辛萬苦排查出來的,其實是一個非常明顯甚至愚蠢的錯誤。
比如這次core dump的主要原因是:
由于沒有正确地設定non-reusable,并發量太大時,用于異步代理計算的connection結構體被nginx回收并進行了初始化,進而導緻不同的事件中出現NULL指針并出CORE。
本文轉載至騰訊架構師