記一次Vue全頁面SSR深坑之旅 – 微弱的記憶體/CPU洩漏

YaHuiLiang(Ryou)發表於2019-03-04

如果我跟你說,我面試來這家的時候,面試題就是這個問題你會作何感想?估計一般人是不會進坑的。然而,我進來了。因為我覺得這種技術問題很好玩。僅此而已。否則工作會很無聊。

前言

  • 其實你沒啥必要解決這個bug,因為國內很多公司每週一個版本,所以壓根兒就察覺不到這個bug的存在。
  • 其實你大可不必解決這個bug,因為你寫一個定時自動重啟指令碼,在一個夜深人靜的夜晚默默執行重啟之。
  • 其實你不用非得解決這個bug,因為百度也開始支援spa系統seo,你還在那裡累死累活搞蹩腳的ssr幹嘛。

如果你像我一樣覺得無聊,那麼就往下看吧。對不對不知道。反正在我本地測試,大部分的問題都已經KO了。

緣起一次換工作

面試的時候面試官提出這樣一個問題,他們的系統出現了一個奇怪的現象,基於Vue的SSR系統出現了CPU緩慢上升,不得不隔段時間重啟一次。問我解決思路是什麼?

嗯?CPU上升?是否有記憶體洩漏?是否每個請求都返回了?是否有阻塞的IO操作?如果是Express是否都執行了返回?緩慢上升,是什麼樣的幅度?QPS是多少?伺服器負載是否合理?

然後我順利的拿到了Offer,入職後給我的第二個任務就是解決這個技術問題。看到這裡是不是覺得我被套路了?哈哈哈哈,但是我就是喜歡這種挑戰。很好玩,否則工作會很無聊。不過對於這種技術調查很難短時間出現成果物,對於我也是很危險的一件事情。而且,嗯。。。。。。也有卸磨殺驢的可能。誰知道呢。反正這是一件很好玩的事情。管他呢。

問題是否真的如描述那樣?

在解決一個技術難題的時候,我們往往得到的是遇到問題的人描述的表現,而實際問題的表現並不一定如描述者所說。

遇到效能問題,我們要充分了解問題的本質是什麼?僅僅是CPU緩慢上漲?現代的SPA框架都有嚴重消耗CPU的問題,是不是伺服器叢集能力不足?是否伴隨記憶體洩漏?是否有掛起的請求沒有返回?這些疑問在我的腦海翻來覆去。

直到我看到系統,看到原始碼,登上了伺服器,看到了各種伺服器監控資料的時候,好傢伙。有點意思,讓我越加亢奮。

問題:

  • CPU週期性上升,偶有下降,但是總體趨勢是上升。週期在2周左右到達80%以上的佔用率。
  • 記憶體每天會有一小部分的洩漏,非常少。也會有釋放。總體趨勢是每天在500M左右。
  • 每天訪問量在有活動的時候會有大範圍波動,但是整體比較平穩,不過日誌系統只保留最近7天日誌,造成從日誌分析原因有點困難。出問題的那幾天資料已經沒了。
  • 後端系統,在程式碼層面,如果沒有重大程式碼邏輯問題,程式碼優化帶來的效能提升是有限的。

第一個彎路

光從訪問日誌和描述者描述問題來看,在CPU居高不下的那幾天恰好有訪問高峰。而且從訪問量降低的時候CPU使用率也是有明顯降低的。於是根據那幾天CPU高峰時段的使用者流量來判斷應該是伺服器負載不足,沒有頂住流量高峰。
於是拿著這個調查結果去找Leader。Leader也接受了。畢竟從資料層面是說的通的。而且在這面諮詢了運維同事,他們也覺得是這樣。而且當時確實有一個很大的流量高峰持續了幾個小時。

但是,在接下來的幾天觀察發現,流量沒有那麼巨大的時候,依然會有緩慢的上升趨勢,只是比流量高峰時段上漲的慢一些。因此第一次的調查結果宣佈不對。

第二個彎路

根據經驗分析造成CPU緩慢上漲而不能明顯下降現象大多是因為有程式碼片段被掛起,無法釋放。對於Nodejs來說無非就是幾種:1 setTimeout,2 阻塞IO,3 express沒呼叫res.end()結束請求。

開始做程式碼code review,發現整個專案都是基於官方vue-hackernews2.0來構建的。從程式碼上面問題不大。那麼可能是阻塞IO?

於是找運維同學get到如何檢視活動網路連結,對本地環境進行壓測。然後停止半小時以後檢視連結情況(因為作業系統為了優化io使用並不會在你操作結束後馬上釋放連結,所以要等待一會)。

壓測後結果很是震驚,由於測試環境後端介面效能極差,導致超多請求被掛起。而這個時候被阻塞的socket連結也非常多,記憶體飆升,CPU一直沒有明顯下降。哈哈哈問題找到了(高興太早了)。

於是去找運維協商是否有手段在伺服器上設定斷開長期無響應的連結。運維很無奈。。。。。。

好吧。還得自己來。為什麼會掛起這麼多連結?查閱資料,發現有這麼一個現象存在:在伺服器超載的情況下,由於無法做出響應,客戶端的socket就會被掛起一直處於connection狀態。

我去問了專案開發負責人,說他們設定了超時處理,並不會引起這種狀況。。。。。。

但是我在log日誌明明看到了很多200s以上才返回的請求。。。。。。說明我們程式碼設定的超時並沒有起作用。於是我需要找到足夠的證據來說服他。

有時候我們在溝通的時候,對方並不信任你觀點,其實是源於你的證據不充分,那麼這個時候,你就需要找到具有足夠說服力的證據來證明你的觀點。

於是深挖Nodejs文件,跟專案程式碼,發現axios的這塊實現有問題:

    if (config.timeout) {
      timer = setTimeout(function handleRequestTimeout() {
        req.abort();
        reject(createError(`timeout of ` + config.timeout + `ms exceeded`, config, `ECONNABORTED`, req));
      }
    }
複製程式碼

這裡程式碼看起來是沒任何問題的,這是在前端處理中一個很典型的超時處理解決方式。

由於Nodejs中,io的連結會阻塞timer處理,因此這個setTimeout並不會按時觸發,也就有了10s以上才返回的情況。

貌似問題解決了,巨大的流量和阻塞的connection導致請求堆積,伺服器處理不過來,CPU也就下不來了。

在Nodejs官方文件中提到:

If req.abort() is called before the connection succeeds, the following events will be emitted in the following order:

- socket
- (req.abort() called here)
- abort
- close
- error with an error with message Error: socket hang up and code ECONNRESET
複製程式碼

於是我給axios提了PR,解決辦法就是利用socket中對於connect的超時處理來代替會在Nodejs中被阻塞的setTimeout來處理超時請求。這個問題在node-request中也存在。而且經過本地大量測試,發現在高負載下CPU和記憶體都在正常範圍內了。以為一切都OK了。

    if (config.timeout) {
      // Sometime, the response will be very slow, and does not respond, the connect event will be block by event loop system.
      // And timer callback will be fired, and abort() will be invoked before connection, then get "socket hang up" and code ECONNRESET.
      // At this time, if we have a large number of request, nodejs will hang up some socket on background. and the number will up and up.
      // And then these socket which be hang up will devoring CPU little by little.
      // ClientRequest.setTimeout will be fired on the specify milliseconds, and can make sure that abort() will be fired after connect.
      req.setTimeout(config.timeout, function handleRequestTimeout() {
        req.abort();
        reject(createError(`timeout of ` + config.timeout + `ms exceeded`, config, `ECONNABORTED`, req));	
      }
    }
複製程式碼

然而。。。。。。我又錯了。

有一天我忘記了關電腦,本地壓測的環境還在跑,第二天驚奇的發現,所有被掛起的socket資源都被釋放了。但是記憶體,CPU依然沒有被回收。關於這一點我求證了運維同事,確實作業系統會自動處理掉這些長時間不活動的連結。雖然我通過修改axios原始碼的方式解決了問題,但是貌似問題的本質原因並沒有找對。

一次偶然發現vue-router中的“騷”處理

實在沒有頭緒,廢了幾天勁貌似都沒有抓到問題的根本原因,雖然誤打誤撞解決了問題。但是這種解決問題方式對於是否能夠根除問題會有一定的不確定性。

利用inspect反覆的去分析系統的記憶體,由於線上流量非常巨大,但是記憶體和CPU的洩漏很小,而本地難以復現這麼大的訪問量,所以本地復現非常難,加上JS的GC方式,在調查上難度很大。只能一個請求一個請求後反覆對比記憶體映象查詢哪怕一絲絲線索。

而對於CPU,那更是難以跟蹤,線上每天CPU增長在每小時0.02左右。也就意味著平均一次請求對於CPU洩漏的影響微乎其微,而一旦進行大規模的請求測試,對於記憶體的跟蹤就不準確了。

可能這個時候就是年齡大的程式設計師的優勢了,可以沉得住氣,耐得住性子去查詢問題的。有的時候解決一個技術問題並不需要你有多麼強的技術,解決問題的方式,以及耐心才是主要的。

在一次偶然發現,發起一個請求後,記憶體映象中總是會出現一個timer。然後下一次抓取記憶體映象又釋放了一個timer。What the fxxk?什麼鬼。

而這個timer卻沒有什麼明顯資訊去告訴我是在哪裡被建立的。再一次陷入崩潰。

難道這就是那個造成記憶體洩漏的根源?timer佔用資源非常小,而且是非同步,並不會阻塞系統,所以並不會像死迴圈那樣導致CPU長期處於高位執行。貌似,這個timer才是問題的根源。

好在Nodejs的所有api介面都是js實現的,於是直接在setTimeout裡面打斷點跟蹤程式碼。。。。。。果然是大力出奇跡。發現了vue-router中的騷操作

function poll (
  cb, // somehow flow cannot infer this is a function
  instances,
  key,
  isValid
) {
  if (instances[key]) {
    cb(instances[key]);
  } else if (isValid()) {
    setTimeout(function () {
      console.log(`vue-router poll`);
      poll(cb, instances, key, isValid);
    }, 16);
  }
}
複製程式碼

是的,沒錯,這是一個死迴圈的timer。instances是什麼?通過程式碼應該是對應的非同步元件例項,而key是對應的元件在例項陣列中的鍵值。而退出條件只有2個:1 非同步元件載入完成,2 路由發生改變。

但是在ssr的場景下,路由發生改變在每一個請求的過程中是不會發生的。因此退出條件就只剩下了非同步元件載入完成。但是處於某種原因,它沒載入成功。導致這個timer就陷入了死迴圈。而且前提是需要在元件裡面實現了beforeRouteEnter這個守衛函式。

由於vue-router程式碼的實現太騷了。只能求助萬能的github。發現了這個issue

和我的情況完全吻合。但是對於member的回覆有一些心寒。通過題主的簡單設定已經可以完美的復現問題了。團隊卻直接以“A boiled down repro instead of a whole app would help to identify the problem, thanks”為由給close了。。。。。。

而更加可氣的是:

> A boiled down repro instead of a whole app would help to identify the problem, thanks

if you have an infinite loop, it`s probably next not being called without arguments  《= 以為我們都是傻子嗎?不知道調next?
複製程式碼

好吧。看來既然上了賊船就只能靠自己了。我和題主溝通後開始嘗試解決問題。但是經過幾天努力題主已經放棄了。而我。。。。。。也選擇了放棄(別把我看那麼高大上,說實話,看了幾天vue-router原始碼。真的沒有找到好的解決辦法,主要是會修改很多東西。)。

解決方案

在vue-ssr中造成記憶體和cpu洩漏的原因目前我所調查的結果就是這麼兩個原因:

  1. 掛起的socket造成暫時性的堵塞
  2. vue-router中的timer在某些情況下會陷入死迴圈
  3. 大量的模板編譯,記憶體中會存留大量被字串佔用的記憶體

那麼如何解決呢?

  • 移除component中對於beforeRouteEnter的處理。將這裡的處理移到其他地方,從vue-router程式碼層面分析是可以避免陷入timer的死迴圈的。
  • 在nodejs中替換掉setTimeout的方式去處理伺服器端請求超時,改用http.request的timeout事件handle來處理。防止io阻塞timer處理。
  • 如果不是對seo要求過高,採用骨架頁渲染的方式,向客戶端渲染出骨架頁,然後由前端直接發起ajax請求拉取伺服器資料。避免在nodejs端執行服務端請求由於服務端後臺無法響應造成堵塞導致部分連結被掛起。(nodejs的事件迴圈和瀏覽器是不同的,雖然都是基於V8引擎。這也是大部分國內網際網路公司在vue-ssr這塊的普遍應用方式)

也許還有

我對vue-ssr只研究了2周,如果以上有疑問歡迎及時提醒我進行改正。

相關文章