歡迎光臨
每天分享高質量文章

這幾招,讓服務的可用性提升到5個9

對每一個程式員而言,故障都是懸在頭上的達摩克利斯之劍,都唯恐避之不及,如何避免故障是每一個程式員都在苦苦追尋希望解決的問題。對於這一問題,大家都可以從需求分析、架構設計、代碼編寫、測試、code review、上線、線上服務運維等各個視角給出自己的答案。

我們大部分服務都是如下的結構,既要給使用方使用,又依賴於他人提供的第三方服務,中間又穿插了各種業務、演算法、資料等邏輯,這裡面每一塊都可能是故障的來源。如何避免故障?我用一句話概括 : 懷疑第三方,防備使用方,做好自己

1. 懷疑第三方

堅持一條信念:“所有第三方服務都不可靠”,不管第三方什麼天花亂墜的承諾。基於這樣的信念,我們需要有以下行動。

1.1 有兜底,制定好業務降級方案

如果第三方服務掛掉怎麼辦?我們業務也跟著掛掉?顯然這不是我們希望看到的結果,如果能制定好降級方案,那將大大提高服務的可靠性。舉幾個例子以便大家更好的理解。比如我們做個性化推薦服務時,需要從用戶中心獲取用戶的個性化資料,以便代入到模型里進行打分排序,但如果用戶中心服務掛掉,我們獲取不到資料了,那麼就不推薦了?顯然不行,我們可以在 cache 里放置一份熱門商品以便兜底。又比如做一個資料同步的服務,這個服務需要從第三方獲取最新的資料並更新到 mysql 中,恰好第三方提供了兩種方式:1)一種是訊息通知服務,只發送變更後的資料;2)一種是 HTTP 服務,需要我們自己主動呼叫獲取資料。我們一開始選擇訊息同步的方式,因為實時性更高,但是之後就遭遇到訊息遲遲發送不過來的問題,而且也沒什麼異常,等我們發現一天時間已過去,問題已然升級為故障。合理的方式應該兩個同步方案都使用,訊息方式用於實時更新,HTTP 主動同步方式定時觸發(比如1小時)用於兜底,即使訊息出了問題,通過主動同步也能保證一小時一更新。

有些時候第三方服務錶面看起來正常,但是傳回的資料是被污染的,這時還有什麼方法兜底嗎?有人說這個時候除了通知第三方快速恢復資料,基本只能幹等了。舉個例子,我們做移動端的檢索服務,其中需要呼叫第三方接口獲取資料來構建倒排索引,如果第三方資料出錯,我們的索引也將出錯,繼而導致我們的檢索服務篩選出錯誤的內容。第三方服務恢復資料最快要半小時,我們構建索引也需要半小時,即可能有超過 1 個多小時的時間檢索服務將不能正常使用,這是不可接受的。如何兜底呢?我們採取的方法是每隔一段時間儲存全量索引檔案快照,一旦第三方資料源出現資料污染問題,我們先按下停止索引構建的開關,並快速回滾到早期正常的索引檔案快照,這樣儘管資料不是很新(可能1小時之前),但是至少能保證檢索有結果,不至於對交易產生特別大的影響。

1.2 遵循快速失敗原則,一定要設置超時時間

某服務呼叫的一個第三方接口正常響應時間是 50 ms,某天該第三方接口出現問題,大約有 15% 的請求響應時間超過 2s,沒過多久服務 load 飆高到 10 以上,響應時間也非常緩慢,即第三方服務將我們服務拖垮了。為什麼會被拖垮?沒設置超時!我們採用的是同步呼叫方式,使用了一個執行緒池,該執行緒池裡最大執行緒數設置了 50,如果所有執行緒都在忙,多餘的請求就放置在佇列里中。如果第三方接口響應時間都是 50 ms 左右,那麼執行緒都能很快處理完自己手中的活,並接著處理下一個請求,但是不幸的是如果有一定比例的第三方接口響應時間為 2 s,那麼最後這 50 個執行緒都將被拖住,佇列將會堆積大量的請求,從而導致整體服務能力極大下降。

正確的做法是和第三方商量確定個較短的超時時間比如 200 ms,這樣即使他們服務出現問題也不會對我們服務產生很大影響。

1.3 適當保護第三方,慎重選擇重試機制

需要結合自己的業務以及異常來仔細斟酌是否使用重試機制。比如呼叫某第三方服務,報了個異常,有些同學就不管三七二十一就直接重試,這樣是不對的,比如有些業務傳回的異常表示業務邏輯出錯,那麼你怎麼重試結果都是異常;又如有些異常是接口處理超時異常,這個時候就需要結合業務來判斷了,有些時候重試往往會給後方服務造成更大壓力,啟到雪上加霜的效果。

2. 防備使用方

這裡又要堅持一條信念:“所有的使用方都不靠譜”,不管使用方什麼天花亂墜的保證。基於這樣的信念,我們需要有以下行動。

2.1 設計一個好的 API,避免誤用

過去兩年間看過不少故障,直接或間接原因來自於糟糕的接口。如果你的接口讓很多人誤用,那要好好反思自己的接口設計了,接口設計雖然看著簡單,但是學問很深,建議大家好好看看 Joshua Bloch 的演講《How to Design a Good API & Why it Matters(如何設計一個好的 API 及為什麼這很重要)》以及《Java API 設計清單》。

下麵簡單談談我的經驗:

  •  

    遵循接口最少暴露原則。使用方用多少接口我們就提供多少,因為提供的接口越多越容易出現亂用現象,言多必失嘛。此外接口暴露越多自己維護成本就越高。

     

  •  

    不要讓使用方做接口可以做的事情。如果使用方需要呼叫我們接口多次才能進行一個完整的操作,那麼這個接口設計就可能有問題。比如獲取資料的接口,如果僅僅提供 getData(int id) 接口,那麼使用方如果要一次性獲取 20 個資料,它就需要迴圈遍歷呼叫我們接口 20 次,不僅使用方性能很差,也無端增加了我們服務的壓力,這時提供 getDataList(List idList) 接口顯然是必要的。

     

  •  

    避免長時間執行的接口。還是以獲取資料方法為例:getDataList(List idList)。假設一個用戶一次傳 1w 個 id 進來,我們的服務估計沒個幾秒出不來結果,而且往往是超時的結果,用戶怎麼呼叫結果都是超時異常,那怎麼辦?限制長度,比如限制長度為 100,即每次最多只能傳 100 個 id,這樣就能避免長時間執行,如果用戶傳的 id 串列長度超過100就報異常。加了這樣限制後,必須要讓使用方清晰地知道這個方法有此限制。之前就遇到誤用的情況,某用戶一個訂單買了超過 100 個商品,該訂單服務需要呼叫商品中心接口獲取該訂單下所有商品的信息,但是怎麼呼叫都失敗,而且異常也沒打出什麼有價值的信息,後來排查好久才得知是商品中心接口做了長度限制。怎麼才能做到加了限制,又不讓用戶誤用呢?兩種思路:1)接口幫用戶做了分割呼叫操作,比如用戶傳了 1w 個 id,接口內部分割成 100 個 id 串列(每個長度 100),然後迴圈呼叫,這樣對使用方屏蔽了內部機制,對使用方透明;2)讓用戶自己做分割,自己寫迴圈顯示呼叫,這樣需要讓用戶知道我們方法做了限制,具體方法有:1)改變方法名,比如getDataListWithLimitLength(List idList); 2)增加註釋;3)如果長度超過 100,很明確地丟擲異常,很直白地進行告知。

     

  •  

    引數易用原則。避免引數長度太長,一般超過 3 個後就較難使用,那有人說了我引數就是這麼多,那怎麼辦?寫個引數類嘛!此外,避免連續的同型別的引數,不然很容易誤用。能用其它型別如 int 等的儘量不要用 String 型別,這也是避免誤用的方法。

     

  •  

    異常。接口應當最真實的反應出執行中的問題,更不能用聰明的代碼做某些特別處理。經常看到一些同學接口代碼里一個 try catch,不管內部拋了什麼異常,捕獲後傳回空集合。這讓使用方很無奈,很多時候不知道是自己引數傳的問題,還是服務方內部的問題,而一旦未知就可能誤用了。

     

  1. 1. public List<Integer> test() {
  2. 2.      try {
  3. 3.          ...
  4. 4. } catch (Exception e) {
  5. 5.          return Collections.emptyList();
  6. 6.      }
  7. 7. }

2.2 流量控制,按服務分配流量,避免濫用

相信很多做過高併發服務的同學都碰到類似事件:某天 A 君突然發現自己的接口請求量突然漲到之前的 10 倍,沒多久該接口幾乎不可使用,並引發連鎖反應導致整個系統崩潰。為什麼會漲 10 倍,難道是接口被外人攻擊了,以我的經驗看一般內部人“作案”可能性更大。之前還見過有同學 mapreduce job 呼叫線上服務,分分鐘把服務搞死。如何應對這種情況?生活給了我們答案:比如老式電閘都安裝了保險絲,一旦有人使用超大功率的設備,保險絲就會燒斷以保護各個電器不被強電流給燒壞。同理我們的接口也需要安裝上“保險絲”,以防止非預期的請求對系統壓力過大而引起的系統癱瘓,當流量過大時,可以採取拒絕或者引流等機制。具體限流演算法參見《接口限流實踐》一文。

2.3 做好自己

做好自己是個非常大的話題,從需求分析、架構設計 、代碼編寫、測試、code review、上線、線上服務運維等階段都可以重點展開介紹,這次簡單分享下架構設計、代碼編寫上的幾條經驗原則。

2.3.1 單一職責原則

對於工作了兩年以上的同學來說,設計樣式應該好好看看,我覺得各種具體的設計樣式其實並不重要,重要的是背後體現的原則。比如單一職責原則,在我們的需求分析、架構設計、編碼等各個階段都非常有指導意義。在需求分析階段,單一職責原則可以界定我們服務的邊界,如果服務邊界如果沒界定清楚,各種合理的不合理的需求都接,最後導致服務出現不可維護、不可擴展、故障不斷的悲哀結局。對於架構來講,單一職責也非常重要。比如讀寫模塊放置在一起,導致讀服務抖動非常厲害,如果讀寫分離那將大大提高讀服務的穩定性(讀寫分離);比如一個服務上同時包含了訂單、搜索、推薦的接口,那麼如果推薦出了問題可能影響訂單的功能,那這個時候就可以將不同接口拆分為獨立服務,並獨立部署,這樣一個出問題也不會影響其他服務(資源隔離);又比如我們的圖片服務使用獨立域名、並放置到cdn上,與其它服務獨立(動靜分離)。從代碼角度上講,一個類只乾一件事情,如果你的類幹了多個事情,就要考慮將他分開。這樣做的好處是非常清晰,以後修改起來非常方便,對其它代碼的影響就很小。再細粒度看類里的方法,一個方法也只乾一個事情,即只有一個功能,如果乾兩件事情,那就把它分開,因為修改一個功能可能會影響到另一個功能。

2.3.2 控制資源的使用

寫代碼腦子一定要繃緊一根弦,認知到我們所在的機器資源是有限的。機器資源有哪些?CPU、記憶體、網絡、磁盤等,如果不做好保護控制工作,一旦某一資源滿負荷,很容易導致出現線上問題。

2.3.2.1 CPU 資源怎麼限制
  • 計算演算法優化。如果服務需要進行大量的計算,比如推薦排序服務,那麼務必對你的計算演算法進行優化,比如筆者曾經對地理空間距離計算這一重度使用的演算法進行了優化,取得了較好的效果,詳見《地理空間距離計算優化》一文。
  • 鎖。對於很多服務而言,沒有那麼多耗費計算資源的演算法,但 CPU 使用率也很高,這個時候需要看看鎖的使用情況,我的建議是如無必要,儘量不用顯式使用鎖。
  • 習慣問題。比如寫迴圈的時候,千萬要檢查看看是否能正確退出,有些時候一不小心,在某些條件下就成為死迴圈,很著名的案例就是《多執行緒下HashMap的死迴圈問題》。比如集合遍歷時候使用性能較差的遍歷方式、String + 檢查,如果有超過多個 String 相加,是否使用 StringBuffer.append?
  • 儘量使用執行緒池。通過執行緒池來限制執行緒的數目,避免執行緒過多造成的執行緒背景關係切換的開銷。
  • JVM 引數調優。JVM 引數也會影響 CPU 的使用,如《發佈或重啟線上服務時抖動問題解決方案》。
2.3.2.2 記憶體資源怎麼限制
  • JVM 引數設置。通過 JVM 引數的設置來限制記憶體使用,JVM 引數調優比較靠經驗,有一篇朋友寫的好文可以參考《Linux 與 JVM 的記憶體關係分析》。
  • 初始化 Java 集合類大小。使用 Java 集合類的時候儘量初始化大小,在長連接服務等耗費記憶體資源的服務中這種優化非常重要。
  • 使用記憶體池/物件池
  • 使用執行緒池的時候一定要設置佇列的最大長度。之前看過好多起故障都是由於佇列最大長度沒有限制最後導致記憶體上限溢位。
  • 如果資料較大避免使用本地快取。如果資料量較大,可以考慮放置到分佈式快取如 Redis、Tair 等,不然 gc 都可能把自己服務卡死。
  • 對快取資料進行壓縮。比如之前做推薦相關服務時,需要儲存用戶偏好資料,如果直接儲存可能有 12G,後來採用短文本壓縮演算法直接壓縮到 6G,不過這時一定要考慮好壓縮解壓縮演算法的 cpu 使用率、效率與壓縮率的平衡,一些壓縮率很高但是性能很差的演算法,也不適合線上實時呼叫。有些時候直接使用 probuf 來序列化之後儲存,這樣也能節省記憶體空間。
  • 清楚第三方軟體實現細節,精確調優。在使用第三方軟體時,只有清楚細節後才知道怎麼節約記憶體,這點我在實際工作中深有體會,比如之前在閱讀過lucene的原始碼後發現我們的索引檔案原來是可以壓縮的,而這在說明文件中都找不到,具體參考《lucene索引檔案大小優化小結》一文。
2.3.2.3 網絡資源怎麼限制
  • 減少呼叫的次數。經常看到有同學在迴圈裡用 redis/tair 的 get,如果意識到這裡面的網絡開銷的話就應該使用批量處理;又如在推薦服務中經常遇到要去多個地方去取資料,一般採用多執行緒並行去取資料,這個時候不僅耗費cpu資源,也耗費網絡資源,一種在實際中常常採用的方法就是先將很多資料離線儲存到一塊 ,這時候線上服務只要一個請求就能將所有資料獲取。
  • 減少傳輸的資料量。一種方法是壓縮後傳輸,還有一種就是按需傳輸,比如經常遇到的 getData(int id),如果我們傳回該 id 對應的 Data 所有信息,一來人家不需要,二來資料量傳輸太大,這個時候可以改為 getData(int id, Listfields),使用方傳輸相應的欄位過來,服務端只傳回使用方需要的欄位即可。
2.3.2.4 磁盤資源怎麼限制

打日誌要控制量,並定期清理。1)只打印關鍵的異常日誌;2)對日誌大小進行監控報警。我有一次就遇到了第三方服務掛了,然後我這邊就不斷打印呼叫該第三方服務異常的日誌,本來我的服務有降級方案,如果第三方服務掛了會自動使用其它服務,但是突然收到報警說我服務掛了,登上機器一看才知道是磁盤不夠導致的崩潰;3)定期對日誌進行清理,比如用 crontab,每隔幾天對日誌進行清理;4)打印日誌到遠端,對於一些比較重要的日誌可以直接將日誌打印到遠端HDFS檔案系統里;

2.3.3 避免單點

不要把雞蛋放在一個籃子上!從大層次上講服務可以多機房部署、異地多活;從自己設計角度上講,服務應該能做到水平擴展。對於很多無狀態的服務,通過 nginx、zookeeper 能輕鬆實現水平擴展;對一些 job 型別的服務,怎麼避免單點呢,畢竟只能在一個節點上運行,可以參考《Quartz應用與集群原理分析》一文;對資料服務來說,怎麼避免單點呢?簡而言之、可以通過分片、分層等方式來實現,後面會有個博文總結。

小結

如何避免故障,我的經驗濃縮為一句:“懷疑第三方,防備使用方,做好自己”,大家也可以思考、總結並分享下自己的經驗。

    赞(0)

    分享創造快樂