一個龐大嘅對話點樣可以令我哋嘅 API 停頓
九月二十八日,大約喺一次發布後 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。我哋當日就喺兩部伺服器上即時套用,並將設定 commit 到部署設定入面,確保下一次部署唔會悄悄將 384 改返回去。手動喺伺服器上做嘅修正,如果下一次部署又將佢還原,就係同一個故障再次出現嘅常見原因。
增加記憶體係一個合理嘅安全餘裕,但並唔係一個解釋。
真正原因:一個請求,71 MB
到當日稍後,我哋用一份該對話嘅副本,以生產環境嘅舊記憶體上限重現咗崩潰。副本單獨就令 API 崩潰,完全冇涉及任何部署。
當你開啟一個項目,網頁應用會向 API 要求該對話嘅歷史。個端點會完整返回最新嘅 30 則訊息。對大部分對話嚟講,30 則訊息好細。但係對呢個對話,最新嘅 30 則訊息喺資料庫入面有 53 MB,回應就有 71 MB。代理嘅幾個回覆,每個有 4 至 10 MB,主要係子代理嘅記錄:主代理為咗並行處理任務其中一部分而啟動嘅幫手代理,佢哋嘅完整工作會連同回覆一齊儲存。
建立嗰一個回應,所用嘅 heap 比 API 擁有嘅更多。而且每次開啟或者重新整理該項目,都會重新送出呢個請求。一個對話就可以令佢所落到嘅任何一部伺服器崩潰,而由於頁面可以被重新整理,佢甚至可以觸及兩部伺服器。
「30 則訊息」一直都唔係一個真正嘅大小限制。佢只係一個按數量計嘅限制,而喺正常對話入面,恰好都係細數。
修正:頁面按大小以及數量限制
而家歷史嘅一頁係 30 則訊息,或者大約 4 MB 嘅訊息,以先到者為準。
流程分兩步:
- 先問大小。 API 向資料庫要求最新訊息嘅 ID 同儲存大小,但唔攞內容。呢個查詢無論訊息幾大都好快。
- 只載入放得落嘅嘢。 由最新到最舊逐條加入,直到下一則訊息會令該頁超過 4 MB 為止。然後先完整載入嗰啲資料列。
有兩條規則令呢個做法安全:
- 最新嘅一則訊息無論幾大都一定會放入該頁。 如果一頁返回零則訊息,對話就會卡住,冇辦法向上捲去睇更早嘅訊息。
- 冇任何嘢被截斷。 冇訊息會被截短或者丟棄。對話嘅其餘部分會透過「向上捲動查看較早訊息」控制項載入,每次一頁,而較早嘅訊息以前本身就係用呢個控制項。
我哋仲喺另一個地方發現咗同一種模式。當你停止一個回合,API 會檢查最近幾個回覆,睇有冇空嘅需要清理。佢以往會完整載入八則訊息去做呢個檢查。喺一個有幾 MB 回覆嘅對話入面,呢個就係同一個問題嘅細版本。而家佢只會問資料庫一個是或否嘅問題:呢則訊息有冇任何內容?而唔會載入內容本身。
點樣驗證
用嗰份對話副本,喺舊嘅 384 MiB 上限下運行:
- 第一頁係 5 MB。以前同一個請求會令 API 崩潰。
- 五個瀏覽器分頁同時開啟同一個對話,記憶體峰值為 154 MB。
- 一路捲回去睇整個對話,會返回全部 190 則訊息,每則只出現一次,次序正確。
選頁邏輯亦有單元測試,包括一個用該對話最新訊息嘅真實大小建立嘅測試。第一頁會返回一則訊息,並表示仲有更多,而下一頁會由其後一則開始。
我哋學到嘅嘢
- 數量唔等於大小限制。 任何返回「最新 N 個」而該事物可以無限增長嘅端點,都需要一個位元組預算。數據遲早會找到個缺口。
- 最明顯嘅嫌疑人未必係真正原因。 部署係一個真實嘅觸發因素,而增加記憶體亦確實值得。但崩潰喺冇部署嘅情況下都能重現;如果冇重現,我哋就會停在增加記憶體嗰一步。
- 兩部伺服器會因為收到相同請求而一齊失效。 冗餘可以防止一部伺服器死機,但防止唔到一個會擊倒任何被觸及伺服器嘅請求,尤其係用戶好自然會靠重新整理頁面去重試嘅請求。
- 喺設定入面修正,而唔單止喺機器上修正。 記憶體增加嘅改動當日就 commit 入部署設定,所以下一次部署都保留咗。
呢個係我哋近期第二個關於代理輸出嘅記憶體故事。第一個係一個狀態標籤排隊咗 690 MB 嘅字串。如果你喺 Mythex 用子代理,可以喺文件入面了解佢哋點樣運作。