你是一個成熟的程序員了,是時候學習面向故障編程了(程序員問題)
作為程序員,最大的噩夢,可能就是下班時間,當我正在開心的浪著,突然傳來一陣急促的鈴聲,運維的同事說系統不行了,我必須馬上上線幫忙搶救...... 之前還看過一個更慘烈的新聞,有一位程序員新郎,在自己的婚禮上,還不得不上線維護系統......

等你好不容易折騰了半天,終于把系統穩定住了,還沒來得及喘口氣,老板就頂著一張黑臉,發出了靈魂的拷問:為什么測試的時候沒發生問題,生產環境里卻出了故障?
這是一個價值百萬的問題。我來試著幫你回答一下:
功能測試只覆蓋了正面測試(positive test),而忽略了負面測試(negative test)
整合測試沒有覆蓋到的某個在生產環境中引起故障的外部系統
沒有進行壓力測試,或者壓力測試的程度與生產環境情況相差過大
這個清單還可以一直寫下去。你不妨檢視一下你自己的測試環境和測試設計,相信你還會發現更多的不足。
老板看著這個清單,臉色越來越黑。接下來,他問出了更扎心的問題:今后怎么避免類似的故障再發生?
你眼睛一亮,舉起你剛剛列出的測試環境缺點清單,向老板保證你會把清單上每一個缺點都改正過來!
然而,這真的是最好的解決方法嗎?
加強測試覆蓋度是非常值得提倡的做法。但是,這未必能避免生產環境中故障的發生。因為測試環境終歸和生產環境不同。如果你只是為了能通過測試而進行開發,那你的產品上線之后,注定要暴露于新的風險之下。
今天,我們就要來探討一下,除了依賴測試,開發人員還有什么更好的辦法來打造更加健壯的系統。
首先,我們來思考一下,故障是什么?今天我們不去探討bug,因為理論上來說,有bug的系統根本不能通過基本測試,也就不會被部署到生產環境。如果任何bug侵入到了生產環境,造成了服務的中斷或系統事故,那這個責任必然要由開發人員和測試人員一起承擔。
今天我們想要探討的,是在生產環境中,常常被歸因于"外部"因素或者環境因素所造成的故障。比如配置不正確的防火墻屏蔽了系統發送的請求。比如其他客戶大量訪問數據庫,從而阻塞了我的系統發出的數據庫請求。比如上游系統發生故障,突然發送了海量的垃圾消息等等。
遇上這樣的故障,我們會說,"真是倒了霉了","今天運氣不好","天有不測風云"。也就是說,我們認為在生產環境中遇到故障是種"異常"情況,所以我們的系統才無法"正常"運行。
今天,我要扭轉這個觀點,在一個成熟的程序員眼里,生產環境中的"故障"才是"真實","異常"才是"正常"。墨菲定律告訴我們:有可能出錯的地方,就一定會出錯。在生產環境中,有可能發生故障的地方,早晚都會發生故障。作為開發人員,我們能做的,就是利用各種設計模式和技巧,主動積極地去正視故障,處理故障,修復故障,將故障殺死于襁褓之中。這種思想,就叫做面向故障編程。
大家跳過的坑,都是相似的。讓我們開門見山,來看一看常見的故障模式,和它們所對應的解決方法:
系統中最薄弱,最容易引起故障的地方,就是系統中的"連接點",或者叫做"集成點"。任意socket/進程/管道/遠程程序之間發送的請求和數據都有可能(所以早晚會)發生故障,從而造成系統的阻塞或崩潰。讓我們仔細觀察一下幾種有代表性的"交接點"
目前,大部分高級通信協議都依賴于下層的tcp協議以及socket連接來實現通信。說到tcp協議,大家都很了解,"三次握手"也是耳熟能詳
客戶端發送SYN到服務器監聽端口以發起連接請求,如果此時沒有進程正在監聽這個端口,服務器就會返回TCP reset以中止此次連接請求。而如果服務器端進程正在監聽此端口,服務器就會返回SYN/ACK表示接受連接請求。客戶端收到之后,再發送ACK,到此為止,新的連接就建立起來了。
可是在生產環境中,事情卻沒有這么簡單。如果客戶端與服務器端之間存在一道防火墻呢?
由于測試環境往往是100%的內部環境,我們幾乎從沒在測試環境下遭遇類似的情境。防火墻就像一個路由。根據內部配置,防火墻每次見到SYN請求,都會決定究竟要允許(即正常轉發SYN請求去目標服務器端口),還是阻攔(即返回tcp reset消息),或是忽略(既不轉發消息,也不返回任何消息)。而一旦防火墻決定允許一個SYN請求通過,就會把這個允許通過的連接記錄在內部的列表中,今后遇到這條連接上發送的消息,就不必再做額外的考察,直接放行。聽上去沒有什么問題吧~
但是,防火墻內部的連接列表并不是無限增長的。當某個列表中的連接長時間處于閑置狀態(無數據傳輸),防火墻會把這個連接從列表中移除。可是,防火墻并不會像普通的路由那樣,發送任何reset消息來提示連接兩端的socket。所以客戶端和服務器端都以為兩者之間的連接還是有效的。【提問:為什么防火墻不能發送一個reset消息作為清除緩存連接的提示呢?回答:因為這樣的reset消息有可能被惡意用戶利用,從而威脅到系統安全性】只是,當它們互相之間試圖繼續發送消息時,這些消息會被防火墻無情的忽略掉(既不放行,也不返回reset)。此時的防火墻,完全成為了一個網絡黑洞,默默地吃掉了這條連接上發送的數據。
作為發送消息的一方,由于消息被防火墻吃掉,所以無法收到ACK。于是TCP協議就會要求重新發送這條消息,然后又被防火墻吃掉。。。這樣周而復始,直到超過os內核鎖預設的TCP重試次數最大值,才會拋出錯誤。一般內核設定的TCP重試最大值在15左右,這可能導致長達20分鐘以上的重試時間!
而接收消息的一方更慘。它只能徒勞的等待黑洞那里傳來任何數據(這當然是不可能的)。如果接收方是以阻塞式調用來進行讀取數據的操作,那么理論上來說,這個接收操作可能永遠地被阻塞下去.......
這還只是我們為了向大家說明情況,講解的一個單個連接被阻塞的情境。在生產環境中,如果我們把例子中的客戶端換成一個常用的連接池,流量大的繁忙時段,連接池里的所有有效連接都在不停的發送數據,所以不會造成防火墻移除超時連接的狀況發生。到了夜晚流量變少,連接池中絕大部分連接都會長時間閑置,導致防火墻大量的移除這些超時連接。然后第二天一早,系統的流量又上來了,連接池中的所有連接都被取出用來發送數據,而這些數據全部被防火墻吞掉...... 此時你的系統會出現大面積的無響應警告,畫面太美......
估計此時你也已經接到運維小伙伴的電話了。而更糟的是,當你查看連接池這邊的客戶端進程,發現一切正常。。。當你查看服務器端的進程時,也是一切正常。。。網絡本身也是正常狀態。。。由于防火墻往往是由網絡安全團隊設置的,有些業務開發人員可能根本不知道防火墻的存在。。。于是這個問題會成為一個懸案,往往最終都是由運維團隊重啟系統來解決。
這個故障情境,幾乎不可能通過提升測試覆蓋度來檢測。我們只能在開發階段主動的去規避這些可能發生(所以早晚會發生)的連接層面的故障。針對系統中這些容易產生連接故障的"連接點",我們給大家推薦兩個最常見的方法來降低故障帶來的影響,從而提示系統的穩定性。
第一個方法就是Timeout。Timeout的原理很簡單。為了避免連接故障造成請求方和應答方陷入長時間的阻塞,一旦發送的請求超過一定時間還沒有返回結果(不管是成功還是失敗的結果),我們就中止這個請求。這樣我們才可以及時的發現失敗的連接。由于現代系統大量使用分布式結構,系統中的"連接點"不再是一個兩個,而是相當大的一個數字(尤其是微服務架構),還會不斷增加。系統中常見的問題就是Timeout機制的缺失。我會建議大家將Timeout的邏輯包裝成一個可復用的實現。這樣就可以一次實現,到處調用,減少了代碼重復性。同時,也可以降低其他開發人員使用Timeout的難度,促使大家多多使用Timeout來保護系統。
另一個常常和Timeout搭配使用的方法,就是Circuit Breaker(翻譯成熔斷器吧)。這個詞本身的意思,就是指電路中的保險絲,在電流過大時,熔斷自己,保護整條電路的安全。當我們的請求長時間無響應,導致Timeout之后,我們需要怎樣處理這個未完成的請求呢?大家的第一個反應一定是重試,也許剛剛應答方太忙,所以才不能及時處理我們的請求。我再試一次,也許應答方就可以答復了呢。但是我勸你謹慎。因為作為開發人員,我們無法猜測導致請求Timeout的原因。如果是之前所講的防火墻黑洞的例子,那你重試一輩子也是沒用的。即便造成請求Timeout的原因的確是暫時性的,可修復的,比如是因為應答方暫時繁忙所造成的,你也要注意重試的頻率和次數。如果盲目的頻繁大量重試,只會給應答方造成更大的流量壓力,不但對你自己的請求沒幫助,還間接影響了整個系統的穩定性。
而Circuit Breaker是一個可以幫到你的設計模式。你可以為有可能Timeout的操作添加一個Circuit Breaker,在初始狀態下,Circuit Breaker處于連接的狀態(保險絲完好,電路連通),我們要求Circuit Breaker發送的請求,都會被正常的發送出去。而當后續的請求開始出現Timeout或請求的失敗的狀況時,Circuit Breaker會記錄下失敗的次數或者頻率。當失敗次數或頻率超過一個閾值時,Circuit Breaker就會轉換到斷開狀態(保險絲熔斷,電路斷開)。此時,Circuit Breaker不會執行任何新的請求,而是在接到請求之后立即返回一個錯誤,告知請求的發起方,目前連接不正常,請等一等再嘗試。在經過一段時間的熔斷之后(這里又用到了Timeout機制),Circuit Breaker會轉換到一個特殊的"半連接"狀態。此時Circuit Breaker會把收到的請求發送出去,如果發送成功,那么Circuit Breaker會馬上轉入連接狀態,恢復正常工作。而如果這次請求發送失敗或再次Timeout,Circuit Breaker就會立刻轉回斷開狀態,直到斷開狀態再次Timeout。
由此可見,Circuit Breaker這種機制,就是在連接故障原因未知的情況下,試圖用一種"聰明"的策略來自動調整"連接點"的流量,以便在系統穩定性和可恢復性之間取得一個平衡。當我們面向故障編程時,一個很大的困難就是故障的未知性。在開發層面,我們很難去判斷故障產生的原因。所以我們不得不"戴著腳鐐跳舞",在未知的情況下選擇最好的策略。Circuit Breaker機制就是一個很好的例子。
另一個可能給生產環境帶來可怕后果的故障和集群有關。在現代系統設計中,為了增強可用性,或是為了增強可擴展性以應付更大的流量,我們往往在一個集群中運行多個服務進程,然后在集群上通過一個Load Balancer,負載均衡器,將發送到集群上的請求盡量平均的分配到集群中的各個服務進程上面去。
這種集群架構,在大多數情況下,可以很有效地幫助我們提高整個系統的健壯度,因為我們有"備胎"了,我們集群里有的是節點,所有節點一起死光光的概率是很小的啊。沒錯,這個假設通常是對的。可是,這個架構對于某一種故障非常敏感。那就是在大流量壓力下所造成的節點崩潰。如果我們在服務進程的實現中有個缺陷,會造成內存泄漏。那么,當整個集群的流量增大時,每一個節點上分擔的流量也很大,大流量可能加快內存泄漏的速度,使得某一個節點因為系統資源耗盡而崩潰。然后會發生什么呢?由于集群中少了一個節點,其他節點就必須分擔更多的流量。別忘了,大部分節點運行的都是同樣版本的服務。所以這個要命的內存泄漏很可能存在于所有節點中。于是剩下的節點在承受了更大的流量之后,也會更容易耗盡系統資源而崩潰,然后留下更少的節點分別承擔更大的流量...... 顯而易見,這是一個惡性循環。從第一個節點崩潰開始,這個故障可能像洪水一樣,迅速蔓延至整個集群從而導致整個集群崩潰。我們管這種故障叫做連鎖反應故障。
聽起來有點可怕,是不是?那么我們怎么才能規避這個故障呢?
首先,改進你的測試方法,盡量在測試環境中發現類似內存泄漏,或是潛在死鎖這樣的bug是很重要的。因為這些bug都對流量很敏感。一旦把這種bug部署到生產環境,無論進程是否跑在集群中,都相當于在生產環境中埋了一個地雷。越是需要系統保持穩定的大流量情境,這個地雷越容易爆炸。
不過正如我們一開始所說的,bug不是我們今天談論的重點。寫了幾萬行代碼,難免包含幾個bug...... 即使這樣的bug存在,我們還有可以規避連鎖反應故障的方法。
首先,如果集群的上游系統總是在一次請求失敗之后,就瘋狂地向集群發送重試請求,那么集群的流量壓力很快就被搞大了。如果說大流量是由可以產生利潤的用戶請求所帶來的,那我們愿意承受。而如果流量是因為愚蠢的系統重試請求所造成的,并且還把我們的集群搞垮了,那就太不值得了。所以, 在開發中使用上面介紹的Circuit Breaker機制,不只可以保護你正在開發的服務組件,還很有可能間接的保護了下游的其他服務組件。你在開發上做的一點額外努力,可能拯救了整個系統。相反的,你在開發上偷的一點懶,可能會坑死下游的兄弟團隊呢
除此之外,最有效的規避連鎖反應故障的方法,就是實現一個可以自動伸縮的集群。尤其是當你在cloud容器上運行節點時,這樣的自動伸縮集群功能就更容易實現了。當集群中的一個節點崩潰,我們最好盡快自動的啟動一個新節點(或者重啟崩潰的節點)。這樣至少可以在短時間內,盡量保證集群的尺寸不要萎縮的太厲害,從而避免了集群中余下的節點承擔(它們這個年齡無法承受的)急劇增大的流量負擔。
到此為止,我們梳理了連接點故障,和連鎖反應故障兩種在生產環境中可能造成嚴重影響的故障,以及Timeout,Circuit Breaker,自動伸縮集群這三種可以用來主動規避故障的設計模式。這只是面向故障編程思想中的冰山一小角。如果今后有機會,希望還可以和大家繼續探討其他的模式和技巧。
今天說了這么多,其實最重要的一點,就是希望大家扭轉思想,明白故障才是生產環境中的"正常",一個零故障的生產環境是不存在的。產品經理也許不會把規避這些故障作為產品需求寫在文檔中,但是作為開發人員,我們自己要做到心中有數,其實這些都是一個優秀的系統所應該實現的隱形的需求。
面向故障編程是一種思維方式。對于有志于成為架構師的小伙伴,這更是非常重要的一種思維方式。總而言之,你是一個成熟的程序員了,是時候學習面向故障編程的思想了。希望今天的分享能帶給大家一些有關的思考。
本文來自:“IT大咖說”
閱讀原文:點擊
開發者 程序員 軟件開發 運維
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。