一個龐大的對話可能會讓我們的 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 的訊息,以先到者為準。
它分兩個步驟運作:
- 先詢問大小。 API 向資料庫要求最新訊息的 ID 與儲存大小,但不取得內容。這個查詢無論訊息多大都很便宜。
- 只載入放得下的內容。 從最新到最舊逐一加入訊息,直到下一則會讓該頁超過 4 MB 為止。接著只有這些資料列會被完整載入。
有兩條規則讓這個做法安全:
- 最新的訊息一定會放進頁面,不論它多大。 一個什麼都沒回傳的頁面,會讓對話卡住,而使用者沒有辦法往回捲動。
- 不切割任何內容。 沒有任何訊息會被截斷或丟棄。對話的其餘部分會透過「向上捲動以查看較早的訊息」的控制項載入,每次一頁,這個控制項在舊訊息本來就已經在使用了。
我們還找到另一處有同樣結構的地方。當你停止一個回合時,API 會檢查最後幾則回覆,看看是否有空的需要清理。它過去的做法是載入八則完整的訊息。在一個充滿多 MB 回覆的對話中,這就是同樣問題的縮小版。現在它改為向資料庫提出一個是非題:這則訊息是否有任何內容?而不會載入內容本身。
我們如何驗證
在這份對話的複本上,以舊的 384 MiB 上限執行:
- 第一頁是 5 MB。以前同樣的請求會讓 API 當機。
- 五個瀏覽器分頁同時開啟這個對話時,記憶體峰值為 154 MB。
- 向上捲動回溯整個對話,會回傳全部 190 則訊息,每一則恰好一次,且順序正確。
頁面挑選的邏輯也有單元測試,其中一項是根據該對話最新訊息的真實大小建立的。第一頁會回傳一則訊息,並表示還有更多,而下一頁會從其後的那一則開始。
我們的收穫
- 筆數不等於大小限制。 任何會回傳「最新 N 筆」、且該資料可能無限增長的端點,都需要一個位元組預算。資料遲早會找到這個缺口。
- 最明顯的嫌疑人不一定是原因。 部署確實是額外工作的真正觸發點,增加記憶體也值得做。但這個當機在沒有部署的情況下也能重現,如果沒有重現它,我們就會停在增加記憶體的那一步。
- 兩台伺服器會因為收到相同的請求而同時失敗。 備援可以防範單一伺服器死掉,但無法防範一個會弄垮它所碰到的任何伺服器的請求,特別是使用者很自然會透過重新整理而重試的那種請求。
- 在設定中修正,而不只是在機器上修正。 記憶體增加的修正同一天就寫進了部署設定,因此下一次部署也會保留它。
這是我們最近第二個與代理輸出有關的記憶體事件。第一個是一個讓 690 MB 字串排隊的狀態標籤。如果你在 Mythex 上使用子代理,可以在文件中了解它們的運作方式。