高併發性能調試經驗分享

作者:helloworlds
鏈接:https://zhuanlan.zhihu.com/p/21348220
來源:知乎

引文

4月份的時候看到一道面試題,據說是騰訊校招面試官提的:在多線程和高併發環境下,如果有一個平均運行一百萬次纔出現一次的bug,你如何調試這個bug?知乎原貼地址如下:騰訊實習生面試,這兩道題目該怎麼回答? - 編程
遺憾的是知乎很多答案在抨擊這道題本身的正確性,雖然我不是這次的面試官,但我認爲這是一道非常好的面試題。當然,只是道加分題,答不上,不扣分。答得不錯,說明解決問題的思路和能力要超過應屆生平均水平。
之所以寫上面這段,是因爲我覺得大部分後臺服務端開發都有可能遇到這樣的BUG,即使沒有遇到,這樣的題目也能夠激發大家不斷思考和總結。非常湊巧的是,我在4月份也遇到了一個類似的而且要更加嚴重的BUG,這是我自己挖的一個很深的坑,不填好,整個項目就無法上線。
現在已經過去了一個多月,趁着有時間,自己好好總結一下,希望裏面提到的一些經驗和工具能夠帶給大家一點幫助。

項目背景

我們針對nginx事件框架和openssl協議棧進行了一些深度改造,以提升nginx的HTTPS完全握手計算性能。
由於原生nginx使用本地CPU做RSA計算,ECDHE_RSA算法的單核處理能力只有400 qps左右。前期測試時的併發性能很低,就算開了24核,性能也無法超過1萬。
核心功能在去年底就完成了開發,線下測試也沒有發現問題。經過優化後的性能提升幾倍,爲了測試最大性能,使用了很多客戶端併發測試https性能。很快就遇到了一些問題:

  1. 第一個問題是nginx有極低概率(億分之一)在不同地方 core dump。白天線下壓力測試2W qps一般都要兩三個小時纔出一次core。每次晚上睡覺之前都會將最新的調試代碼編譯好並啓動測試,到早上醒來第一眼就會去查看機器並祈禱不要出core,不幸的是,一般都會有幾個到幾十個core,並且會發現經常是在一個時間點集中core dump。線上灰度測試運行了6天,在第6天的早上才集中core dump了幾十次。這樣算來,這個core dump的概率至少是億分之一了。 不過和麪試題目中多線程不同的是,nginx採用的是多進程+全異步事件驅動的編程模式(目前也支持了多線程,但只是針對IO的優化,核心機制還是多進程加異步)。在webserver的實現背景下,多進程異步相比多線程的優點是性能高,沒有太多線程間的切換,而且內存空間獨立,省去線程間鎖的競爭。當然也有缺點,就是異步模式編程非常複雜,將一些邏輯上連續的事件從空間和時間切割,不符合人的正常思考習慣,出了問題後比較難追查。另外異步事件對網絡和操作系統的底層知識要求較高,稍不小心就容易挖坑。
  2. 第二個問題是高併發時nginx存在內存泄漏。在流量低的時候沒有問題,加大測試流量就會出現內存泄漏。
  3. 第三個問題,因爲我們對nginx和openssl的關鍵代碼都做了一些改造,希望提升它的性能。那麼如何找到性能熱點和瓶頸並持續優化呢?

其中第一和第二個問題的背景都是,只有併發上萬qps以上時纔有可能出現,幾百或者一兩千QPS時,程序沒有任何問題。

core dump的調試

首先說一下core的解決思路,主要是如下幾點:

  1. gdb及debug log定位,發現作用不大。
  2. 如何重現bug?
  3. 構造高併發壓力測試系統。
  4. 構造穩定的異常請求。

gdb及debug log效率太低

因爲有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 的真正原因

log debug的新嘗試

這時候強大的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)的調試日誌。

總體思路依然是在不明顯降低性能的前提下打印儘量詳細的調試日誌,遺憾的是,上述辦法還是不能幫助問題定位,當然了,在不斷的日誌調試中,對代碼和邏輯越來越熟悉。

bug如何重現?

這時候的調試效率已經很低了,幾萬QPS連續壓力測試,幾個小時纔出一次CORE,然後修改代碼,添加調試日誌。幾天過去了,毫無進展。所以必須要在線下構造出穩定的core dump環境,這樣才能加快debug效率。
雖然還沒有發現根本原因,但是發現了一個很可疑的地方:
出CORE比較集中,經常是在凌晨4,5點,早上7,8點的時候 dump幾十個CORE。

弱網絡環境的構造 traffic control

聯想到夜間有很多的網絡硬件調整及故障,我猜測這些core dump可能跟網絡質量相關。特別是網絡瞬時不穩定,很容易觸發BUG導致大量的CORE DUMP。
最開始我考慮過使用TC(traffic control)工具來構造弱網絡環境,但是轉念一想,弱網絡環境導致的結果是什麼?顯然是網絡請求的各種異常啊,所以還不如直接構造各種異常請求來複現問題。於是準備構造測試工具和環境,需要滿足兩個條件:

  1. 併發性能強,能夠同時發送數萬甚至數十萬級以上qps。
  2. 請求需要一定概率的異常。特別是TCP握手及SSL握手階段,需要異常中止。

traffic control是一個很好的構造弱網絡環境的工具,我之前用過測試SPDY協議性能。能夠控制網絡速率、丟包率、延時等網絡環境,作爲iproute工具集中的一個工具,由linux系統自帶。但比較麻煩的是TC的配置規則很複雜,facebook在tc的基礎上封裝成了一個開源工具apc,有興趣的可以試試。

WRK壓力測試工具

由於高併發流量時纔可能出core,所以首先就需要找一個性能強大的壓測工具。
WRK是一款非常優秀的開源HTTP壓力測試工具,採用多線程 + 異步事件驅動的框架,其中事件機制使用了redis的ae事件框架,協議解析使用了nginx的相關代碼。
相比ab(apache bench)等傳統壓力測試工具的優點就是性能好,基本上單臺機器發送幾百萬pqs,打滿網卡都沒有問題。
wrk的缺點就是隻支持HTTP類協議,不支持其他協議類測試,比如protobuf,另外數據顯示也不是很方便。

nginx的測試用法: wrk -t500 -c2000 -d30s https://127.0.0.1:8443/index.html

分佈式自動測試系統的構建

由於是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小結

構造好了上述高併發壓力異常測試系統,果然,幾秒鐘之內必然出CORE。有了穩定的測試環境,那bug fix的效率自然就會快很多。
雖然此時通過gdb還是不方便定位根本原因,但是測試請求已經滿足了觸發CORE的條件,打開debug調試日誌也能觸發core dump。於是可以不斷地修改代碼,不斷地GDB調試,不斷地增加日誌,一步步地追蹤根源,一步步地接近真相。
最終通過不斷地重複上述步驟找到了core dump的根本原因。其實在寫總結文檔的時候,core dump的根本原因是什麼已經不太重要,最重要的還是解決問題的思路和過程,這纔是值得分享和總結的。很多情況下,千辛萬苦排查出來的,其實是一個非常明顯甚至愚蠢的錯誤。
比如這次core dump的主要原因是:
由於沒有正確地設置non-reusable,併發量太大時,用於異步代理計算的connection結構體被nginx回收並進行了初始化,從而導致不同的事件中出現NULL指針並出CORE。

內存泄漏

雖然解決了core dump,但是另外一個問題又浮出了水面,就是高併發測試時,會出現內存泄漏,大概一個小時500M的樣子

valgrind的缺點

出現內存泄漏或者內存問題,大家第一時間都會想到valgrind
valgrind是一款非常優秀的軟件,不需要重新編譯程序就能夠直接測試。功能也非常強大,能夠檢測常見的內存錯誤包括內存初始化、越界訪問、內存溢出、free錯誤等都能夠檢測出來。推薦大家使用。
valgrind 運行的基本原理是:
待測程序運行在valgrind提供的模擬CPU上,valgrind會紀錄內存訪問及計算值,最後進行比較和錯誤輸出
我通過valgrind測試nginx也發現了一些內存方面的錯誤,簡單分享下valgrind測試nginx的經驗:

  1. nginx通常都是使用master fork子進程的方式運行,使用–trace-children=yes來追蹤子進程的信息
  2. 測試nginx + openssl時,在使用rand函數的地方會提示很多內存錯誤。比如Conditional jump or move depends on uninitialised value,Uninitialised value was created by a heap allocation等。這是由於rand數據需要一些熵,未初始化是正常的。如果需要去掉valgrind提示錯誤,編譯時需要加一個選項:-DPURIFY
  3. 如果nginx進程較多,比如超過4個時,會導致valgrind的錯誤日誌打印混亂,儘量減小nginx工作進程,保持爲1個。因爲一般的內存錯誤其實和進程數目都是沒有關係的。

上面說了valgrind的功能和使用經驗,但是valgrind也有一個非常大的缺點,就是它會顯著降低程序的性能,官方文檔說使用memcheck工具時,降低10-50倍
也就是說,如果nginx完全握手性能是20000 qps,那麼使用valgrind測試,性能就只有400 qps左右。對於一般的內存問題,降低性能沒啥影響,但是我這次的內存泄漏是在大壓力測試時纔可能遇到的,如果性能降低這麼明顯,內存泄漏的錯誤根本檢測不出來。
只能再考慮其他辦法了。

AddressSanitizer的優點

address sanitizer(簡稱asan)是一個用來檢測c/c++程序的快速內存檢測工具。相比valgrind的優點就是速度快,官方文檔介紹對程序性能的降低只有2倍。
對Asan原理有興趣的同學可以參考asan的算法這篇文章,它的實現原理就是在程序代碼中插入一些自定義代碼,如下:

編譯前:  
*address = ...;  //   or: ... = *address; 
編譯後:   
if (IsPoisoned(address)) { 
    ReportError(address, kAccessSize, kIsWrite);  
}
*address = ...;  // or: ... = *address;`

和valgrind明顯不同的是,asan需要添加編譯開關重新編譯程序,好在不需要自己修改代碼。而valgrind不需要編程程序就能直接運行。
address sanitizer集成在了clang編譯器中,GCC 4.8版本以上才支持。我們線上程序默認都是使用gcc4.3編譯,於是我測試時直接使用clang重新編譯nginx:

--with-cc="clang" \ 
--with-cc-opt="-g -fPIC -fsanitize=address -fno-omit-frame-pointer" 
其中with-cc是指定編譯器,with-cc-opt指定編譯選項, -fsanitize=address就是開啓AddressSanitizer功能。

由於AddressSanitizer對nginx的影響較小,所以大壓力測試時也能達到上萬的併發,內存泄漏的問題很容易就定位了。
這裏就不詳細介紹內存泄漏的原因了,因爲跟openssl的錯誤處理邏輯有關,是我自己實現的,沒有普遍的參考意義。
最重要的是,知道valgrind和asan的使用場景和方法,遇到內存方面的問題能夠快速修復。

性能熱點分析

到此,經過改造的nginx程序沒有core dump和內存泄漏方面的風險了。但這顯然不是我們最關心的結果(因爲代碼本該如此),我們最關心的問題是:

  1. 代碼優化前,程序的瓶頸在哪裏?能夠優化到什麼程度?
  2. 代碼優化後,優化是否徹底?會出現哪些新的性能熱點和瓶頸?
    這個時候我們就需要一些工具來檢測程序的性能熱點。

perf,oprofile,gprof,systemtap

linux世界有許多非常好用的性能分析工具,我挑選幾款最常用的簡單介紹下:

  1. perf 應該是最全面最方便的一個性能檢測工具。由linux內核攜帶並且同步更新,基本能滿足日常使用。推薦大家使用
  2. oprofile,我覺得是一個較過時的性能檢測工具了,基本被perf取代,命令使用起來也不太方便。比如opcontrol --no-vmlinux , opcontrol --init等命令啓動,然後是opcontrol --start, opcontrol --dump, opcontrol -h停止,opreport查看結果等,一大串命令和參數。有時候使用還容易忘記初始化,數據就是空的。
  3. gprof 主要是針對應用層程序的性能分析工具,缺點是需要重新編譯程序,而且對程序性能有一些影響。不支持內核層面的一些統計,優點就是應用層的函數性能統計比較精細,接近我們對日常性能的理解,比如各個函數時間的運行時間,,函數的調用次數等,很人性易讀。
  4. systemtap 其實是一個運行時程序或者系統信息採集框架,主要用於動態追蹤,當然也能用做性能分析,功能最強大,同時使用也相對複雜。不是一個簡單的工具,可以說是一門動態追蹤語言。如果程序出現非常麻煩的性能問題時,推薦使用 systemtap。

這裏再多介紹一下perf命令,tlinux系統上默認都有安裝,比如通過perf top就能列舉出當前系統或者進程的熱點事件,函數的排序。
perf record能夠紀錄和保存系統或者進程的性能事件,用於後面的分析,比如接下去要介紹的火焰圖。

火焰圖 flame graph

perf有一個缺點就是不直觀。火焰圖就是爲了解決這個問題。它能夠以矢量圖形化的方式顯示事件熱點及函數調用關係。
比如我通過如下幾條命令就能繪製出原生nginx在ecdhe_rsa cipher suite下的性能熱點:

  1. perf record -F 99 -p PID -g – sleep 10
  2. perf script | ./stackcollapse-perf.pl > out.perf-folded
  3. ./flamegraph.pl out.perf-folded>ou.svg

在這裏插入圖片描述

直接通過火焰圖就能看到各個函數佔用的百分比,比如上圖就能清楚地知道rsaz_1024_mul_avx2和rsaz_1024_sqr_avx2函數佔用了75%的採樣比例。那我們要優化的對象也就非常清楚了,能不能避免這兩個函數的計算?或者使用非本地CPU方案實現它們的計算?
當然是可以的,我們的異步代理計算方案正是爲了解決這個問題,在這裏插入圖片描述
從上圖可以看出,熱點事件裏已經沒有RSA相關的計算了。至於是如何做到的,後面有時間再寫專門的文章來分享。

心態

爲了解決上面提到的core dump和內存泄漏問題,花了大概三週左右時間。壓力很大,精神高度緊張, 說實話有些狼狽,看似幾個很簡單的問題,搞了這麼長時間。心裏當然不是很爽,會有些着急,特別是項目的關鍵上線期。但即使這樣,整個過程我還是非常自信並且鬥志昂揚。我一直在告訴自己:
調試BUG是一次非常難得的學習機會,不要把它看成是負擔。不管是線上還是線下,能夠主動地,高效地追查BUG特別是有難度的BUG,對自己來說一次非常寶貴的學習機會。面對這麼好的學習機會,自然要充滿熱情,要如飢似渴,回首一看,如果不是因爲這個BUG,我也不會對一些工具有更深入地瞭解和使用,也就不會有這篇文檔的產生。
不管什麼樣的BUG,隨着時間的推移,肯定是能夠解決的。這樣想想,其實會輕鬆很多,特別是接手新項目,改造複雜工程時,由於對代碼,對業務一開始並不是很熟悉,需要一個過渡期。但關鍵是,你要把這些問題放在心上。白天上班有很多事情干擾,上下班路上,晚上睡覺前,大腦反而會更加清醒,思路也會更加清晰。特別是白天上班時容易思維定勢,陷入一個長時間的誤區,在那裏調試了半天,結果大腦一片混沌。睡覺前或者上下班路上一個人時,反而能想出一些新的思路和辦法。
開放地討論。遇到問題不要不好意思,不管多簡單,多低級,只要這個問題不是你google一下就能得到的結論,大膽地,認真地和組內同事討論。這次BUG調試,有幾次關鍵的討論給了我很大的啓發,特別是最後reusable的問題,也是組內同事的討論才激發了我的靈感。謝謝大家的幫助。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章