鞏固系統韌性三個基礎策略

hh54188發表於2023-09-28

眾所周知我所在的團隊常年解決線上問題,我也以為我們會在解決一個個具體問題的道路上無聊走到黑。但是最近出現的各種疑難雜症似乎讓我們的工作有了一點樂趣,甚至有了更高階的意義。

這些疑難雜症包括但不限於

  • 因為網路故障導致郵件傳送失敗
  • 因為資料庫磁碟滿導致資料出現了讀寫不一致
  • 事件推送出現了延遲
  • 因為死鎖導致非同步任務頻繁掛掉

它們有兩個特徵:

  1. 至少從已知的排查結果看,它們並非是由程式碼導致。我將它們歸納為運維錯誤(operational errors)——文章《Error Handling in Node.js》裡對將錯誤劃分為程式錯誤(programmer errors)和運維錯誤(operational errors),簡單來說前者由程式碼產生,可以透過修改程式碼避免;後者與程式無關
  2. 與程式無關,也就意味著無法主動解決

如果我們的工作職責是解決問題,而問題卻無法得到解決該怎麼辦?——僅僅用“解決問題”來形容我們的工作並不恰當。更準確的說,我們是在確保業務部門工作的正常推進,而掃除推進過程中的障礙,並不一定要從根源上解決技術問題。

換而言之,當我們面臨掌控之外的不確定因素時,與其與難題死磕,更優的選擇是讓系統變得富有韌性(resilience),即能夠迅速讓服務從意外狀況中恢復過來。高階精密的技術可以是我們的選項之一,但與其事後花大力氣補救,有一些改進手段可以在日常開發過程中用低成本的方式與之融合,這些就是本篇要聊的內容,也是標題中強調“基本”這個詞的原因

快速失敗

我們發現有一類空指標問題是由磁碟空間不足之後資料儲存失敗導致的,比如下面的虛擬碼

var authorName = book.author.name

在磁碟空間不足的情況下,book 資訊會儲存成功,但 author 並不會。而開發者在編寫程式碼以及在設計資料庫時又從未考慮到 author 不可空的情況。於是在磁碟問題發生之後,上述語句就會引發空指標問題,因為 name 所依賴的 author 並不存在。

此時想當然面臨一個最簡單粗暴的解決辦法,讓程式碼中的 author 物件變得可空(nullable type):

var authorName = book.author?.name

這麼做可能會讓情況變的更加糟糕,因為它忽略了錯誤在分散式系統下的傳播性:雖然此處的空指標問題得以修復,但是為空的 authorName 是否符合下游程式碼的期待?如果不是,下游程式碼是否會發生更加嚴重的錯誤?經驗告訴我們可怕的不是錯誤,是未知。也許這個錯誤會導致無法釋放資源進而耗盡記憶體或者連線池,而此時你又無法遇見到它,那麼可空的修復方案帶來的破壞則會更廣。

退一步說即使空 authorName 變數的不足以對程式碼造成執行中斷的危害,但是當使用者在頁面上發現這一異常行為上報時,我們的排查工作會因為相容可空變得困難,因為從監控上看沒有任何與此有關的錯誤日誌產生。

按照過往的經驗,透過監控系統發現錯誤的效率會高於等待使用者上報;同時越早暴露錯誤也越能夠讓損害和修復成本降到最少,基於此,讓錯誤儘快發生似乎是一個更優解。

甚至允許系統徹底掛掉(crash it)也是快速失敗的一種,當然前提是

  • 程式能夠及時得到重啟
  • 客戶端能夠有重連的能力

備選方法(Fallback)

還有一類看上去稍稍不那麼糟糕的情況是:我預見到了問題的發生,於是我用分支語句準備好了一個備選方案,似乎就可以高枕無憂了?比如下面這段虛擬碼

if (redis_is_health) {
    const book =  getFromCache()
} else {
    const book = getFromDatabaes()
}

這則分支試圖處理 Redis 不可用的情況,但遺憾的是這類程式碼依然充滿風險。

首先如何能夠測試到邊緣分支情況?針對 Redis 也許我們可以透過修改 connectionString 讓它變得暫時無法訪問。但是對於掌控之外的基礎設施,甚至更加極端的情況,比如磁碟耗盡問題,我們是無法模擬出完全一致的場景的。又例如在解決文章開頭提到的隨機死鎖問題,為了能夠測試到分支程式碼,我們特意加入了一段“破壞”程式碼,很顯然這種測試方式經不起推敲。

再者因為 fallback 並沒有機會線上上環境實際執行,自然並未有人見識到它真正的功效,那麼當它實際被啟動的那一天,帶來的可能是危害而不是幫助。上面有關 Redis 的程式碼時來自 Amazon 的一個實際例子,當 Redis 不可用的那一天真的來臨時,因為承載了過多請求,資料庫成為了各個服務的瓶頸,導致網站直接掛掉。

專注於提高主線程式碼的可靠性會帶來更大的收益

重試(Retry)

我們要想通兩件事:

  • 失敗一定會發生:"Failures are a given and everything will eventually fail over time"(Dr Werner Vogels CTO of Amazon)
  • 和解決效能問題類似,想要正式處理這些失敗,一定是去中心化的:需要分策略的解決不同問題;甚至對於同一個問題,不同呼叫方處理的方式也不會相同

但千人千面的線上問題並不意味著束手無策。例如當依賴的系統/服務/網路不可用時,簡單粗暴且有效的辦法就是不斷重試。因為你需要想通的第三件事是,它必須要恢復上線,且終會恢復上線。

不要小看了重試,我願稱之為價效比最高的解決手段,因為我們所用到的重要前端類庫、後端元件都天然整合有重試機制,我們要做的就是將他們利用起來。

即使手動實現大部分情況技術也並不複雜,這裡就不贅述了。但是請千萬留意重試策略,切忌無腦向下遊傳送請求,這樣與 DDOS 攻擊無異。也會帶來不可知的副作用(我們最近便遭遇了一次由此引發的事故,有機會細聊)。具體請參考這篇文章:Timeouts, retries, and backoff with jitter

文化支援

Amazon 在 2019 年進行過一次有關如何打造韌性系統的分享:Amazon's approach to building resilient services,這輪分享中有一半的篇幅都在敘述組織文化在其中的作用,這與我們整個組的想法都不謀而合。

我們最大的苦惱是,線上問題被修復之後,同樣的問題過一段時間又被爆出。也就是說如果沒法把教訓傳達給功能開發的上游,甚至整個組織。就無法從根源上解決問題。

換而言之,學習能力對於富有韌性的系統來說非常重要,無論是對程式碼還是人而言都是如此。它也是我所認可的韌性系統的四基石之一

這種思維模式與 DevOps 類似:團隊應該具有主人翁意識(ownship),完整的負責程式碼的生命週期,從開發到部署再到後期運維。Tech Leader 和 Principle 等類角色也應該對問題細節瞭如指掌,以免成為天馬行空的架構師(non-practitioner architect)

反過來,如果 tech support 團隊和 feature 團隊隔離並且揹負不同的 KPI,那麼可以想象 tech support 最大心願是產品一個季度上線一次才好,因為沒有上線就意味著沒有新的線上問題。

同時允許犯錯也很重要,繼續借用來自 AWS 分享中的一幀來展現通常解決問題過程中團隊內每個人心理壓力的變化

注意在最後的 Confirmation 階段,開發者的心理壓力會陡增形成一片 fear 區域,因為大家擔心那段有問題的程式碼是我寫出的,會被秋後算賬。

“責備文化”形成帶來的壓抑感不言而喻,更重要的是它在扼殺改進和創新,因為改動的程式碼越多犯錯的機率也就越大,那我何必自找苦吃呢。

相關文章