4 月份的時候看到一道面試題,聽說是騰訊校招面試官提的:在多線程和高併發環境下,若是有一個平均運行一百萬次纔出現一次的 bug,你如何調試這個 bug?知乎原貼地址以下:騰訊實習生面試,這兩道題目該怎麼回答? – 編程html
遺憾的是知乎不少答案在抨擊這道題自己的正確性,雖然我不是此次的面試官,但我認爲這是一道很是好的面試題。固然,只是道加分題,答不上,不扣分。答得不錯,說明解決問題的思路和能力要超過應屆平生均水平。linux
之因此寫上面這段,是由於我以爲大部分後臺服務端開發都有可能遇到這樣的 BUG,即便沒有遇到,這樣的題目也可以激發你們不斷思考和總結。很是湊巧的是,我在 4 月份也遇到了一個相似的並且要更加嚴重的 BUG,這是我本身挖的一個很深的坑,不填好,整個項目就沒法上線。如今已通過去了一個多月,趁着有時間,本身好好總結一下,但願裏面提到的一些經驗和工具可以帶給你們一點幫助。nginx
咱們針對 nginx 事件框架和 openssl 協議棧進行了一些深度改造,以提高 nginx 的 HTTPS 徹底握手計算性能。c++
因爲原生 nginx 使用本地 CPU 作 RSA 計算,ECDHE_RSA 算法的單核處理能力只有 400 qps 左右。前期測試時的併發性能很低,就算開了 24 核,性能也沒法超過 1 萬。web
核心功能在去年末就完成了開發,線下測試也沒有發現問題。通過優化後的性能提高几倍,爲了測試最大性能,使用了不少客戶端併發測試 https 性能。很快就遇到了一些問題:面試
1.第一個問題是 nginx 有極低機率(億分之一)在不一樣地方 core dump。白天線下壓力測試 2W qps 通常都要兩三個小時纔出一次 core。每次晚上睡覺以前都會將最新的調試代碼編譯好並啓動測試,到早上醒來第一眼就會去查看機器並祈禱不要出 core,不幸的是,通常都會有幾個到幾十個 core,而且會發現常常是在一個時間點集中 core dump。線上灰度測試運行了 6 天,在第 6 天的早上才集中 core dump 了幾十次。這樣算來,這個 core dump 的機率至少是億分之一了。 不過和麪試題目中多線程不一樣的是,nginx 採用的是多進程 + 全異步事件驅動的編程模式(目前也支持了多線程,但只是針對 IO 的優化,核心機制仍是多進程加異步)。在 webserver 的實現背景下,多進程異步相比多線程的優勢是性能高,沒有太多線程間的切換,並且內存空間獨立,省去線程間鎖的競爭。固然也有缺點,就是異步模式編程很是複雜,將一些邏輯上連續的事件從空間和時間切割,不符合人的正常思考習慣,出了問題後比較難追查。另外異步事件對網絡和操做系統的底層知識要求較高,稍不當心就容易挖坑。redis
2.第二個問題是高併發時 nginx 存在內存泄漏。在流量低的時候沒有問題,加大測試流量就會出現內存泄漏。算法
3.第三個問題,由於咱們對 nginx 和 openssl 的關鍵代碼都作了一些改造,但願提高它的性能。那麼如何找到性能熱點和瓶頸並持續優化呢?apache
其中第一和第二個問題的背景都是,只有併發上萬 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,另外數據顯示也不是很方便。
nginx 的測試用法: wrk -t500 -c2000 -d30s https://127.0.0.1:8443/index.html
因爲是 HTTPS 請求,使用 ECDHE_RSA 密鑰交換算法時,客戶端的計算消耗也比較大,單機也就 10000 多 qps。也就是說若是 server 的性能有 3W qps,那麼一臺客戶端是沒法發送這麼大的壓力的,因此須要構建一個多機的分佈式測試系統,即經過中控機同時控制多臺測試機客戶端啓動和中止測試。
以前也提到了,調試效率過低,整個測試過程須要可以自動化運行,好比晚上睡覺前,能夠控制多臺機器在不一樣的協議,不一樣的端口,不一樣的 cipher suite 運行整個晚上。白天由於一直在盯着,運行幾分鐘就須要查看結果。 這個系統有以下功能:
併發控制多臺測試客戶端的啓停,最後彙總輸出總的測試結果。
支持 https,http 協議測試,支持 webserver 及 revers proxy 性能測試。
支持配置不一樣的測試時間、端口、URL。
根據端口選擇不一樣的 SSL 協議版本,不一樣的 cipher suite。
根據 URL 選擇 webserver、revers proxy 模式。
壓力測試工具和系統都準備好了,仍是不能準確復現 core dump 的環境。接下來還要完成異常請求的構造。構造哪些異常請求呢?因爲新增的功能代碼主要是和 SSL 握手相關,這個過程是緊接着 TCP 握手發生的,因此異常也主要發生在這個階段。因而我考慮構造了以下三種異常情形:
異常的 tcp 鏈接。即在客戶端 tcp connent 系統調用時,10% 機率直接 close 這個 socket。
異常的 ssl 鏈接。考慮兩種狀況,full handshake 第一階段時,即發送 client hello 時,客戶端 10% 機率直接 close 鏈接。full handshake 第二階段時,即發送 clientKeyExchange 時,客戶端 10% 機率直接直接關閉 TCP 鏈接。
異常的 HTTPS 請求,客戶端 10% 的請求使用錯誤的公鑰加密數據,這樣 nginx 解密時確定會失敗。
構造好了上述高併發壓力異常測試系統,果真,幾秒鐘以內必然出 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 是一款很是優秀的軟件,不須要從新編譯程序就可以直接測試。功能也很是強大,可以檢測常見的內存錯誤包括內存初始化、越界訪問、內存溢出、free 錯誤等都可以檢測出來。推薦你們使用。
valgrind 運行的基本原理是:待測程序運行在 valgrind 提供的模擬 CPU 上,valgrind 會紀錄內存訪問及計算值,最後進行比較和錯誤輸出。我經過 valgrind 測試 nginx 也發現了一些內存方面的錯誤,簡單分享下 valgrind 測試 nginx 的經驗:
nginx 一般都是使用 master fork 子進程的方式運行,使用–trace-children=yes 來追蹤子進程的信息
測試 nginx + openssl 時,在使用 rand 函數的地方會提示不少內存錯誤。好比 Conditional jump or move depends on uninitialised value,Uninitialised value was created by a heap allocation 等。這是因爲 rand 數據須要一些熵,未初始化是正常的。若是須要去掉 valgrind 提示錯誤,編譯時須要加一個選項:-DPURIFY
若是 nginx 進程較多,好比超過 4 個時,會致使 valgrind 的錯誤日誌打印混亂,儘可能減少 nginx 工做進程, 保持爲 1 個。由於通常的內存錯誤其實和進程數目都是沒有關係的。
上面說了 valgrind 的功能和使用經驗,可是 valgrind 也有一個很是大的缺點,就是它會顯著下降程序的性能,官方文檔說使用 memcheck 工具時,下降 10-50 倍。也就是說,若是 nginx 徹底握手性能是 20000 qps, 那麼使用 valgrind 測試,性能就只有 400 qps 左右。對於通常的內存問題,下降性能沒啥影響,可是我此次的內存泄漏是在大壓力測試時纔可能遇到的,若是性能下降這麼明顯,內存泄漏的錯誤根本檢測不出來。
address sanitizer(簡稱 asan)是一個用來檢測 c/c++ 程序的快速內存檢測工具。相比 valgrind 的優勢就是速度快,官方文檔介紹對程序性能的下降只有 2 倍。對 Asan 原理有興趣的同窗能夠參考 asan 的算法這篇文章,它的實現原理就是在程序代碼中插入一些自定義代碼,以下:
和 valgrind 明顯不一樣的是,asan 須要添加編譯開關從新編譯程序,好在不須要本身修改代碼。而 valgrind 不須要編程程序就能直接運行。
address sanitizer 集成在了 clang 編譯器中,GCC 4.8 版本以上才支持。咱們線上程序默認都是使用 gcc4.3 編譯,因而我測試時直接使用 clang 從新編譯 nginx:
因爲 AddressSanitizer 對 nginx 的影響較小,因此大壓力測試時也能達到上萬的併發,內存泄漏的問題很容易就定位了。這裏就不詳細介紹內存泄漏的緣由了,由於跟 openssl 的錯誤處理邏輯有關,是我本身實現的,沒有廣泛的參考意義。最重要的是,知道 valgrind 和 asan 的使用場景和方法,遇到內存方面的問題可以快速修復。
到此,通過改造的 nginx 程序沒有 core dump 和內存泄漏方面的風險了。但這顯然不是咱們最關心的結果(由於代碼本該如此),咱們最關心的問題是:
代碼優化前,程序的瓶頸在哪裏?可以優化到什麼程度?
代碼優化後,優化是否完全?會出現哪些新的性能熱點和瓶頸?
這個時候咱們就須要一些工具來檢測程序的性能熱點。
perf,oprofile,gprof,systemtap
linux 世界有許多很是好用的性能分析工具,我挑選幾款最經常使用的簡單介紹下:
[perf](Perf Wiki) 應該是最全面最方便的一個性能檢測工具。由 linux 內核攜帶而且同步更新,基本能知足平常使用。** 推薦你們使用 **。
oprofile,我以爲是一個較過期的性能檢測工具了,基本被 perf 取代,命令使用起來也不太方便。好比 opcontrol –no-vmlinux , opcontrol –init 等命令啓動,而後是 opcontrol –start, opcontrol –dump, opcontrol -h 中止,opreport 查看結果等,一大串命令和參數。有時候使用還容易忘記初始化,數據就是空的。
gprof主要是針對應用層程序的性能分析工具,缺點是須要從新編譯程序,並且對程序性能有一些影響。不支持內核層面的一些統計,優勢就是應用層的函數性能統計比較精細,接近咱們對平常性能的理解,好比各個函數時間的運行時間,,函數的調用次數等,很人性易讀。
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.erf 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 方案實現它們的計算?
固然是能夠的,咱們的異步代理計算方案正是爲了解決這個問題,
心態
爲了解決上面提到的 core dump 和內存泄漏問題,花了大概三週左右時間。壓力很大,精神高度緊張, 說實話有些狼狽,看似幾個很簡單的問題,搞了這麼長時間。內心固然不是很爽,會有些着急,特別是項目的關鍵上線期。但即便這樣,整個過程我仍是很是自信而且鬥志昂揚。我一直在告訴本身:
1.調試 BUG 是一次很是可貴的學習機會,不要把它當作是負擔。無論是線上仍是線下,可以主動地,高效地追查 BUG 特別是有難度的 BUG,對本身來講一次很是寶貴的學習機會。面對這麼好的學習機會,天然要充滿熱情,要如飢似渴,回首一看,若是不是由於這個 BUG,我也不會對一些工具備更深刻地瞭解和使用,也就不會有這篇文檔的產生。
2.無論什麼樣的 BUG,隨着時間的推移,確定是可以解決的。這樣想一想,其實會輕鬆不少,特別是接手新項目,改造複雜工程時,因爲對代碼,對業務一開始並非很熟悉,須要一個過渡期。但關鍵是,你要把這些問題放在心上。白天上班有不少事情干擾,上下班路上,晚上睡覺前,大腦反而會更加清醒,思路也會更加清晰。特別是白天上班時容易思惟定勢,陷入一個長時間的誤區,在那裏調試了半天,結果大腦一片混沌。睡覺前或者上下班路上一我的時,反而能想出一些新的思路和辦法。
3.開放地討論。遇到問題不要很差意思,無論多簡單,多低級,只要這個問題不是你 google 一下就能獲得的結論,大膽地,認真地和組內同事討論。此次 BUG 調試,有幾回關鍵的討論給了我很大的啓發,特別是最後 reusable 的問題,也是組內同事的討論才激發了個人靈感。謝謝你們的幫助。
原本來源於:http://mp.weixin.qq.com/s?__biz=MzI5NjAxODQyMg==&mid=2676478541&idx=1&sn=60537ca932bc3577cc9a1118eaecdc4e&scene=0#rd