這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
最近連續遇到朋友問我項目裡錯誤和異常管理的事情,之前也多次跟團隊強調過錯誤和異常管理的一些概念,所以趁今天有動力就趕緊寫一篇Go語言項目錯誤和異常管理的經驗分享。
首先我們要理清:什麼是錯誤、什麼是異常、為什麼需要管理。然後才是怎樣管理。
錯誤和異常從語言機制上面講,就是error和panic的區別,放到別的語言也一樣,別的語言沒有error類型,但是有錯誤碼之類的,沒有panic,但是有throw之類的。
在語言層面它們是兩種概念,導致的是兩種不同的結果。如果程式遇到錯誤不處理,那麼可能進一步的產生業務上的錯誤,比如給使用者多扣錢了,或者進一步產生了異常;如果程式遇到異常不處理,那麼結果就是進程異常退出。
在項目裡面是不是應該處理所有的錯誤情況和捕捉所有的異常呢?我只能說,你可以這麼做,但是估計效果不會太好。我的理由是:
- 如果所有東西都處理和記錄,那麼重要訊息可能被淹沒在資訊的海洋裡。
- 不應該處理的錯誤被處理了,很容易匯出BUG暴露不出來,直到出現更嚴重錯誤的時候才暴露出問題,到時候排查就很困難了,因為已經不是錯誤的第一現場。
所以錯誤和異常最好能按一定的規則進行分類和管理,在第一時間能暴露錯誤和還原現場。
對於錯誤處理,Erlang有一個很好的概念叫速錯,就是有錯誤第一時間暴露它。我們的項目從Erlang到Go一直是沿用這一設計原則。但是應用這個原則的前提是先得區分錯誤和異常這兩個概念。
錯誤和異常上面已經提到了,從語言機制層面比較容易區分它們,但是語言取決於人為,什麼情況下用錯誤表達,什麼情況下用異常表達,就得有一套規則,否則很容易出現全部靠異常來做錯誤處理的情況,似乎Java項目特別容易出現這樣的設計。
這裡我先假想有這樣一個業務:遊戲玩家通過購買按鈕,用銅錢購買寶石。
在實現這個業務的時候,程式邏輯會進一步分化成用戶端邏輯和服務端邏輯,用戶端邏輯又進一步因為設計方式的不同分化成兩種結構:胖用戶端結構、瘦用戶端結構。
胖用戶端結構,有更多的本機資料和懂得更多的商務邏輯,所以在胖用戶端結構的應用中,以上的業務會實現成這樣:用戶端檢查緩衝中的銅錢數量,銅錢數量足夠的時候購買按鈕為可用的亮起狀態,使用者點擊購買按鈕後用戶端發送購買請求到服務端;服務端收到請求後校正使用者的銅錢數量,如果銅錢數量不足就拋出異常,終止請求過程並斷開用戶端的串連,如果銅錢數量足夠就進一步完成寶石購買過程,這裡不繼續描述正常過程。
因為正常的用戶端是有一步資料校正的過程的,所以當服務端收到不合理的請求(銅錢不足以購買寶石)時,拋出異常比返回錯誤更為合理,因為這個請求只可能來自兩種用戶端:外掛或者有BUG的用戶端。如果不通過拋出異常來終止業務過程和斷開用戶端串連,那麼程式的錯誤就很難被第一時間發現,攻擊行為也很難被發現。
我們再回頭看瘦用戶端結構的設計,瘦用戶端不會存有太多狀態資料和使用者資料也不清楚商務邏輯,所以用戶端的設計會是這樣:使用者點擊購買按鈕,用戶端發送購買請求;服務端收到請求後檢查銅錢數量,數量不足就返回數量不足的錯誤碼,數量足夠就繼續完成業務並返回成功資訊;用戶端收到服務端的處理結果後,在介面上做出反映。
在這種結構下,銅錢不足就變成了商務邏輯範圍內的一種失敗情況,但不能提升為異常,否則銅錢不足的使用者一點購買按鈕都會出錯掉線。
所以,異常和錯誤在不同程式結構下是互相轉換的,我們沒辦法一句話的給所有類型所有結構的程式一個統一的異常和錯誤分類規則。
但是,異常和錯誤的分類是有跡可循的。比如上面提到的痩用戶端結構,銅錢不足是商務邏輯範圍內的一種失敗情況,它屬於業務錯誤,再比如程式邏輯上嘗試請求某個URL,最多三次,重試三次的過程中請求失敗是錯誤,重試到第三次,失敗就被提升為異常了。
所以我們可以這樣來歸類異常和錯誤:不會終止程式邏輯啟動並執行歸類為錯誤,會終止程式邏輯啟動並執行歸類為異常。
因為錯誤不會終止邏輯運行,所以錯誤是邏輯的一部分,比如上面提到的瘦用戶端結構,銅錢不足的錯誤就是商務邏輯處理過程中需要考慮和處理的一個邏輯分支。而異常就是那些不應該出現在商務邏輯中的東西,比如上面提到的胖用戶端結構,銅錢不足已經不是商務邏輯需要考慮的一部分了,所以它應該是一個異常。
錯誤和異常的分類需要通過一定的思維訓練來強化分類能力,就類似於物件導向的設計方式一樣的,技術實現就擺在那邊,但是要用好需要不斷的思維訓練不斷的歸類和總結,以上提到的歸類方式希望可以作為一個參考,期待大家能發現更多更有效歸類方式。
接下來我們講一下速錯和Go語言裡面怎麼做到速錯。
速錯我最早接觸是在做ASP.NET的時候就體驗到的,當然跟Erlang的速錯不完全一致,那時候也沒有那麼高大上的一個名字,但是對待異常的理念是一樣的。
在.NET項目開發的時候,有經驗的程式員都應該知道,不能隨便re-throw,就是catch錯誤再拋出,原因是異常的第一現場會被破壞,堆疊追蹤資訊會丟失,因為外部最後拿到異常的堆疊追蹤資訊,是最後那次throw的異常的堆疊追蹤資訊;其次,不能隨便try catch,隨便catch很容易匯出異常暴露不出來,升級為更嚴重的業務漏洞。
到了Erlang時期,大家學到了速錯概念,簡單來講就是:讓它掛。只有掛了你才會第一時間知道錯誤,但是Erlang的掛,只是Erlang進程的異常退出,不會導致整個Erlang節點退出,所以它掛的影響層面比較低。
在Go語言項目中,雖然有類似Erlang進程的Goroutine,但是Goroutine如果panic了,並且沒有recover,那麼整個Go進程就會異常退出。所以我們在Go語言項目中要應用速錯的設計理念,就要對Goroutine做一定的管理。
在我們的遊戲服務端項目中,我把Goroutine按掛掉後的結果分為兩類:1、掛掉後不影響其他業務或功能的;2、掛掉後業務就無法正常進行的。
第一類Goroutine典型的有:處理各個玩家請求的Goroutine,因為每個玩家串連各自有一個Goroutine,所以掛掉了只會影響單個玩家,不會影響整體業務進行。
第二類Goroutine典型的有:資料庫同步用的Goroutine,如果它掛了,資料就無法同步到資料庫,遊戲如果繼續運行下去只會導致資料回檔,還不如讓整個遊戲都異常退出。
這樣一分類,就可以比較清楚哪些Goroutine該做recover處理,哪些不該做recover處理了。
那麼在做recover處理時,要怎樣才能盡量保留第一現場來幫組開發人員排查問題原因呢?我們項目中通常是會在最外層的recover中把錯誤和堆疊追蹤資訊記進日誌,同時把關鍵的商務資訊,比如:使用者ID、來源IP、請求資料等也一起記錄進去。
為此,我們還特地設計了一個庫,用來格式化輸出堆疊追蹤資訊和對象資訊,項目地址:http://github.com/funny/debug
通篇寫下來發現比我預期的長很多,所以這裡我做一下歸納總結,幫組大家理解這篇文章所要表達的:
- 錯誤和異常需要分類和管理,不能一概而論
- 錯誤和異常的分類可以以是否終止業務過程作為標準
- 錯誤是業務過程的一部分,異常不是
- 不要隨便捕獲異常,更不要隨便捕獲再重新拋出異常
- Go語言項目需要把Goroutine分為兩類,區別處理異常
- 在捕獲到異常時,需要儘可能的保留第一現場的關鍵資料
以上僅為一家之言,拋磚引玉,希望對大家有所協助。