譯者 | 劉汪洋
審校 | 重樓
很多年前,我在維護一個數據庫驅動的系統時遇到了一個奇怪的生產環境的 bug。我讀取的列有一個空值,但是代碼中不允許這樣,而且也沒有地方可以讓這個值為空。數據庫嚴重損壞,我們沒有任何線索。雖然有日志,但是由于隱私問題,關鍵信息并未被打印出來。即使我們能打印,我們怎么知道該找什么呢?
應用程序出錯不可避免。我們努力減少出錯,但總是還會出錯。我們還有另一項工作,它并未得到足夠的關注:故障分析。有一些最佳實踐和常見方法,最著名的就是日志記錄。我曾多次說過,日志其實是預知性的調試,但是我們該如何創建一個更容易調試的應用程序呢?
我們應如何構建系統,以便當它出現類似的錯誤時,我們能知道出了什么問題?
一個常見的理念是:“艱苦的準備讓工作更輕松。” 在開發階段,我們面臨的挑戰可能更大,因為我們無法預見在生產過程中會遇到哪些問題。但是,這個階段的準備工作也很有價值,因為我們正在為生產階段做準備。
這種準備超出了測試和質量保證的范圍。它意味著我們需要為代碼和基礎設施在未來可能出現的問題做好準備。到了出現問題的那一刻,測試和質量保證就失效了。簡單來說,這是一種對未知問題的預防措施。
失敗的定義
首先,我們需要明確什么是失敗。當我提到生產環境中的失敗,人們往往會自動聯想到系統崩潰、網站宕機或災難級別的事件。然而,實際上,這些情況相對較少,大部分都由運維人員和系統工程師處理。
當我向開發人員詢問他們最近遇到的生產問題時,他們通常會猶豫不決,甚至無法回憶起具體情況。然而在進行更深入的交談和詢問后,開發者們回憶起了一個他們最近處理過的錯誤。這個錯誤的確是在生產環境中出現的,并且是由客戶報告的。他們必須在本地以某種方式復現它,或者審查信息來修復它。我們并不把這種錯誤看作是生產錯誤,但它們確實是。需要復現已經在現實世界中發生的故障,這使我們的工作變得更困難。
如果我們能夠僅通過查看問題在生產環境中出現的方式就理解問題,那就太好了。
簡潔性
簡潔性非常顯而易見,但是在實際應用中,人們對于簡潔性的理解卻各不相同。簡潔性是主觀的。以下哪段代碼更簡單?
return obj.method(val).compare(otherObj.method(otherVal));
還是這段代碼更簡單?
var resultA = obj.method(val);
var resultB = otherObj.method(otherVal);
return resultA.compare(resultB);
從代碼行數來看,第一個示例似乎更簡單,而且許多開發人員確實會更喜歡那樣。但這可能是一個錯誤。注意,第一個示例在一行中包含了多個可能出現故障的地方,對象可能無效,或者三個方法中的任何一個都可能會失敗。如果真的出現了故障,我們可能無法清晰地判斷具體是哪一部分出了問題。
此外,我們無法適當地記錄結果。我們無法輕易地對代碼進行調試,因為這需要我們逐個進入每一個方法進行檢查。如果故障發生在方法內,堆棧跟蹤應該會引導我們到正確的位置,甚至在第一個示例中也是如此。
試想一下,如果我們調用的方法改變了某個狀態,那么obj.method(val)是否在otherObj.method(otherVal)之前被調用呢?
第二種方式寫代碼,可以很容易地看出方法的調用順序和結果。此外,我們還可以審查和記錄中間狀態,即 resultA 和 resultB 的值。
我們來看一個常見的例子:
var result = list.stream()
.map(MyClass::convert)
.collect(Collectors.toList());
這是一段非常常見的代碼,與下面的代碼有相似之處:
var result = new ArrayList<OtherType>();
for(MyClass c: list) {
result.add(c.convert());
}
從可調試性的角度看,這兩種方法都有其優點,我們的選擇可能會對代碼的長期質量產生重大影響。第一個示例中的一個微妙之處在于,返回的列表是不可修改的。這既是優點也是問題。
當我們試圖更改不可修改的列表時,會在運行時出現故障,這是一種潛在的風險。然而,故障的原因通常是明確的,我們可以清楚地知道什么導致了這個問題。
如果我們對第二個示例中的列表結果進行更改,可能會引發一系列問題。然而,只要這些問題在生產環境中沒有導致故障,我們就可以視為問題已經被解決。
那么,我們應該選擇哪個呢?
通過只讀列表可以實現快速失敗的原則,這對我們調試生成問題有幫助。當快速失敗時,我們降低了連鎖故障的可能性。這些可能是我們在生產環境中遇到的最糟糕的故障,因為對應用程序狀態的深入理解在生產環境中具有很高的復雜度。
在構建大型應用程序時,我們經常提到 "robust"(魯棒性)這個詞。系統應該具有魯棒性,這種魯棒性應該由代碼以外的部分提供。同時,你的代碼本身應該遵循快速失敗的原則。
一致性原則
在我之前關于日志記錄最佳實踐的分享中,我提到過,無論在哪家曾服務過的公司,都有一套自己的代碼風格指南,或者至少會遵循某種公認的風格。然而,很少有公司會有一份關于日志記錄的指南,告訴我們應該在何處記錄日志,以及應該記錄哪些內容。
我們追求的一致性,實際上比代碼格式化更為重要。 在進行調試的時候,我們需要知道應該尋找什么。如果禁止使用某些特定的包,我期望這個規則能適用于整個代碼庫。 同樣,如果大家普遍不推薦某種編碼實踐,我期望這是一個共識。
幸運的是,有了持續集成(CI),這些一致性規則可以輕易地得到執行,而不會增加我們的代碼審查負擔。像 SonarQube 這樣的自動化工具是可插拔的,并且可以通過自定義的檢測代碼進行擴展。我們可以調整這些工具,以強制執行我們的一致性規則集,限制代碼中某個特定子集的使用,或者要求適當的日志記錄。
當然,每條規則都可能有例外。我們不應受過于嚴格的規則束縛。這就是我們需要能夠調整這些工具,通過開發者審查來合并代碼變更的原因,這非常重要。
雙重驗證
調試,其本質是在我們發現并修復 bug 的過程中對假設進行驗證。通常,這一過程進行得相當迅速:我們找到問題所在,進行驗證,然后修復。但偶爾,我們可能會在追蹤某個 bug 上花費過多的時間,尤其是那些難以復現的 bug 或只在生產環境出現的 bug。
當我們遇到難以解決的 bug,重要的是能夠后退一步反思,這通常表示我們可能需要重新審視我們的假設和驗證方法。雙重驗證的關鍵在于利用不同的方法來確認假設的有效性,以保證我們的結論準確無誤。
一般來說,我們需要驗證 bug 存在的兩個方面。例如,假設后端存在一個問題,而這個問題會在前端呈現出錯誤的數據。為了定位這個 bug,我最初做出了兩個假設:
- 前端準確地顯示了后端的數據
- 數據庫查詢返回了正確的數據
為了驗證這兩個假設,我可能會打開瀏覽器查看數據,使用網絡開發者工具檢查響應來確保顯示的數據確實來自服務器查詢。對于后端,我可以直接發出數據庫查詢,確認返回的數據是否正確。
但這僅僅是驗證這些數據的一種方法。我們希望在理想情況下,有第二種驗證方法。例如,如果緩存返回了錯誤的數據,又或者 SQL 查詢的前提假設錯誤呢?
第二種驗證方法應盡可能與第一種方法不同,以防止犯下與第一種方法相同的錯誤。對于前端代碼,我們可能會嘗試使用像 cURL 這樣的工具進行驗證。 使用像 cURL 這樣的工具進行驗證是一個好方法,我們應該嘗試。但更好的方式可能是在服務器上查看記錄的數據,或者調用支持前端的 WebService。
同樣,對于后端,我們希望能夠看到從應用程序內部返回的數據。這是可觀察性的一個核心概念。一個具有可觀察性的系統,就是我們可以對其提出問題并得到答案的系統。在開發過程中,我們應通過兩種不同的方式來提高我們的系統可觀察性,以便更好地回答問題。
為何避免使用三種以上驗證方式?
我們通常不采用超過兩種驗證方式,這是因為過度驗證會導致我們的成本增加,性能降低。我們需要將收集的信息量限制在合理的范圍內,尤其在收集信息的過程中,必須重視個人信息保護的風險,這是我們必須重視的關鍵因素。
可觀察性往往根據其使用的工具,柱狀指標或者某些明顯的特征來定義,但這其實是錯誤的。可觀察性應當根據它提供的訪問權限來定義。我們決定記錄什么,監控什么,決定追蹤的范圍,決定信息的粒度,以及決定是否希望部署開發者的可觀察性工具。
我們需要保證我們的生產系統能夠得到適當的監控。為此,我們需要進行故障注入,可能還需要安排混沌工程的執行。在運行這樣的場景時,我們需要考慮如何解決出現的問題。我們能對系統提出哪些問題?我們如何回答這些問題?
例如,當特定問題出現時,我們通常關心有多少用戶的操作正在實時影響系統數據。因此,我們可以為這個信息添加一個度量。
通過特性標志進行驗證
雖然我們可以使用可觀察性工具來驗證假設,但我們也可以使用一些更具創新性的驗證工具。其中,一個意想不到的工具就是特性標志系統。特性標志解決方案通常可以非常細粒度地操作,例如我們可以只為特定用戶關閉或改變一個特性的設置等。
這種功能非常強大。如果特定的代碼被封裝在一個標志中,我們就可以通過切換一個特性來為我們提供對特定行為的驗證。我并不建議在所有代碼中都使用特性標志,但是能夠拉動開關并在生產環境中改變系統是一種強大的調試工具,這種工具的威力常常被低估。
bug 分析會議
我在 90 年代開發過飛行模擬器,期間有幸與許多戰斗機飛行員合作。我從他們那里了解到了"分析會議"這個概念。我之前只把這樣的會議當作任務失敗后的討論,然而戰斗機飛行員無論任務成功還是失敗,都會在任務結束后立刻進行這樣的會議。
我們可以從中學習以下重要的要點:
- 即時性 - 我們需要在事件剛發生不久的時候討論這些信息。如果等待過久,部分信息可能會丟失,而我們的記憶也會發生明顯的改變。
- 成功與失敗 - 每次任務都包含成功與失敗的元素。我們需要理解在哪些方面做對了,哪些方面做錯了,尤其是在任務成功的情況下。
解決了一個 bug 之后,我們通常只想把這件事了結,不再討論它。即使我們想要"炫耀",也通常只是對追蹤過程的模糊記憶。通過開放的討論,我們對做對的事情和做錯的事情沒有任何評判,我們可以了解到我們的現狀。這些信息可以幫助我們在追蹤問題時提升我們的效果。
這樣的分析會議能幫我們發現在可觀測數據中的缺失、不一致性以及問題流程。在許多團隊中,流程是一個常見的問題。通常,當出現問題時,它的解決過程是這樣的:
- 客戶發現問題
- 向支持部門報告問題
- 運維團隊進行檢查
- 問題轉交給研發部門
如果你在研發部門,你離客戶隔了 4 層,而你接收到的問題可能不包含你需要的所有信息。盡管優化這些流程并不涉及到代碼修改,但我們可以在代碼中添加工具,以便更輕松地定位問題。一種常見的做法是為每個異常對象添加一個唯一的鍵。在出現故障的情況下,這將呈現到用戶界面。
當客戶報告問題時,他們可能會包含這個錯誤碼,方便研發部門通過日志快速定位錯誤。這些都是通過這樣的分析會議,我們可以發現并優化流程的方法。
審閱有效的日志與儀表板
僅僅等待失敗的發生并非是解決問題的正確方式。我們需要定期查閱日志和儀表板,以便于追蹤可能已經存在但未曾顯露出來的 bug,并能建立正常運行狀態的基準。健康的儀表板或者日志應該呈現出怎樣的狀態。
在日志中,我們可能會遇到一些錯誤信息。如果我們在追蹤 bug 的過程中,花費時間去查看那些并無實質性影響的錯誤,那我們就是在浪費時間。理想情況下,我們需要盡可能地減少這些錯誤,因為它們會使得日志的閱讀變得困難。但是在服務器開發中的現實狀況是,我們不能總是做到這一點。不過,我們可以通過對源代碼的深入理解和適當的注釋,來降低這方面的時間開銷。
結語
創建 Codename One 數年后,我們的 Google App Engine 賬單突然飆升,甚至在幾天內就會導致公司的破產。這是由于他們后端系統的一次更新,引發了一個突發性的回歸問題。
這個問題的源頭在于未被緩存的數據,但是由于當時 App Engine 的運行方式,我們無法定位到代碼中哪一個具體的區域觸發了這個問題。我們無法直接調試這個問題,只能嘗試通過更新服務器并觀察其運行情況來判斷問題是否得到了解決。
幸運的是,我們當時解決了這個問題。我們對所有可能的部分盡可能進行了緩存處理。直到今天,我仍然不清楚是什么觸發了這個問題,也不知道我們做了什么修復了問題。
但是我知道的是:
選擇使用"App Engine"是我當時的一個決策失誤。它未能提供足夠的可觀察性,并留下了關鍵的盲點。如果我在部署前花時間對可觀察性能力進行審查,我就能夠察覺這點。選擇使用"App Engine"是我當時的一個決策失誤。
譯者介紹
劉汪洋,51CTO社區編輯,昵稱:明明如月,一個擁有 5 年開發經驗的某大廠高級 Java 工程師,擁有多個主流技術博客平臺博客專家稱號。
原文標題:Building for Failure: Best Practices for Easy Production Debugging,作者:Shai Almog