nodejs爬蟲記憶體洩露排查

yanjunz發表於2019-04-02

引子

最近在學推薦系統,萌生一個從頭實現一個推薦系統的想法。說做就開始著手,第一步先寫一個視訊爬蟲。

在網上找了一個有網頁的版的視訊聚合源,用nodejs+jsdom快速搭建了一個spider,爬取過程發現用併發的請求個數不好控制,太多容易把源網站爬掛了,就引入了async.parallelLimit和async.queue來做併發請求控制;另外看網上資料jsdom資源佔用比較多,cheerio更輕便,便切換到cheerio。

但執行一段時間之後發現記憶體漲的非常快,像是存在記憶體洩露問題。

遇到問題不要著急,先進行下邏輯分析,再通過工具去逐步確認自己的假設或找到更多可疑的地方,兩種方式不斷交叉最終確認問題。

分析流程

問題:爬蟲啟動之後記憶體快速增長。

  1. 根據之前分析記憶體洩露的經驗先仔細讀下程式碼,看看是否有容易出現記憶體洩露的程式碼。這種程式碼排查過,沒有可疑的地方。
  2. 引入的cheerio是否有記憶體洩露?快速網上查閱,有人有提及。換回jsdom快速試驗下,同樣出現。有可能是這2個庫本來就有記憶體問題或者是爬蟲邏輯上就存在記憶體的問題。
  3. 先通過工具判斷下爬蟲邏輯是否存在記憶體問題。js是記憶體自動管理,那看看主動gc有沒有效果。給node增加了--max_old_space_size=512 --gc_interval=100 --expose_gc,然後在程式碼裡面定時主動呼叫global.gc(),但記憶體還是飈的很快。
  4. 主動gc都沒法解決,那肯定是有記憶體洩露,使用heapdump,定時列印heapdump出來分析對比。

nodejs爬蟲記憶體洩露排查

nodejs爬蟲記憶體洩露排查
發現有大量的字串(網站的html)沒有被釋放,獲取網上html的地方有好幾處,通過二分查詢能定位到程式碼

nodejs爬蟲記憶體洩露排查
其中videoData就是儲存從網上獲取到的html。也就是說videoData沒有被正確釋放,根據之前做iOS的經驗,如果在一個大迴圈內產生很多臨時物件,但又沒有建立AutoReleasePool的話會直到這個Runloop結束才能被釋放,難道js也是這樣?查詢了下資料js的gc其實是很頻繁的,沒有這些限制,而且這個for迴圈裡面有await,有足夠的時機可以gc。那就想辦法看看能不能找到是哪一句導致的問題。 同時看到日誌裡面有請求的url,開啟一看是抓取蠟筆小新的視訊,其中有1600+集。直覺告訴我這應該是集數太多所以才容易出現這個問題,這肯定跟這個大迴圈有關。既然知道了一個復現的地址,改下單測的程式碼直接抓取這個頁面,同時通過--trace_gc來跟蹤下記憶體gc情況

node --trace_gc spider.js | grep Mark-sweep

nodejs爬蟲記憶體洩露排查
然後從程式碼405行開始增加continue來進行定位,這個時候記憶體變得非常穩定

nodejs爬蟲記憶體洩露排查

發現在直到415行之後新增continue,記憶體又開始漲得很厲害了。所以可以定位是415行這句程式碼導致了記憶體洩露。415行就一個tvLink的賦值為啥會導致記憶體洩露呢?處於好奇就這414行列印了一句

console.log("tvLink=", tvLink)

nodejs爬蟲記憶體洩露排查

神奇的事情發生了,再次跑的時候記憶體又不暴漲了,記憶體洩露問題解決了。諮詢了下同事super大神,思路切換到既然知道videoData沒有被釋放掉,那就看看是誰retain著他?切換到Chrome的Profiler,可以點選字串看到誰retain著這些字串。

nodejs爬蟲記憶體洩露排查
看到是一個陣列retain著這些物件,然後在這個陣列上Review in summary view

nodejs爬蟲記憶體洩露排查

可以看到href是一個sliced string,記得之前看一篇文章說過sliced string導致的記憶體不釋放的問題,頓時明白了,sliced string顧名思義就是他不實際儲存字串,而是儲存他在父字串的startOffset和len 所以href其實就是videoData的sliced string,這也是為啥videoData不能在迴圈的時候雖然不用了但還是不能被釋放。但只要console.log就能迫使sliced string提取出確切的值,既然提取出值後面也沒必要再儲存成sliced string,所以記憶體洩露的問題也就解決了。附錄還有一篇super大神寫的SliceString的文章。 可以理解sliced string其實是為了優化字串使用,但在我這個特定場景確會產生記憶體不能被快速釋放的問題。準確的講這不算是一個記憶體洩露的問題,而是一個記憶體堆積的問題。那有啥辦法可以規避sliced string引入的問題呢?經同事建議,只要對這個字串進行操作就能flatten sliced string,比如輕量的parseInt,而console.log其實也是一種,但不建議。

總結

  1. 對js底層的字串機制得了解清楚,這個道理對於其他語言也一樣。比如很多語言都有sliced string機制
  2. 可測性,不一定都有時間寫單測,但儘量保證關鍵步驟都是拆分成可以獨立測試的函式
  3. 如果有大迴圈,一定要注意哪些地方是sliced string,如果是的話執行必要的flatten操作,以便記憶體能及時釋放
  4. 不建議著急用工具除錯,有bug的程式碼都有規律,可以先通讀程式碼確保邏輯上沒有明顯的問題,這樣能提高效率;工具分析為輔助,好的工具像利器,得熟練掌握。

參考

相關文章