部落格

一個龐大的對話可能會讓我們的 API 停擺

9 月 28 日,大約在一次發版後的 17 分鐘,我們正式環境中的兩台 API 伺服器同時耗盡記憶體、重新啟動,而且在大約五分鐘內,負載平衡器沒有任何健康的伺服器可以把流量送過去。我們的第一個修正是把 API 的記憶體加倍。真正的原因在當天稍後才浮現:打開某一個特定的對話,會讓 API 在一次請求中載入 53 MB 的訊息。現在,對話歷史會以頁面的方式載入,每頁的上限同時以大小與筆數計算,而那個對話則以 5 MB 的第一頁開啟,而不是當機。

停擺的經過

我們的 API 在兩台伺服器上各以一個容器執行,前面有負載平衡器。每個容器的記憶體上限是 384 MiB。平時它只用 130 到 140 MiB,因此這個上限看起來綽綽有餘。

兩個容器同時碰到上限,並以「JavaScript heap out of memory」當機。堆積(heap)是 Node.js 處理其物件時所使用的記憶體。由於兩台伺服器同時失敗,已經沒有地方可以路由請求了。對於只有兩台伺服器的架構來說,這是最糟糕的一種停擺:備援只有在失敗並非同時發生時才有幫助。

第一個修正:更多空間

時間點指向發版。發版後,被部署中斷的代理回合會被恢復,並重新保存它們的逐字記錄,而這一陣突發的工作看起來就是記憶體飆升的明顯來源。

因此第一項改變是把 API 的上限從 384 MiB 提高到 768 MiB。我們當天就在兩台伺服器上即時套用,並把它提交到部署設定中,讓下一次部署不會悄悄地把它改回 384。手動在伺服器上做的修正,如果下一次部署又把它還原,是讓同樣停擺再次發生的常見方式。

更多記憶體是合理的安全邊際,但它並不是原因的解釋。

真正的原因:一個請求,71 MB

後來的同一天,我們在一份該對話的複本上,使用正式環境舊的記憶體上限,重現了當機。這份複本在沒有任何部署參與的情況下,就讓 API 自己當機了。

當你打開一個專案時,網頁應用程式會向 API 要求該對話的歷史。這個端點會原封不動地回傳最新的 30 則訊息。對大多數對話來說,30 則訊息很小。但對這個對話來說,最新的 30 則訊息在資料庫中是 53 MB,回應則是 71 MB。代理的幾則回覆每則就有 4 到 10 MB,主要是子代理的逐字記錄:主代理為了平行處理任務的一部分而啟動的協助代理,它們的完整工作會與回覆一起保存。

建置那一則回應所需的堆積,超過了 API 所擁有的。而且每次打開或重新整理該專案,都會再次送出同樣的請求。單一個對話就能讓它所落到的任何一台伺服器當機,而因為頁面可以重新整理,它也能波及兩台伺服器。

「30 則訊息」從來就不是真正的大小限制,只是一個筆數限制,而對一般對話來說剛好很小。

修正方式:依大小與筆數共同限制的頁面

現在一頁歷史是 30 則訊息,或大約 4 MB 的訊息,以先到者為準。

它分兩個步驟運作:

  1. 先詢問大小。 API 向資料庫要求最新訊息的 ID 與儲存大小,但不取得內容。這個查詢無論訊息多大都很便宜。
  2. 只載入放得下的內容。 從最新到最舊逐一加入訊息,直到下一則會讓該頁超過 4 MB 為止。接著只有這些資料列會被完整載入。

有兩條規則讓這個做法安全:

  • 最新的訊息一定會放進頁面,不論它多大。 一個什麼都沒回傳的頁面,會讓對話卡住,而使用者沒有辦法往回捲動。
  • 不切割任何內容。 沒有任何訊息會被截斷或丟棄。對話的其餘部分會透過「向上捲動以查看較早的訊息」的控制項載入,每次一頁,這個控制項在舊訊息本來就已經在使用了。

我們還找到另一處有同樣結構的地方。當你停止一個回合時,API 會檢查最後幾則回覆,看看是否有空的需要清理。它過去的做法是載入八則完整的訊息。在一個充滿多 MB 回覆的對話中,這就是同樣問題的縮小版。現在它改為向資料庫提出一個是非題:這則訊息是否有任何內容?而不會載入內容本身。

我們如何驗證

在這份對話的複本上,以舊的 384 MiB 上限執行:

  • 第一頁是 5 MB。以前同樣的請求會讓 API 當機。
  • 五個瀏覽器分頁同時開啟這個對話時,記憶體峰值為 154 MB。
  • 向上捲動回溯整個對話,會回傳全部 190 則訊息,每一則恰好一次,且順序正確。

頁面挑選的邏輯也有單元測試,其中一項是根據該對話最新訊息的真實大小建立的。第一頁會回傳一則訊息,並表示還有更多,而下一頁會從其後的那一則開始。

我們的收穫

  • 筆數不等於大小限制。 任何會回傳「最新 N 筆」、且該資料可能無限增長的端點,都需要一個位元組預算。資料遲早會找到這個缺口。
  • 最明顯的嫌疑人不一定是原因。 部署確實是額外工作的真正觸發點,增加記憶體也值得做。但這個當機在沒有部署的情況下也能重現,如果沒有重現它,我們就會停在增加記憶體的那一步。
  • 兩台伺服器會因為收到相同的請求而同時失敗。 備援可以防範單一伺服器死掉,但無法防範一個會弄垮它所碰到的任何伺服器的請求,特別是使用者很自然會透過重新整理而重試的那種請求。
  • 在設定中修正,而不只是在機器上修正。 記憶體增加的修正同一天就寫進了部署設定,因此下一次部署也會保留它。

這是我們最近第二個與代理輸出有關的記憶體事件。第一個是一個讓 690 MB 字串排隊的狀態標籤。如果你在 Mythex 上使用子代理,可以在文件中了解它們的運作方式。

Mythex · Pricing · 用 AI 打造任何東西 · 網站與應用程式範本 · Mythex 比較 · AI 應用程式開發詞彙表 · 免費的 AI 應用程式開發工具 · English

English አማርኛ العربية Български বাংলা Bosanski Català Čeština Dansk Deutsch Ελληνικά Español Español (España) Eesti فارسی Suomi Français Français (Canada) ગુજરાતી हिन्दी Hrvatski Magyar Հայերեն Bahasa Indonesia Íslenska Italiano 日本語 ქართული Қазақ тілі ಕನ್ನಡ 한국어 Lietuvių Latviešu Македонски മലയാളം Монгол मराठी Bahasa Melayu မြန်မာ Norsk bokmål Nederlands ਪੰਜਾਬੀ Polski Português (Brasil) Português (Portugal) Română Русский Slovenčina Slovenščina Soomaali Shqip Srpski Svenska Kiswahili தமிழ் తెలుగు ไทย Tagalog Türkçe Українська اردو Tiếng Việt 简体中文 繁體中文(香港)