一週怒肝百萬高併發系統|項目覆盤

本文是魚皮在騰訊實習期間,從零開始一週緊急上線百萬高併發系統的相關經驗、思路及感悟,分享給大家。

花 5 分鐘閱讀本文,你將收穫:

  1. 加深對實際工作環境、工作狀態的瞭解

  2. 學習高併發系統的設計思路、技術選型及理解

  3. 學習工作中對接多方的溝通技巧

  4. 學會與測試打配合的技巧

  5. 學習緊急事故的處理方式

  6. 事後如何進行歸納總結

  7. 感受筆者爆肝工作的痛苦與掙扎

前言

從年前開始和導師二人接手了一個緊急項目,年前加班做完一期後項目效果顯著,於是年後開工立刻加急開發二期,目標是一週上線。由於項目業務邏輯複雜、工期緊、人手缺、對接方多,難度很大,極具挑戰性,因此和導師二人開始了 007 的爆肝工作。

遠程辦公無疑爲 007 無休工作制提供了有利條件,那段時間,我做夢都在敲代碼。

項目簡介

首先要介紹下負責的項目及系統。項目相關業務信息自然不能透露,這裏剝離業務,僅介紹關鍵系統模型,如下圖:

如圖,我負責的是一個狀態流轉系統和查詢系統,以及它們依賴的數據庫服務。

狀態流轉系統的作用是按照邏輯修改數據庫中某條數據的狀態字段,並在修改成功後依據狀態向其他業務側發送通知。

查詢系統,顧名思義就是從數據庫中查詢數據,包括最基礎的鑑權、查詢等功能。

先分析一下系統中一些難點:

  1. 查詢系統是一個高扇入服務,被其他各業務側調用,會存在三個問題:

    高併發:將各業務側請求量聚集,經評估,會產生百萬量級的高併發請求。

    兼容性:如何設計一套 API,滿足各業務側需求的同時容易被理解。

    對接複雜:要同時與多個業務側的同學溝通來討論接口,想想就是一件很複雜的事情。

  2. 狀態流轉系統的業務邏輯相當複雜。

  3. 狀態流轉系統和查詢系統、其他業務側之間存在交互(比如互相發送通知和調用),對時延、容錯性、一致性的要求很高。

分析出了難點,在寫代碼之前,要先編寫可行的技術方案。

設計思路

在實際工作中,編寫詳細的技術方案是非常有必要的。優秀的工程師會在技術方案中考慮到各種場景、評估各種風險、工作量估時、記錄各種問題等,不僅幫助自己梳理思路、歸納總結,同時也給其他人提供了參照以及說服力(比如你預期 7 天上線,沒有方案誰信你?)。

根據二八定理,複雜的系統中,編寫技術方案、梳理設計思路的時間和實際敲代碼開發的時間比例爲 8 : 2。

設計遵循的原則是 “貼合業務”,沒有最好的架構,只有最適合業務的架構。切忌過度設計!

此外,還要考慮項目的緊急程度和人力成本,先保證可用,再追求極致。

一些簡單的設計這裏就略過了,下面針對系統難點和業務需求,列舉幾個重點設計及技術選型:

1. 高併發

提到高併發,大家首先想到的是緩存和負載均衡,缺一不可。

負載均衡說白了就是 “砸錢,加機器!”,但是爲公司省機器、降本增效 是每位後端工程師的信仰,這就要靠技術選型、架構設計及編碼來實現了。目標是儘可能利用每臺機器的有限資源,抗住最大的併發請求。

技術選型

選型如下:

編程框架:選擇輕量級的 Restful 框架 Jersey,搭配輕量級依賴注入庫 Guice

Web 服務器:選擇高性能的輕量級 NIO 服務器 Grizzly

緩存:騰訊自研海量分佈式存儲系統 CKV+(支持 Redis 協議,有數據監控平臺)

數據庫分庫分表:選用公司自研的基礎設施,不細說了

負載均衡:輕量級反向代理服務器 Nginx 和 L5 負載均衡,百萬併發需要增加十餘臺機器

CDN 及預熱:能夠支持高效的文件下載服務

其中,緩存 是抗住高併發流量的關鍵,須重點設計。

緩存方案

1. 數據結構設計

用過緩存的同學都瞭解,關於緩存 Key 的設計是很重要的。根據業務來,保證緩存 key 之間不衝突、便於查找就好。此處我選擇請求參數 + 接口唯一 id 來拼接 key。並且分頁查詢接口可複用全量查詢接口的緩存。

2. 緩存降級

找不到對應 key / redis 連接失敗時直接查庫。

3. 緩存更新

當數據庫發生修改時,需要對緩存進行刪除。由於存在非必填的請求參數,因此緩存 key 可能是一個模糊值。比如有 a、b 兩個請求參數,key 可能爲 “a”,也可能爲 “ab”。

針對請求字段固定(所有字段必填)的接口,更新緩存時,直接拼接出唯一的 key 進行刪除即可。

而針對請求字段不固定(存在非必填字段)的接口,可使用 redis 的 scan 命令範圍掃描(不要用 keys 命令!)或者通過循環拼接出所有可能的 key。比如使用 scan 命令清除所有 key 前綴爲 user1 的緩存。

4. 緩存穿透

無論查詢出的列表是否爲空,都寫入緩存。但在業務會返回多種錯誤碼時,不建議採用這種方式,複雜度高,成本太大。

2. 兼容性

兼容性主要考察接口的設計,爲兼容多個業務側,需要將請求參數以及響應參數設置的儘可能靈活。在設計接口時,切忌一定要和所有的業務側對齊,否則一個字段設計不當可能導致滿盤皆輸!

這裏有 三個小技巧:

  1. 提供可訪問鏈接的文檔,供調用方即時查閱(比如騰訊文檔)。

  2. 請求參數不能過多,且要易於理解,不能爲了強制兼容而設置過於複雜的參數,必要時可針對某一業務側定製接口。

  3. 響應參數儘量多(多不是濫),要知道每次增加返回字段都要修改代碼,而適當冗餘的字段避免了此問題。

3. 消息通知

上面介紹難點時提到:狀態流轉系統與查詢系統、其他業務側存在互相發送通知的交互。當狀態流轉時,需要通知其他業務,還要查詢系統立即更新緩存。對消息的實時性要求很高。

這裏最初有兩種方案:

  1. 各系統提供回調接口,用於接收通知。能保證實時性,但是各系統間緊耦合,不利於擴展。

  2. 使用消息隊列,實現應用解耦及異步消息。

最後還是採取了第二種方案,並選用騰訊自研的 TubeMQ(萬億級分佈式消息中間件,已開源 Apache 孵化),原因如下:

  1. 狀態流轉系統的通知數據之後可能存在其他消費方,使用消息隊列利於擴展,對代碼侵入性也少。

  2. 消息隊列可持久化消息

  3. TubeMQ 支持消費方負載均衡,性能高

  4. TubeMQ 容量大,可存放萬億數量級消息

  5. 支持公司自研組件,便於形成統一規範

在技術選型和確定方案時,不僅要關注當前的業務需求,也要有一定的前沿視角。

4. 風險評估

切忌,在選用中間件 / 框架前,要儘可能多的進行了解,評估其可能帶來的風險。一般公司內都有自己的知識庫,可以利用好內部資源或者找谷歌度娘。

這裏我評估了 TubeMQ 帶來的風險,從消息可靠性、消息順序性、消息重複、監控告警等多個角度進行了分析,還是發現了一些可能的風險。比如當消費方消費數據狀態改變的消息失敗時,緩存未被及時更新,導致數據庫和緩存中的數據不一致。

那麼,如何規避風險呢?我從消息隊列生產方和消費方的角度設計了消息可靠性和數據一致性的解決方案。

解決方案

生產方消息可靠性:
  1. Tube 可保證消息一定送達,發送失敗時會自動重發。

  2. 發送消息結束時會觸發回調,回調裏可判斷消息發送及確認狀態,可將發送失敗的消息放入隊列,下次發送優先從隊列裏取。

消費方消息可靠性和數據一致性:
  1. 消費失敗時進行最多三次重試

  2. 重試後仍消費失敗,則記錄日誌,確保消息不丟失

  3. 通過定時任務讀取日誌,嘗試再次消費失敗消息,並進行告警

開發過程

其實開發過程沒什麼好說的,就是按照既定技術方案去敲代碼。

這裏也有 幾個開發小竅門:

  1. 同時開發多個項目時,可以每個項目一個獨立 Git 分支,分批次提交及合併,否則別人閱讀你提交的代碼時會非常累!

  2. 給每個請求做一些打點數據上報,比如請求量、請求時間、失敗請求數,便於監控統計。

  3. 多記錄日誌,詳細清晰的日誌可以幫助我們快速定位故障。

問題解決

很多問題在本地開發時是察覺不到的,在測試及線上環境纔會被發現。問題解決的過程就像坐過山車,經常的狀態是:測試 => 開發 => 測試 => 上線 => 開發 => 測試,循環往復。

兩個溫馨小貼士:

  1. 遇到問題時,千萬不要慌,可以先深呼吸幾口氣,因爲問題一定是可以解決的,解決不了那麼你可能要被解決了!

  2. 解決問題後,千萬別激動,可以先深呼吸幾口氣,因爲你還會產生新的問題,而且往往新問題更嚴重!

下面分享一些讓魚皮印象深刻的問題。

1. 事務提交時報錯?

原因:事務中調用的函數里也有事務,因此事務裏套了事務,破壞了隔離性。

解決:修改代碼,保證事務隔離性。

2. 依賴包存在,項目啓動卻報錯?

原因:存在多版本 jar 包,導致 Java 代碼使用反射機制動態生成類時不知道使用哪個 jar 包裏的類。

解決:刪掉多餘版本 jar 包。

3. 緩存未即時更新

原因:經排查,是由於實際的緩存 key 數量可達千萬級,導致更新緩存時使用 scan 命令掃描的效率過低,長達 20 多秒!

解決:修改更新緩存的方案,不再使用 scan 命令,而是在業務代碼中拼湊出所有可能的 keys,依次刪除。

以爲這個問題這樣就結束了?不要忘記上面的小貼士:

“解決問題後,千萬別激動,可以先深呼吸幾口氣,因爲你還會產生新的問題,而且往往新問題更嚴重!”

4. 緩存仍未即時更新?

原因:某業務側要求數據強一致性,緩存和數據庫中的狀態必須完全一致!而緩存雖然是毫秒級更新,但無法做到實時一致。

解決:爲該業務側定製一個接口,該接口不查詢緩存,直接查數據庫,保證查到的數據一定是最新值。

5. 請求卡死

服務運行一段時間後,發現所有的請求都被阻塞了!心臟受不了。

原因:使用 jstack 打印線程信息後分析 thread_dump 文件,發現是由於緩存類庫 Jedis 未手動釋放連接導致連接數耗盡,導致新的請求線程會不斷等待 Jedis 連接釋放,從而卡死。

解決:補充釋放 Jedis 連接的代碼即可。

6. 線上環境分析日誌時突然告警,磁盤 IO 佔用超過 99%!

原因:誤用 cat 命令查看未分割的原始日誌文件,由於日誌文件太大(幾十 GB),導致磁盤 IO 直接刷爆!

解決:使用 less、tail、head 等命令代替 cat,並刪除已備份的大日誌文件。

7. 進程閃退

排查:通常 JVM 進程閃退是有錯誤日誌的,但是並沒有找到,排查陷入絕境。沒辦法,只能祈禱問題不再復現。後來問題真的沒出現過了,謝謝!

原因:後來,經詢問,是有人手動 kill 掉了這個進程。好的,***。

8. 線上環境的消息通知發送成功了,怎麼沒有預期的數據更新效果?

定位思路:先看消息是否被消費,再看對消息的處理是否正確。

排查:查看線上日誌,發現消息並未被消費;但是查看監控界面,發現消息被測試環境的機器消費了!

原因:由於測試環境和線上環境屬於同一個消費組,當消息到達時,同一個消費組只有一個消費者能夠成功消費該消息,被測試環境消費掉了,導致線上環境數據沒更新。

發現這個問題的時候,已經是上線前一天的深夜。再申請一個消費組已經來不及了,情急之下,只能先下掉測試環境的服務。第二天申請好消費組後,根據環境去區分使用哪個消費組就可以了,這樣每個消費組都會獨立消費消息,成功避免了消息競爭。

9. 報告!流量太大,撐不住啊!

原因:機器不夠,需進行緊急擴容

解決:緊急新申請了 10 臺機器,完成初始化配置,成功部署新機器後,成功增大了併發度。

小技巧:需要對多個機器執行相同操作時,有兩種快捷的做法。

  1. 利用 SSH 連接工具自帶的並行操作功能,自動給所有機器鍵入命令( XShell 軟件支持)

  2. 配置好一臺機器後,可使用 rsync 命令同步配置至其他機器

10. 上線前一天你跟我說接口設計有問題?

原因:溝通出現嚴重問題!

工作中,一些同事因爲自身業務繁忙,可能在覈對接口設計方案的時候沒有注意。等他們忙完了,會反覆 @ 你、私聊你詢問。我們一定不要這樣!

解決:緊急電話會議,拉羣覈對方案

11. 線上出 bug 了!

線上出 bug,是一件很大的事,必須緊急響應。在夢裏也得給我爬起來!

原因:測試環境和線上環境未必完全一致,且測試環境未必能測出所有問題。因此驗證時通常需要預發佈環境,數據使用線上數據,但卻是獨立的服務器,保證不影響線上。

解決:緊急排查定位問題,三分鐘成功修復!

修復 bug 有一定的技巧,分享下個人的排錯路徑:

截圖 / 問題 => 請求 => bug 是否可復現,和測試緊密配合 => 數據 => 數據源(真實數據與接口數據是否一致) => 數據處理

解釋一下:

通常發現問題的是運維、用戶或者測試,他們會拋出一個問題或者問題的相關的截圖,這時,我們要快速想到這個問題對應的功能(即對應的請求 / 接口),然後讓問題描述者儘可能多的提供信息(比如請求參數、問題時間等)。

如果問題時間較久,看日誌及監控不易排查,可以詢問是否可以造一個復現該問題的 case,這樣只需觀察最新的日誌即可,方便排錯。

定位到請求後,我們要分析請求及響應的哪些數據是異常的,即定位關鍵數據,然後定位數據來源(是從數據庫查的,還是從緩存查的),並觀察響應數據與真實數據源是否一致。如果不一致,可能是業務邏輯中對數據的處理出現了問題,再進一步去做分析。

高效溝通建議:描述問題,儘量用數據說話,給出截圖的同時,要提供完整的數據、請求等信息,有助他人分析。

12. 線上出現部分錯誤數據

這是一個可以預見的問題。還好已經在項目中配置了郵件告警,能夠報告錯誤數據的信息,錯誤數據量也不大。

解決:修復導致錯誤數據的 bug 後,編寫程序循環所有錯誤信息並生成請求代碼,然後手動執行請求代碼,刷新線上不同步數據即可。

建議:設計時還是要儘可能考慮到風險,可以按照問題的嚴重程度做分級報警策略(短信 > 郵件 > 通訊軟件)。

13. 線上機器 OOM!

上線三天後發現的問題,部分線上機器竟然出現了 OOM(堆內存溢出)的情況,導致服務不可用。經排查,是使用的第三方中間件的當前版本存在 bug, 所以說在使用組件前要充分調研和風險評估,選擇正確的版本。

血淚教訓

  1. 有問題一定儘可能在測試環境去解決,否則線上出問題對心臟很不友好。

  2. 不要盲目樂觀,以爲上線就沒問題,要多驗證,保持警惕。

  3. 使用第三方依賴時,一定要嚴格覈對依賴版本號,確保穩定版本。使用老版本或版本不一致可能導致嚴重 bug!

上線後如果發現問題,會經歷如下流程,我稱它爲 hapy 流程。

比如當發現 DB 服務的 bug 後,你只需要改 DB 服務的一行代碼。然而還要做:

  1. 修改 DB 服務的一行代碼

  2. 跑單元測試

  3. DB 服務打成依賴包

  4. 修改 “狀態流轉系統”、“查詢系統” 對 DB 服務的依賴包(改動版本號 / 更新本地緩存拉取最新包)

  5. 重新發布 “狀態流轉系統”、“查詢系統” 至測試環境

  6. 可能還要重新交給測試的同學進行迴歸測試

  7. 測試通過,再次提交 “狀態流轉系統”、“查詢系統” 的代碼,發起 CR(代碼審查)

  8. 找同事或 Leader 讀代碼,通過 CR

  9. 合併分支

  10. 發佈 “狀態流轉系統”、“查詢系統” 至線上環境,每發一臺機器,都要進行一次驗證(滾動部署)。

  11. 再次發現新的 bug

這是一件噁心到爆炸的事情,但是在第 2、6、8 步驟時,是存在空餘等待時間的。這時我們可以做做其他工作,記錄一下工作內容、問題等。

總結思考

首先總結一下這個項目各階段的耗時:

理解需求:5%

開發:15%

溝通確認問題:30%

測試及驗證:30%

上線及驗證:20%

其中,修復 bug 貫穿後面的幾個流程,大概佔了總時間的 60%。

項目過程存在的問題:

  1. 前期未參與需求評審,瞭解的信息較少。

  2. 上線前一天晚上,竟然還在臨時對齊接口?這是在溝通方案階段應該確認好的。

  3. 大約 80% 的時間花在溝通、查詢數據、提供數據及驗證。

  4. 自己沒測試完,就開始串測,導致同一個 bug 被多方發現,反覆 @,導致改 bug 效率低下。

  5. 對自研中間件的不熟悉,導致花費的時間成本較高。

  6. 全局觀還不夠,不能提前預見到一些可能的問題。

  7. 對中間件調研不夠,在最初未覈對依賴版本號導致線上機器 OOM。

自我感覺良好的地方:

  1. 和測試同學配合緊密,互相體諒,測試效率較高

  2. 爲查詢系統編寫了詳細的接口文檔,上傳至公司知識庫供實時查閱

  3. 最快 3 分鐘緊急修復線上 bug

  4. 最快 30 分鐘從接受需求到上線

  5. 在發現中間件問題時,即時和對接方溝通,設計出了對其無任何影響的低成本解決方案

  6. 積極幫助其他同學查詢數據,排查問題

  7. 編寫腳本高效解決部分錯誤數據

成長與收穫:

  1. 抗壓熬夜能力 ↑

  2. 設計思維能力 ↑

  3. 溝通能力 ↑

  4. 解決問題能力 ↑

  5. 高級命令熟悉度 ↑

  6. 中間件熟悉度 ↑

  7. 集羣管理能力 ↑

  8. 拒絕需求能力 ↑

  9. 吐槽能力 ↑

  10. 吹 🐂 能力 ↑

後續

項目上線後,通過總結覆盤,發現了項目中值得優化的地方,也思考到了一些更健全的機制,將逐漸去實現。比如:

1. 兩個系統中有部分相同的配置

目前採用複製粘貼的方式去同步相同的配置,這種方式的優點是比較簡單。但缺點也很明顯,如果一個系統的配置改了,而忘了修改另一個系統的配置,就會出現錯誤。

事實上,可以引入一個配置中心,集中管理多個系統的配置文件,並且支持手動修改、多環境、灰度、配置版本回退等功能。

可以採用阿里的 Nacos 或攜程的 Apollo,提供了界面來管理配置。

2. 曾經的進程閃退問題,必須重視!

無法保證進程不閃退,但是可以對進程實時監控,並自動對閃退進程進行重啓。

實現方式有兩種:

  1. 使用工具,例如 supervisor 或 monit 等,可以對進程進行管理和閃退重啓

  2. 編寫 shell 腳本,再通過定時任務,實現週期性觀察進程狀態及重啓。推薦將定時任務接入分佈式任務調度平臺,尤其當定時任務很多時,進行可視化的管理和方便的控制調度是必要的!

3. 消息隊列可靠性保障

  1. 消息重傳機制:如方案所說,設計重傳隊列(或死信隊列),再次發送時優先取重傳隊列中的消息發送。但注意要避免隊列無限重傳,須給每個消息設置重傳次數閾值。

  2. 郵件告警:如果消息重傳次數超過閾值,直接發送郵件告警,不再將該消息入隊。

工作真是簡單而不簡單,誰說後端只是 CRUD(增刪改查)?

竟然將近 7000 字了,希望大家可以多多點贊支持本文呀!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://juejin.cn/post/6940093658968358948?utm_source=gold_browser_extension