本部落格介紹了我如何解鎖效能,使我能夠在最少的資源(2 GB RAM 1v CPU 和最小網路頻寬 50-100 Mbps)上將後端從 50K 請求擴展到 1M 請求(~16K 請求/分鐘)。

迷因

它將帶你踏上一段與過去的自己的旅程。這可能是一段漫長的旅程,所以請繫緊安全帶,享受這段旅程! 🎢

它假設您熟悉後端和編寫 API。如果你了解一點 Go,這也是一個優勢。如果你不這樣做,也沒關係。您仍然可以按照我提供的資源來幫助您理解每個主題。 (如果您不知道 GO,這裡有一個快速介紹

長話短說;博士,

首先,我們建立一個可觀察性管道,幫助我們監控後端的所有面向。然後,我們開始對後端進行壓力測試,直到斷點測試(當一切最終崩潰時)。

連接輪詢以避免達到最大連接閾值

實施資源限制以避免非關鍵服務佔用資源

新增索引

禁用隱式事務

增加 Linux 的最大檔案描述符限制

限制 Goroutines

未來計劃

後端簡介🤝

讓我簡單介紹一下後端,

  • 它是一個用 Golang 寫的整體 RESTful API。

  • 使用GIN框架編寫,並使用GORM作為ORM

  • 使用 Aurora Postgres 作為託管在 AWS RDS 上的唯一主資料庫。

  • 後端是Docker 化的,我們在 AWS 上的t2.small實例中執行它。它具有 2GB RAM、50-100mb/s 網路頻寬、1 個 vCPU。

  • 後端提供身份驗證、CRUD 操作、推播通知和即時更新。

  • 對於即時更新,我們打開一個非常輕量級的Web 套接字連接,通知設備實體已更新。

我們的應用程式主要是讀取密集型,具有下降的寫入活動,如果我必須給它一個比率,它將是 65% 讀取/35% 寫入。

我可以寫一篇單獨的部落格來解釋我們為什麼選擇 - 整體架構、golang 或 postgress,但為了向您介紹MsquareLabs 的tl;dr,我們相信「保持簡單,並建立允許我們以驚人的快節奏前進的程式碼。

資料資料資料🙊

在進行任何模擬負載生成之前,我首先將可觀察性建置到我們的後端中。其中包括追蹤、指標、分析和日誌。這使得找到問題並準確地找出造成疼痛的原因變得非常容易。當您對後端擁有如此強大的監控能力時,您也可以更輕鬆地更快地追蹤生產問題。

在我們繼續之前,讓我先簡單介紹一下指標、分析、日誌和追蹤:

  • 日誌:我們都知道日誌是什麼,它只是我們在事件發生時建立的大量文字訊息。

圖片.png

  • 追蹤:這是高度可見性的結構化日誌,有助於我們以正確的順序和時間封裝事件。

圖片.png

  • 指標:所有數字攪動資料,例如 CPU 使用率、活動請求和活動 goroutine。

圖片.png

  • 分析:為我們提供程式碼的即時指標及其對機器的影響,幫助我們了解正在發生的情況。 (WIP,下一篇部落格會詳細講)

要了解有關我如何將可觀察性建置到後端的更多訊息,您可以研究下一個博客(WIP),我將此部分移至另一個博客,因為我想避免讀者不知所措並只關註一件事 -優化

這就是追蹤、日誌和指標的視覺化的樣子,

截圖 2024-05-30 下午 4.53.29.png

所以現在我們有一個強大的監控管道+一個像樣的儀表板作為開始🚀

嘲笑高級用戶 x 100,000 🤺

現在真正的樂趣開始了,我們開始嘲笑愛上該應用程式的用戶。

「只有當你把你的愛(後端)置於極大的壓力時,你才會發現它的真正本質✨」 - 某個偉大的人,哈哈,idk

Grafana 還提供了一個負載測試工具,因此我決定使用它,因為它只需要幾行程式碼的最少設置,因此您已經準備好了模擬服務。

我沒有觸及所有 API 路線,而是專注於最關鍵的路線,這些路線負責我們 90% 的流量。

圖片.png

關於k6的簡單介紹,它是一個基於 javascript 和 golang 的測試工具,您可以在其中快速定義要模擬的行為,它負責對其進行負載測試。無論您在主函數中定義什麼,都稱為迭代,k6 會啟動多個虛擬使用者單元(VU)來處理此迭代,直到達到給定的週期或迭代計數。

每次迭代構成4個請求,建立任務→更新任務→取得任務→刪除任務

iLoveIMG 下載 (1).jpg

慢慢開始,讓我們看看大約 10K 請求 → 100 VU 和 30 iter → 3000 iters x 4reqs → 12K 請求情況如何

圖片.png

這是輕而易舉的事情,沒有任何記憶體洩漏、CPU 過載或任何類型瓶頸的跡象,萬歲!

這是 k6 的摘要,發送了 13MB 資料,接收了 89MB,平均超過 52 req/s,平均延遲為 278ms,考慮到所有這些都在單台機器上執行,這還不錯。

checks.........................: 100.00% ✓ 12001     ✗ 0    
     data_received..................: 89 MB   193 kB/s
     data_sent......................: 13 MB   27 kB/s
     http_req_blocked...............: avg=6.38ms  min=0s       med=6µs      max=1.54s    p(90)=11µs   p(95)=14µs  
     http_req_connecting............: avg=2.99ms  min=0s       med=0s       max=536.44ms p(90)=0s     p(95)=0s    
   ✗ http_req_duration..............: avg=1.74s   min=201.48ms med=278.15ms max=16.76s   p(90)=9.05s  p(95)=13.76s
       { expected_response:true }...: avg=1.74s   min=201.48ms med=278.15ms max=16.76s   p(90)=9.05s  p(95)=13.76s
   ✓ http_req_failed................: 0.00%   ✓ 0         ✗ 24001
     http_req_receiving.............: avg=11.21ms min=10µs     med=94µs     max=2.18s    p(90)=294µs  p(95)=2.04ms
     http_req_sending...............: avg=43.3µs  min=3µs      med=32µs     max=13.16ms  p(90)=67µs   p(95)=78µs  
     http_req_tls_handshaking.......: avg=3.32ms  min=0s       med=0s       max=678.69ms p(90)=0s     p(95)=0s    
     http_req_waiting...............: avg=1.73s   min=201.36ms med=278.04ms max=15.74s   p(90)=8.99s  p(95)=13.7s 
     http_reqs......................: 24001   52.095672/s
     iteration_duration.............: avg=14.48s  min=1.77s    med=16.04s   max=21.39s   p(90)=17.31s p(95)=18.88s
     iterations.....................: 3000    6.511688/s
     vus............................: 1       min=0       max=100
     vus_max........................: 100     min=100     max=100

running (07m40.7s), 000/100 VUs, 3000 complete and 0 interrupted iterations
_10k_v_hits ✓ [======================================] 100 VUs  07m38.9s/20m0s  3000/3000 iters, 30 per VU

讓我們增加 12K → 100K 請求,發送 66MB,接收 462MB,CPU 使用率峰值達到 60%,記憶體使用率達到 50%,執行需要 40 分鐘(平均 2500 個請求/分鐘)

圖片.png

一切看起來都很好,直到我在日誌中看到一些奇怪的東西,“::gorm: 連接太多::”,快速檢查RDS 指標,確認打開的連接已達到410,即最大打開連接的限制。它是由 Aurora Postgres 本身根據實例的可用記憶體設定的。

您可以透過以下方法檢查,

select * from pg_settings where name='max_connections'; ⇒ 410

Postgres 為每個連接產生一個進程,考慮到它會在新請求到來時打開一個新連接並且之前的查詢仍在執行,因此這是極其昂貴的。因此 postgress 對可以開啟的並發連線數進行了限制。一旦達到限制,它會阻止任何進一步連接資料庫的嘗試,以避免實例崩潰(這可能會導致資料遺失)

優化一:連接池⚡️

連接池是一種管理資料庫連接的技術,它重用打開的連接並確保它不會超過閾值,如果客戶端請求連接並且超過最大連接限制,它會等待直到連接被釋放或拒絕該請求。

這裡有兩個選項,要么執行客戶端池,要么使用單獨的服務,例如pgBouncer (充當代理)。當我們規模較大且我們有連接到相同資料庫的分散式架構時,pgBouncer 確實是一個更好的選擇。因此,為了簡單性和我們的核心價值觀,我們選擇繼續進行客戶端池化。

幸運的是,我們使用的 ORM GORM 支援連接池,但在幕後使用資料庫/SQL (golang 標準套件)來處理它。

有一些非常簡單的方法可以處理這個問題,

configSQLDriver, err := db.DB()
        if err != nil {
            log.Fatal(err)
        }
        configSQLDriver.SetMaxIdleConns(300)
        configSQLDriver.SetMaxOpenConns(380) // kept a few connections as buffer for developers
        configSQLDriver.SetConnMaxIdleTime(30 * time.Minute)
        configSQLDriver.SetConnMaxLifetime(time.Hour)
  • SetMaxIdleConns → 保留在記憶體中的最大空閒連接,以便我們可以重複使用它(有助於減少開啟連接的延遲和成本)

  • SetConnMaxIdleTime → 我們應該在記憶體中保留空閒連接的最長時間。

  • SetMaxOpenConns → 與資料庫的最大開啟連接,因為我們在同一個 RDS 實例上執行兩個環境

  • SetConnMaxLifetime → 任何連線保持開啟的最長時間

現在更進一步,500K 請求(4000 個請求/分鐘)和 20 分鐘伺服器崩潰💥,最後讓我們調查一下🔎

圖片.png

快速查看指標,然後砰! CPU 和記憶體使用量激增。 Alloy(開放遙測收集器)佔用了所有 CPU 和內存,而不是我們的 API 容器。

圖片.png

優化二:合金資源解鎖(開放式遙測收集器)

我們在小型 t2 實例中執行三個容器,

  • API開發

  • API 分期

  • 合金

當我們將大量負載轉儲到 DEV 伺服器時,它開始以相同的速率產生日誌和跟踪,從而呈指數級增加 CPU 使用率和網路出口。

因此,確保合金容器不會超出資源限制並妨礙關鍵服務非常重要。

由於合金在 docker 容器內執行,因此更容易強制執行此約束,

resources:
        limits:
            cpus: '0.5'
            memory: 400M

此外,這次日誌不為空,存在多個上下文取消錯誤 - 原因是請求逾時,並且連接突然關閉。

圖片.png

然後我檢查了延遲,這太瘋狂了 😲 經過一段時間後,平均延遲為 30 - 40 秒。多虧了跟踪,我現在可以準確地找出是什麼導致瞭如此巨大的延遲。

圖片.png

我們在 GET 操作中的查詢非常慢,讓我們對查詢執行EXPLAIN ANALYZE

截圖 2024-06-11 9.55.10 PM.png

LEFT JOIN 花了 14.6 秒,而 LIMIT 又花了 14.6 秒,我們如何優化它 - INDEXING

優化3:新增索引🏎️

whereordering子句中常用的欄位新增索引可以將查詢效能提高五倍。在新增 LEFT JOIN 表和 ORDER 欄位的索引後,相同查詢花費了 50 毫秒。你能從14.6 秒 ⇒ 50 毫秒開始思考嗎?

(但要注意盲目加入索引,會導致CREATE/UPDATE/DELETE慢死)

它還可以更快地釋放連接,並有助於提高處理巨大並發負載的整體能力。

最佳化 4:確保測試時沒有阻塞 TRANSACTION 🤡

從技術上講不是優化而是修復,您應該記住這一點。當您進行壓力測試時,您的程式碼不會嘗試同時更新/刪除相同實體。

在檢查程式碼時,我發現了一個錯誤,該錯誤導致每次請求時都會對用戶實體進行更新,並且當在事務內執行每個更新呼叫時,這會建立一個鎖,幾乎所有更新呼叫都被以前的更新呼叫阻止。

僅此一項修復就將吞吐量提高至 2 倍。

最佳化5:跳過 GORM 的隱式 TRANSACTION 🎭

圖片.png

預設情況下,GORM 在事務中執行每個查詢,這會降低效能,因為我們擁有極其強大的事務機制,在關鍵區域丟失事務的機會幾乎是不可能的(除非他們是實習生🤣)。

我們有一個中間件可以在到達模型層之前建立事務,並且有一個集中函數來確保控制器層中該事務的提交/回滾。

透過停用此功能,我們可以獲得至少約 30% 的效能提升

“我們卡在每分鐘 4-5K 請求的原因是這個,我認為這是我的筆記型電腦網路頻寬的問題。” - 愚蠢的我

所有這些優化帶來了 5 倍的吞吐量增益 💪,現在光是我的筆記型電腦就可以每分鐘產生 12K-18K 請求的流量。

截圖 2024-06-12 7.20.27 PM.png

百萬點次數🐉

最後,每分鐘 10k-13K 請求達到 100 萬次,大約需要 2 小時,本來應該早點完成,但隨著合金重新啟動(由於資源限制),所有指標都會隨之丟失。

圖片.png

令我驚訝的是,該時間段內的最大 CPU 使用率為 60%,而記憶體使用量僅為 150MB。

Golang 的效能如此之高且處理負載的能力如此出色,這真是太瘋狂了。它具有最小的記憶體佔用。就是愛上了 golang 💖

每個查詢需要 200-400 毫秒才能完成,下一步是找出為什麼需要這麼多時間,我的猜測是連接池和 IO 阻塞減慢了查詢速度。

平均延遲降至約 2 秒,但仍有很大改進空間。

隱式優化🕊️

優化6:增加最大檔案描述子限制🔋

當我們在 Linux 作業系統中執行後端時,我們打開的每個網路連線都會建立一個檔案描述符,預設為 Linux 將每個進程限制為 1024 個,這阻礙了它達到峰值效能。

當我們開啟多個 Web 套接字連線時,如果有大量並發流量,我們很容易就會達到此限制。

Docker compose 提供了一個很好的抽象,

ulimits:
        core:
          soft: -1
          hard: -1

優化 7:避免 goroutine 過載 🤹

作為一個 Go 開發者,我們經常認為 Goroutine 是理所當然的,只是盲目地在 Goroutine 中執行許多非關鍵任務,我們在函數之前加入go ,然後忘記它的執行,但在極端情況下它可能會成為瓶頸。

為了確保它永遠不會成為我的瓶頸,對於經常在 goroutine 中執行的服務,我使用帶有 n-worker 的記憶體佇列來執行任務。

圖片.png

後續步驟🏃‍♀️

改進:從 t2 移動到 t3 或 t3a

t2是老一代的AWS通用機器,而t3和t3a、t4g是新一代。它們是可突發的實例,與 t2 相比,它們為長時間的 CPU 使用提供更好的網路頻寬和更好的效能

了解突發實例,

AWS 引入了可突發執行個體類型,主要針對大多數時間不需要 100% CPU 的工作負載。因此,這些實例以基準效能 (20% - 30%) 運作。當您的實例不需要 CPU 時,他們會維護一個積分系統,它會累積積分。當 CPU 峰值發生時,它會使用該積分。這可以降低您的 AWS 運算成本和浪費。

t3a 將是一個值得堅持的好系列,因為它們的成本/效率比在可突發實例係列中好得多。

這是一個比較t2 和 t3 的不錯的部落格。

改進:查詢

我們可以對查詢/模式進行許多改進來提高速度,其中一些是:

  • 在插入重型表中批量插入。

  • 透過非規範化避免 LEFT JOIN

  • 快取層

  • 著色和分區,但這要晚得多。

改進:分析

釋放效能的下一步是啟用分析並弄清楚執行時到底發生了什麼。

改進:斷點測試

為了發現我的伺服器的限制和容量,下一步是斷點測試。

尾註👋

如果你讀到最後,你已經破解了,恭喜你🍻

這是我的第一篇博客,如果有不清楚的地方,或者您想更深入地了解該主題,請告訴我。在我的下一篇部落格中,我將深入研究分析,敬請關注。

您可以在X上關注我,以獲取最新資訊:)


原文出處:https://dev.to/rikenshah/scaling-backend-to-1m-requests-with-just-2gb-ram-4m0c


共有 0 則留言