把錯誤分成兩大類很有用[腳注3]:
* **操作失敗**?是正確編寫的程序在運行時產生的錯誤。它并不是程序的Bug,反而經常是其它問題:系統本身(內存不足或者打開文件數過多),系統配置(沒有到達遠程主機的路由),網絡問題(端口掛起),遠程服務(500錯誤,連接失敗)。例子如下:
* 連接不到服務器
* 無法解析主機名
* 無效的用戶輸入
* 請求超時
* 服務器返回500
* 套接字被掛起
* 系統內存不足
* **程序員失誤**?是程序里的Bug。這些錯誤往往可以通過修改代碼避免。它們永遠都沒法被有效的處理。
* 讀取 undefined 的一個屬性
* 調用異步函數沒有指定回調
* 該傳對象的時候傳了一個字符串
* 該傳IP地址的時候傳了一個對象
人們把操作失敗和程序員的失誤都稱為“錯誤”,但其實它們很不一樣。操作失敗是所有正確的程序應該處理的錯誤情形,只要被妥善處理它們不一定會預示著Bug或是嚴重的問題。“文件找不到”是一個操作失敗,但是它并不一定意味著哪里出錯了。它可能只是代表著程序如果想用一個文件得事先創建它。
與之相反,程序員失誤是徹徹底底的Bug。這些情形下你會犯錯:忘記驗證用戶輸入,敲錯了變量名,諸如此類。這樣的錯誤根本就沒法被處理,如果可以,那就意味著你用處理錯誤的代碼代替了出錯的代碼。
這樣的區分很重要:操作失敗是程序正常操作的一部分。而由程序員的失誤則是Bug。
有的時候,你會在一個Root問題里同時遇到操作失敗和程序員的失誤。HTTP服務器訪問了未定義的變量時奔潰了,這是程序員的失誤。當前連接著的客戶端會在程序崩潰的同時看到一個`ECONNRESET`錯誤,在NodeJS里通常會被報成“Socket Hang-up”。對客戶端來說,這是一個不相關的操作失敗, 那是因為正確的客戶端必須處理服務器宕機或者網絡中斷的情況。
類似的,如果不處理好操作失敗, 這本身就是一個失誤。舉個例子,如果程序想要連接服務器,但是得到一個`ECONNREFUSED`錯誤,而這個程序沒有監聽套接字上的?`error`事件,然后程序崩潰了,這是程序員的失誤。連接斷開是操作失敗(因為這是任何一個正確的程序在系統的網絡或者其它模塊出問題時都會經歷的),如果它不被正確處理,那它就是一個失誤。
理解操作失敗和程序員失誤的不同, 是搞清怎么傳遞異常和處理異常的基礎。明白了這點再繼續往下讀。
### 處理操作失敗
就像性能和安全問題一樣,錯誤處理并不是可以憑空加到一個沒有任何錯誤處理的程序中的。你沒有辦法在一個集中的地方處理所有的異常,就像你不能在一個集中的地方解決所有的性能問題。你得考慮任何會導致失敗的代碼(比如打開文件,連接服務器,Fork子進程等)可能產生的結果。包括為什么出錯,錯誤背后的原因。之后會提及,但是關鍵在于錯誤處理的粒度要細,因為哪里出錯和為什么出錯決定了影響大小和對策。
你可能會發現在棧的某幾層不斷地處理相同的錯誤。這是因為底層除了向上層傳遞錯誤,上層再向它的上層傳遞錯誤以外,底層沒有做任何有意義的事情。通常,只有頂層的調用者知道正確的應對是什么,是重試操作,報告給用戶還是其它。但是那并不意味著,你應該把所有的錯誤全都丟給頂層的回調函數。因為,頂層的回調函數不知道發生錯誤的上下文,不知道哪些操作已經成功執行,哪些操作實際上失敗了。
我們來更具體一些。對于一個給定的錯誤,你可以做這些事情:
* **直接處理**。有的時候該做什么很清楚。如果你在嘗試打開日志文件的時候得到了一個`ENOENT`錯誤,很有可能你是第一次打開這個文件,你要做的就是首先創建它。更有意思的例子是,你維護著到服務器(比如數據庫)的持久連接,然后遇到了一個“socket hang-up”的異常。這通常意味著要么遠端要么本地的網絡失敗了。很多時候這種錯誤是暫時的,所以大部分情況下你得重新連接來解決問題。(這和接下來的重試不大一樣,因為在你得到這個錯誤的時候不一定有操作正在進行)
* **把出錯擴散到客戶端**。如果你不知道怎么處理這個異常,最簡單的方式就是放棄你正在執行的操作,清理所有開始的,然后把錯誤傳遞給客戶端。(怎么傳遞異常是另外一回事了,接下來會討論)。這種方式適合錯誤短時間內無法解決的情形。比如,用戶提交了不正確的JSON,你再解析一次是沒什么幫助的。
* **重試操作**。對于那些來自網絡和遠程服務的錯誤,有的時候重試操作就可以解決問題。比如,遠程服務返回了503(服務不可用錯誤),你可能會在幾秒種后重試。**如果確定要重試,你應該清晰的用文檔記錄下將會多次重試,重試多少次直到失敗,以及兩次重試的間隔。**?另外,不要每次都假設需要重試。如果在棧中很深的地方(比如,被一個客戶端調用,而那個客戶端被另外一個由用戶操作的客戶端控制),這種情形下快速失敗讓客戶端去重試會更好。如果棧中的每一層都覺得需要重試,用戶最終會等待更長的時間,因為每一層都沒有意識到下層同時也在嘗試。
* **直接崩潰**。對于那些本不可能發生的錯誤,或者由程序員失誤導致的錯誤(比如無法連接到同一程序里的本地套接字),可以記錄一個錯誤日志然后直接崩潰。其它的比如內存不足這種錯誤,是JavaScript這樣的腳本語言無法處理的,崩潰是十分合理的。(即便如此,在`child_process.exec`這樣的分離的操作里,得到`ENOMEM`錯誤,或者那些你可以合理處理的錯誤時,你應該考慮這么做)。在你無計可施需要讓管理員做修復的時候,你也可以直接崩潰。如果你用光了所有的文件描述符或者沒有訪問配置文件的權限,這種情況下你???么都做不了,只能等某個用戶登錄系統把東西修好。
* **記錄錯誤,其他什么都不做**。有的時候你什么都做不了,沒有操作可以重試或者放棄,沒有任何理由崩潰掉應用程序。舉個例子吧,你用DNS跟蹤了一組遠程服務,結果有一個DNS失敗了。除了記錄一條日志并且繼續使用剩下的服務以外,你什么都做不了。但是,你至少得記錄點什么(凡事都有例外。如果這種情況每秒發生幾千次,而你又沒法處理,那每次發生都記錄可能就不值得了,但是要周期性的記錄)。
### (沒有辦法)處理程序員的失誤
對于程序員的失誤沒有什么好做的。從定義上看,一段本該工作的代碼壞掉了(比如變量名敲錯),你不能用更多的代碼再去修復它。一旦你這樣做了,你就使用錯誤處理的代碼代替了出錯的代碼。
有些人贊成從程序員的失誤中恢復,也就是讓當前的操作失敗,但是繼續處理請求。這種做法不推薦。考慮這樣的情況:原始代碼里有一個失誤是沒考慮到某種特殊情況。你怎么確定這個問題不會影響其他請求呢?如果其它的請求共享了某個狀態(服務器,套接字,數據庫連接池等),有極大的可能其他請求會不正常。
典型的例子是REST服務器(比如用Restify搭的),如果有一個請求處理函數拋出了一個`ReferenceError`(比如,變量名打錯)。繼續運行下去很有肯能會導致嚴重的Bug,而且極其難發現。例如:
1. 一些請求間共享的狀態可能會被變成`null`,`undefined`或者其它無效值,結果就是下一個請求也失敗了。
2. 數據庫(或其它)連接可能會被泄露,降低了能夠并行處理的請求數量。最后只剩下幾個可用連接會很壞,將導致請求由并行變成串行被處理。
3. 更糟的是, postgres 連接會被留在打開的請求事務里。這會導致 postgres “持有”表中某一行的舊值,因為它對這個事務可見。這個問題會存在好幾周,造成表無限制的增長,后續的請求全都被拖慢了,從幾毫秒到幾分鐘[腳注4]。雖然這個問題和 postgres 緊密相關,但是它很好的說明了程序員一個簡單的失誤會讓應用程序陷入一種非常可怕的狀態。
4. 連接會停留在已認證的狀態,并且被后續的連接使用。結果就是在請求里搞錯了用戶。
5. 套接字會一直打開著。一般情況下 NodeJS 會在一個空閑的套接字上應用兩分鐘的超時,但這個值可以覆蓋,這將會泄露一個文件描述符。如果這種情況不斷發生,程序會因為用光了所有的文件描述符而強退。即使不覆蓋這個超時時間,客戶端會掛兩分鐘直到 “hang-up” 錯誤的發生。這兩分鐘的延遲會讓問題難于處理和調試。
6. 很多內存引用會被遺留。這會導致泄露,進而導致內存耗盡,GC需要的時間增加,最后性能急劇下降。這點非常難調試,而且很需要技巧與導致造成泄露的失誤聯系起來。
**最好的從失誤恢復的方法是立刻崩潰**。你應該用一個restarter 來啟動你的程序,在奔潰的時候自動重啟。如果restarter 準備就緒,崩潰是失誤來臨時最快的恢復可靠服務的方法。
奔潰應用程序唯一的負面影響是相連的客戶端臨時被擾亂,但是記住:
* 從定義上看,這些錯誤屬于Bug。我們并不是在討論正常的系統或是網絡錯誤,而是程序里實際存在的Bug。它們應該在線上很罕見,并且是調試和修復的最高優先級。
* 上面討論的種種情形里,請求沒有必要一定得成功完成。請求可能成功完成,可能讓服務器再次崩潰,可能以某種明顯的方式不正確的完成,或者以一種很難調試的方式錯誤的結束了。
* 在一個完備的分布式系統里,客戶端必須能夠通過重連和重試來處理服務端的錯誤。不管 NodeJS 應用程序是否被允許崩潰,網絡和系統的失敗已經是一個事實了。
* 如果你的線上代碼如此頻繁地崩潰讓連接斷開變成了問題,那么正真的問題是你的服務器Bug太多了,而不是因為你選擇出錯就崩潰。
如果出現服務器經常崩潰導致客戶端頻繁掉線的問題,你應該把經歷集中在造成服務器崩潰的Bug上,把它們變成可捕獲的異常,而不是在代碼明顯有問題的情況下盡可能地避免崩潰。調試這類問題最好的方法是,把 NodeJS 配置成出現未捕獲異常時把內核文件打印出來。在 GNU/Linux 或者 基于 illumos 的系統上使用這些內核文件,你不僅查看應用崩潰時的堆棧記錄,還可以看到傳遞給函數的參數和其它的 JavaScript 對象,甚至是那些在閉包里引用的變量。即使沒有配置 code dumps,你也可以用堆棧信息和日志來開始處理問題。
最后,記住程序員在服務器端的失誤會造成客戶端的操作失敗,還有客戶端必須處理好服務器端的奔潰和網絡中斷。這不只是理論,而是實際發生在線上環境里。