Out of Memory 沒有想像中難發生
不知道是不是流年不利,今年碰到了幾次 OOM (Out of Memory) 的問題,這邊簡單記錄一下處理的狀況順便提醒一下自己。
狀況一:一直在累加查詢的結果
伺服器這端通常會設有 cache 避免直接查詢資料庫的次數過多,當資料有異動時,就會去找出相對應的 cache 並刪除,確保資料的正確性。以新聞列表API 為例,當後台修改新聞的標題或是摘要等內容時,就需要去找出這則新聞可能出現的列表快取作刪除。原本的做法是利用 Redis scan 批次找回所有符合的 cache key,再統一用 del 一次刪除,如以下 pseudocode 所示。
// pseudocode
$cursor = 0;
$keys = [];
do {
$result = Redis::scan($match, $cursor);
$cursor = $result['cursor'];
$keys = array_merge($keys, $result['keys']);
} while ($cursor !== 0)
Redis::del($keys);這段程式碼看起來很合理,也可以正常運行。但是當新聞列表種類一多或是頁數一多的時候,例如可能有分類列表、頭條列表、最新新聞列表,每個列表又都有好幾十頁,在這個情況下,我們找出來的 cache key 的數量就會無法估計,如果 cache key 又為了能夠方便識別而變成很長的字串的話,$keys 所佔用的記憶體就會越來越多,多到總有一天一定會 OOM。
所以比較好的做法應該會是一找到符合的 key 就做刪除,雖然會因此增加 Redis I/O 的次數,但還是比這種在不知到何時才會結束的迴圈內做 array_merge 要來的好 (如果想減少操作 Redis 的次數的話,scan 也可以帶入參數,指定找回的數量)。
// pseudocode
$cursor = 0;
do {
$result = Redis::scan($match, $cursor);
$cursor = $result['cursor'];
Redis::del($result['keys']);
} while ($cursor !== 0)把握一個原則就是:盡量不要在不能控制的條件下,無限制的一直累積資料。如果流程上允許的話,也不要等到結果全部都被查詢之後,才做下一步動作。
狀況二:垃圾就是會不小心堆積
之前在做一個排程,藉由 Laravel 的 queue/worker 每天固定去把幾萬個會不定期更新的檔案,從遠端地址下載下來,再上傳到 S3 上面。流程上是會先確認檔案是否有更新,有的話會將該檔案連結放進 job 丟到 queue 上,再由 worker 去處理每個 job 中的檔案下載和上傳 (php artisan queue:work queueName --daemon)。
開發的時候測試兩三個跑的很正常,但是一到正式環境上執行,就發現 worker 發生 OOM 了。一追之下才發現下問題不是發生在下載檔案,而是每次上傳之後,都會殘留一些垃圾在記憶體中,如果上傳的檔案稍大 (幾百MB) 的話,效果更加明顯。
嘗試了 unset(),寫法也試了 Laravel storage facades 和直接使用 AWS SDK,但對於 memory leak 仍然沒有改善。最後搜尋到了 AWS github issue 之後,發現似乎只能放棄在程式寫法上改善這件事。再加上 worker 是以 daemon 狀態在執行的,仍是屬於同一個 process,只能一直使用同一份大小的記憶體。因此最後是選擇在程式中呼叫 AWS 的命令列指令,將上傳檔案實際的工作從 PHP 丟出去給 shell 執行。之後,也就沒有再發生記憶體用完的問題了。不過這種 memory leak 的情況,好像也只能作為個案處理,一個一個看,沒有什麼統一的解法或是一些可以參考的準則。
