:實踐記錄)
一次深夜的線上事故讓我徹底改變了對高可用的理解。那不是一個復(fù)雜的分布式系統(tǒng)只有一個主庫和一個應(yīng)用節(jié)點但就是在流量高峰時數(shù)據(jù)庫連接池被一個慢查詢打滿整個服務(wù)全部不可用。重啟后不到五分鐘問題再次出現(xiàn)。當(dāng)時我盯著監(jiān)控大屏上刺眼的紅色告警意識到所謂的高可用從來不是靠某個“高可用組件”就能實現(xiàn)的而是一整套從架構(gòu)設(shè)計到日常運維的紀(jì)律。后來我花了很長時間反復(fù)重構(gòu)自己的后端系統(tǒng)把每一次故障都當(dāng)作一次補課。這篇實踐記錄不是理論堆砌而是我真實踩坑后留下的操作清單。如果你也想搭建一個能扛住突發(fā)流量、能在機器宕機時無感切換、能在依賴服務(wù)抖動時不雪崩的后端系統(tǒng)希望這些經(jīng)驗?zāi)軒湍闵僮邚澛?。先從“可故障”開始設(shè)計很多人一上來就追求“永不宕機”但這是方向性錯誤。高可用的本質(zhì)不是不壞而是壞了以后系統(tǒng)還能繼續(xù)工作或者快速恢復(fù)。我做的第一件事是給系統(tǒng)里每一個組件寫上“故障假設(shè)”如果這臺服務(wù)器被斷電會發(fā)生什么如果數(shù)據(jù)庫主庫磁盤寫滿會發(fā)生什么如果Redis集群整個不可達會發(fā)生什么如果消息隊列積壓了百萬條消息又會發(fā)生什么把這些假設(shè)寫下來以后你會發(fā)現(xiàn)很多“看起來正?!钡脑O(shè)計其實經(jīng)不起推敲。比如某個服務(wù)把用戶會話存在本地內(nèi)存一旦這個節(jié)點重啟所有登錄用戶全部掉線——這就是一個典型的“可故障”短板。后來我強制自己遵循一個原則任何無狀態(tài)服務(wù)都可以被隨時殺死任何有狀態(tài)數(shù)據(jù)都必須有外部持久化或可恢復(fù)路徑?;谶@個原則我把應(yīng)用層全部改成了無狀態(tài)設(shè)計會話數(shù)據(jù)挪到Redis文件上傳直接進對象存儲就連日志都通過標(biāo)準(zhǔn)輸出收集不落本地盤。這樣做的直接好處是我可以在任何一臺應(yīng)用服務(wù)器上隨便執(zhí)行kill -9前端用戶完全無感知因為負(fù)載均衡器會把請求自動轉(zhuǎn)發(fā)給其他健康節(jié)點。不過無狀態(tài)只是第一步。真正讓我感到系統(tǒng)“皮實”的是把每一個依賴都當(dāng)成可能出錯的第三方來對待。比如調(diào)用下游API我統(tǒng)一加了超時控制和熔斷器并且為每個外部依賴設(shè)置了獨立的線程池。某次一個第三方短信服務(wù)響應(yīng)變慢熔斷器快速打開系統(tǒng)直接跳過短信發(fā)送而不是讓所有請求卡在等待響應(yīng)上。那次故障只影響了短信功能主流程絲毫無損——這讓我深刻理解了“隔離”的價值。三個節(jié)點與一個共識算法應(yīng)用層的無狀態(tài)解決了“殺掉任意節(jié)點”的問題但數(shù)據(jù)庫才是真正的核心。我最早的單庫架構(gòu)里數(shù)據(jù)庫一旦宕機整個系統(tǒng)就徹底癱瘓。為了提升可用性我開始搭建數(shù)據(jù)庫主從集群但僅僅有主從復(fù)制是不夠的——如果主庫宕機需要人工切換到從庫切換期間業(yè)務(wù)中斷而且如果主從數(shù)據(jù)不一致切過去以后還會出現(xiàn)臟數(shù)據(jù)。后來我嘗試了常見的MHAMaster High Availability方案但手動切換腳本實在讓人提心吊膽。我的改進方案是引入一個能自動完成“選主、切換、數(shù)據(jù)補償”的協(xié)調(diào)節(jié)點。其實原理非常簡單三個節(jié)點組成一個小組通過多數(shù)派投票決定誰是主庫。任何一臺節(jié)點宕機只要剩余兩臺還活著系統(tǒng)就能自動選出新主并把原來的主庫隔離。這個方案借鑒了Raft共識算法但我并沒有自己去實現(xiàn)Raft而是直接用了etcd來充當(dāng)這個協(xié)調(diào)者。每個數(shù)據(jù)庫節(jié)點啟動時向etcd注冊自己的角色和狀態(tài)應(yīng)用層通過etcd監(jiān)聽主庫變化。當(dāng)主庫出現(xiàn)心跳超時etcd的租約機制會觸發(fā)選主流程從庫提升為新主并且應(yīng)用層會自動更新自己的數(shù)據(jù)源連接——整個過程大約在幾秒內(nèi)完成。關(guān)鍵點在于應(yīng)用層不能緩存數(shù)據(jù)庫地址每次獲取連接都必須先去etcd查詢或者訂閱變更通知。不過在實施過程中我也吃了不少苦頭。比如主從復(fù)制延遲問題。在高并發(fā)寫入下從庫經(jīng)常會落后主庫幾百毫秒甚至幾秒。對于那些必須讀到最新數(shù)據(jù)的請求比如剛下單后跳轉(zhuǎn)到訂單詳情如果直接走從庫用戶會看到訂單不存在或狀態(tài)未更新。我的解決方案是“讀寫分離分級”核心強一致讀請求強制走主庫非核心的列表頁、統(tǒng)計頁可以接受少量延遲走從庫。這樣既保證了用戶體驗又減輕了主庫壓力。負(fù)載均衡不只是轉(zhuǎn)發(fā)有人覺得高可用只要在前面架一臺Nginx就夠了這是很大的誤解。我最初也只配置了一個反向代理指向后面幾臺應(yīng)用服務(wù)器。直到有一次其中一臺應(yīng)用服務(wù)器的網(wǎng)卡出現(xiàn)了半斷開狀態(tài)——TCP連接能建立但數(shù)據(jù)包丟得厲害。Nginx默認(rèn)的健康檢查只是檢查“端口是否通”所以它依然把流量源源不斷發(fā)給這臺“半死不活”的機器結(jié)果大量請求超時。從那以后我把負(fù)載均衡的健康檢查改成了主動探測業(yè)務(wù)接口的返回碼和響應(yīng)時間而不是僅僅檢查端口存活。我還在Nginx配置里加了被動熔斷如果某個上游節(jié)點連續(xù)出現(xiàn)多次502或超時就自動將它拉出轉(zhuǎn)發(fā)列表一段時間等冷卻期過了再重新試探。但負(fù)載均衡本身也可能成為單點。所以我在最外層部署了兩臺Nginx它們共享一個虛擬IPVIP通過keepalived進行主備切換。當(dāng)主Nginx宕機備用Nginx自動接管VIP。這里有個細(xì)節(jié)容易被忽略keepalived之間的健康檢查必須足夠靈敏檢查腳本里不能寫復(fù)雜的邏輯否則腳本卡住會導(dǎo)致主備都認(rèn)為對方活著出現(xiàn)“腦裂”現(xiàn)象。我踩過這個坑兩邊的Nginx同時持有VIP流量被隨機打到了兩臺其中一臺已經(jīng)掛掉導(dǎo)致部分用戶無法訪問。后來我專門加了iptables規(guī)則讓備Nginx在搶到VIP之前先主動斷開對外的服務(wù)端口避免雙主同時生效。緩存救命的稻草也是燒身的火在數(shù)據(jù)庫壓力達到瓶頸時緩存幾乎是唯一的救贖。我用Redis做了三層緩存熱點對象的本地緩存、全量對象的Redis緩存、以及數(shù)據(jù)庫作為最終兜底。這個設(shè)計思路是對的但我也經(jīng)歷過緩存穿透和緩存雪崩的雙重考驗。一次促銷活動某個商品ID被惡意腳本瘋狂請求而這個ID在數(shù)據(jù)庫中并不存在。每次請求都會繞過Redis直擊數(shù)據(jù)庫瞬間把數(shù)據(jù)庫打到慢查詢。為了解決穿透我在Redis里專門緩存了“空值”對象并設(shè)置了較短的過期時間。對于惡意隨機ID攻擊這些空值緩存能擋住絕大部分流量數(shù)據(jù)庫壓力直接下降了90%。緩存雪崩則是另一個噩夢。當(dāng)時我給所有緩存設(shè)置了同樣的過期時間比如24小時結(jié)果午夜零點所有key同時過期數(shù)據(jù)庫被瞬間涌入的請求打垮。后來我改了策略過期時間必須加一個隨機偏移量比如基礎(chǔ)時間1200秒加上0到300秒的隨機數(shù)讓緩存失效時間錯開。同時對于熱門key我采用“提前續(xù)期”的方式——后臺任務(wù)每隔一段時間檢查即將過期的熱點key主動延長它們的生命周期。還有一個我屢次踩坑的地方是緩存與數(shù)據(jù)庫的一致性。最初我采用“先刪緩存再更新數(shù)據(jù)庫”的策略結(jié)果在高并發(fā)下出現(xiàn)臟讀一個線程刪除緩存后還沒來得及更新庫另一個線程已經(jīng)讀到了舊數(shù)據(jù)并寫回了緩存。后來我改成“先更新數(shù)據(jù)庫再刪除緩存”并配合一個延遲雙刪策略。雖然無法做到絕對一致但配合Binlog異步監(jiān)聽來主動失效緩存實際業(yè)務(wù)中幾乎沒有再見過臟數(shù)據(jù)。冪等性高可用系統(tǒng)的隱形基石很多高可用設(shè)計只關(guān)注“請求能不能被處理”卻忽略了“請求被重復(fù)處理”帶來的災(zāi)難。我的下單接口曾經(jīng)因為一次消息重試導(dǎo)致用戶被扣了兩次款。原因很簡單消息隊列在消費端處理完訂單邏輯后由于網(wǎng)絡(luò)抖動沒有返回ACKBroker又重新投遞了這條消息消費端沒有做冪等校驗。讓系統(tǒng)具備冪等能力是保障高可用不可或缺的一環(huán)。我在所有寫操作入口都增加了冪等令牌機制客戶端在發(fā)起請求前先向服務(wù)端申請一個唯一的請求ID或者用業(yè)務(wù)訂單號本身。服務(wù)端在處理寫操作時先去Redis里查這個ID是否已經(jīng)處理過如果處理過就直接返回上一次的處理結(jié)果否則才執(zhí)行真正的業(yè)務(wù)邏輯并把結(jié)果緩存起來。這個方案雖然簡單但細(xì)節(jié)非常多。冪等校驗和業(yè)務(wù)執(zhí)行必須放在同一個事務(wù)里否則會出現(xiàn)并發(fā)問題兩個相同ID的請求同時通過了校驗然后重復(fù)執(zhí)行。為了徹底解決這個并發(fā)窗口我用Redis的SET NX命令作為分布式鎖只有獲取成功才能繼續(xù)執(zhí)行后續(xù)邏輯。鎖的過期時間要設(shè)置得比業(yè)務(wù)執(zhí)行時間更長否則鎖提前釋放后另一個請求又能進來了又會導(dǎo)致重復(fù)執(zhí)行。后來我把冪等范圍擴大到了所有對外提供的接口包括支付回調(diào)、消息消費、內(nèi)部RPC調(diào)用。甚至對于讀操作我也做了“讀多寫少”場景下的請求合并和去重。冪等性并沒有帶來可見的性能提升但在故障恢復(fù)后的重放流量面前它是防止資金損失和訂單錯亂的最后一道防線。鏈路追蹤與監(jiān)控高可用的眼睛系統(tǒng)沒有監(jiān)控就等于蒙著眼睛開車。我在早期只能靠用戶報障才能發(fā)現(xiàn)問題那種被動感讓人非常難受。后來我把監(jiān)控分成三個層級基礎(chǔ)設(shè)施層CPU、內(nèi)存、磁盤、網(wǎng)絡(luò)、中間件層連接池、隊列積壓、緩存命中率、業(yè)務(wù)層接口耗時、錯誤率、關(guān)鍵業(yè)務(wù)流程成功率。搭建監(jiān)控最核心的不是工具選型而是必須從第一天就統(tǒng)一日志鏈路ID。我通過一個全局的Trace ID把一次用戶請求經(jīng)過的所有服務(wù)調(diào)用日志串起來。前端網(wǎng)關(guān)生成這個ID并注入到HTTP Header所有內(nèi)部RPC都攜帶它。這樣當(dāng)用戶說“我下單失敗了”我只要在日志系統(tǒng)里搜索這個ID就能立刻看到是哪個環(huán)節(jié)卡了殼。如果沒有這個統(tǒng)一ID排查分布式故障就像在黑暗中找一根丟了的針。Prometheus和Grafana是我用得最順手的監(jiān)控組合。但有一套監(jiān)控還不夠關(guān)鍵在于設(shè)定合理的告警閾值。我的教訓(xùn)是告警閾值設(shè)得太寬松故障臨頭了還沒觸發(fā)設(shè)得太嚴(yán)格又天天被無效告警轟炸最后人疲勞到直接關(guān)掉告警。經(jīng)歷過幾次“狼來了”之后我學(xué)會了為不同指標(biāo)設(shè)置分級告警P0級別的只保留服務(wù)完全不可用、核心數(shù)據(jù)庫主從切換失敗、消息積壓超過閾值等真正致命的問題P1/P2級別的只是通知不會半夜把值班的人吵醒。我還會周期性地做故障演練。每個月挑一個夜深人靜的時段隨機關(guān)掉一臺應(yīng)用節(jié)點觀察系統(tǒng)是否自動恢復(fù)。最開始演練簡直慘不忍睹——每次都要人工介入腳本和自動恢復(fù)邏輯漏洞百出。經(jīng)過了大半年反復(fù)演練我現(xiàn)在已經(jīng)可以在不通知任何人的情況下手動切斷數(shù)據(jù)庫主節(jié)點并在十分鐘內(nèi)恢復(fù)服務(wù)。這種感覺才是真正的高可用儲備。容災(zāi)與降級為了活下去要會放棄高可用的另一層含義是“在資源不足時能智能地犧牲一部分功能來保核心功能”。電商大促期間系統(tǒng)流量是平時的幾十倍。如果每個請求都要查庫存、查價格、查優(yōu)惠券、查物流信息再疊加各種復(fù)雜的營銷計算任何數(shù)據(jù)庫都扛不住。這個時候必須學(xué)會降級。我設(shè)計的降級策略分為三層頁面降級、功能降級、數(shù)據(jù)降級。頁面降級指的是把動態(tài)渲染的頁面切換為靜態(tài)緩存頁用戶看到的商品信息可能是幾分鐘前的舊數(shù)據(jù)但頁面能正常打開。功能降級指的是關(guān)閉那些非核心的輔助功能比如“猜你喜歡”“歷史瀏覽記錄”“優(yōu)惠券領(lǐng)券中心”這些功能對不產(chǎn)生直接交易價值關(guān)掉以后能省出大量計算資源。數(shù)據(jù)降級則更強硬比如當(dāng)Redis內(nèi)存超過80%時直接丟棄非核心數(shù)據(jù)的緩存寫入只保留訂單和用戶等核心緩存。降級策略的核心是“主動放棄”而不是等系統(tǒng)被拖垮后再被動失敗。我自己寫了一個動態(tài)配置開關(guān)中心所有的降級決策都通過這個配置中心下發(fā)不需要修改代碼重新部署。每次大促前我和團隊會提前梳理好哪些接口需要降級、降級后返回什么默認(rèn)值、什么時候重新恢復(fù)并做成預(yù)案文檔。這里有一個容易忽略的心態(tài)問題技術(shù)團隊往往不愿意降級覺得一旦降級就代表系統(tǒng)不行。但實際上降級是保證核心業(yè)務(wù)連續(xù)性的手段用戶寧愿看到“查不到某個列表”也不會接受“整個網(wǎng)頁打不開”。我逐漸從“追求所有功能都可用”轉(zhuǎn)變?yōu)椤白非笞铌P(guān)鍵的功能永遠可用”這個思維轉(zhuǎn)變讓我的系統(tǒng)可用性從99.9%提升到了99.99%。踩坑總結(jié)高可用沒有完成式搭建高可用后端系統(tǒng)不是一個項目而是一個持續(xù)對抗未知失敗的過程。我在這條路上遇到過的每一個故障最終都變成了系統(tǒng)的一部分。比如我原來以為消息隊列是高可用的結(jié)果發(fā)現(xiàn)Broker宕機后由于消費端的重試機制寫得不合理直接把下游數(shù)據(jù)庫打垮。后來我加了消費端的退避重試和最大重試次數(shù)限制超過次數(shù)就進入死信隊列等待人工處理絕不讓重試流量無限放大。我原來以為容器化能自動解決高可用結(jié)果發(fā)現(xiàn)Pod頻繁重啟只是因為健康檢查的探針路徑寫錯了。原來以為多機房部署就萬事大吉結(jié)果發(fā)現(xiàn)機房之間的專線抖動導(dǎo)致數(shù)據(jù)同步延遲和腦裂問題。高可用不是靠某個工具或某個框架帶來的而是要對每一層依賴有敬畏之心把每一次失敗都當(dāng)作優(yōu)化的機會。最后我想說一個最接地氣的觀點哪怕你的系統(tǒng)架構(gòu)設(shè)計得再完美如果沒人維護、沒有人值班、沒有清晰的應(yīng)急流程它依然談不上高可用。人才是系統(tǒng)中最大的不確定性同時也是最重要的兜底力量。我給自己和團隊制定了嚴(yán)格的變更流程——每個改動都要經(jīng)過代碼評審、灰度發(fā)布、監(jiān)控觀察才能全量上線。因為高可用系統(tǒng)和脆弱系統(tǒng)之間往往只隔著一個“隨手改了下配置文件”的夜晚?,F(xiàn)在站在新項目起點我仍然會先寫下故障假設(shè)再去畫架構(gòu)圖。因為只有先接受失敗的可能才能設(shè)計出優(yōu)雅的應(yīng)對方案。這就是我搭建高可用后端系統(tǒng)的全部實踐記錄希望對你有所幫助。