<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          Node 案發(fā)現(xiàn)場揭秘 —— 未定義 “window” 對象引發(fā)的 SSR 內(nèi)存泄露

          共 9358字,需瀏覽 19分鐘

           ·

          2022-02-26 20:09

          大廠技術(shù)  高級前端  Node進(jìn)階

          點(diǎn)擊上方 程序員成長指北,關(guān)注公眾號

          回復(fù)1,加入高級Node交流群


          泄露現(xiàn)象

          在生產(chǎn)中遇到一枚全新的內(nèi)存泄露場景,因?yàn)樯婕暗较鄬ζ俚?react ssr,因此記錄下排查的過程作為以后相關(guān)的 vue / react ssr 場景內(nèi)存問題參考。

          先來看下現(xiàn)象,內(nèi)部某應(yīng)用當(dāng)天 10 點(diǎn)開始內(nèi)存就一直不停往上漲,一直到凌晨超過閾值觸發(fā)告警:

          經(jīng)過相關(guān)業(yè)務(wù)同學(xué)通過觀察了各個(gè)接口的請求訪問量,發(fā)現(xiàn)有一個(gè)實(shí)人認(rèn)證的頁面訪問量也在當(dāng)天 10 點(diǎn)突增:

          于是判斷是業(yè)務(wù)放量導(dǎo)致此頁面的請求量上漲,而這個(gè)頁面的 ssr 可能存在問題導(dǎo)致了應(yīng)用的內(nèi)存泄露,并且與這個(gè)頁面相關(guān)有大量 window is not defined 的錯(cuò)誤信息:

          server render bundle error, try client render, the server render error is:
          ReferenceError: window is not defined.

          這個(gè)錯(cuò)誤其實(shí)也很容易理解,因?yàn)?ssr 場景下一部分頁面邏輯構(gòu)造會(huì)在服務(wù)端執(zhí)行,這里顯然是編寫對應(yīng)代碼的時(shí)候沒有處理好 window 對象,導(dǎo)致在 ssr 的時(shí)候報(bào)錯(cuò)。


          猜想修復(fù)

          對于業(yè)務(wù)來說,第一優(yōu)先級需要做的是線上止血,內(nèi)存泄露的原因暫時(shí)不明,但是伴隨泄露的大量錯(cuò)誤是擺在這邊的,因此業(yè)務(wù)同學(xué)第一反應(yīng)是先修復(fù)掉這個(gè) window is not defined 錯(cuò)誤來觀察效果:

          重新發(fā)布后經(jīng)過一天觀察,這個(gè)錯(cuò)誤修復(fù)后,內(nèi)存趨勢確實(shí)穩(wěn)定了下來。

          雖然看似修復(fù)了這個(gè)泄露問題,但是一線同學(xué)都知道,不怕出問題,就怕問題莫名其妙修復(fù),所以在線上穩(wěn)定的情況下,接下來通過本地壓測這個(gè)疑似泄露的人臉識別頁面來復(fù)現(xiàn)當(dāng)時(shí)的場景,進(jìn)行更加深入的故障原因探究。


          第一次分析

          既然是內(nèi)存泄露,那肯定是先抓堆快照進(jìn)行分析,在線下復(fù)現(xiàn)的應(yīng)用上通過工具獲取到泄露場景復(fù)原時(shí)對應(yīng)的堆快照,進(jìn)行分析:

          第一眼看到這個(gè)泄露支配樹視圖的時(shí)候就猜測是類似 輕松排查線上 Node 內(nèi)存泄露問題[1] 中提到的 閉包引用導(dǎo)致的泄露,因?yàn)檫@種類型的泄露原始引用鏈路是非常長的,要靠完全展開看細(xì)節(jié)比較難。

          在這個(gè)圖又可以發(fā)現(xiàn)每一層上下文中都具有一些特征的context類型變量:

          mobileRealNameAuth.js 正是這個(gè)人臉識別頁面對應(yīng)的 react ssr 組件,由業(yè)務(wù)編寫的 tsx 打包而成,首先還是考慮在這個(gè) js bundle 中通過上述的context關(guān)鍵字比對來看看是哪里產(chǎn)生了循環(huán)持有context 的泄露。

          可惜的是這個(gè) bundle 因?yàn)楣惨蕾嚾即蛄诉M(jìn)去,有數(shù)萬行代碼,因此混淆后基本不具備可讀性,艱難嘗試了一會(huì)遂選擇放棄。

          到這里這個(gè)泄露問題似乎陷入了僵局,已經(jīng)知道是泄露產(chǎn)生的原因看起來是 ssr 的時(shí)候在重復(fù)引入mobileRealNameAuth.js這個(gè)打包文件,但是不知道業(yè)務(wù)源頭是哪里。


          第二次分析

          因?yàn)榉治龆芽煺瘴募萑肓私┚?,因此開始嘗試各種比如業(yè)務(wù)邏輯屏蔽縮小問題范圍等隔靴搔癢的方法,然而均無果,不得不繼續(xù)回來啃堆快照。

          這時(shí)候可能是排查這個(gè)問題時(shí)間比較長,突然靈光一閃,既然混淆后的context變量名不具備可讀性,那我們可以 在 webpack 配置中修改只打包不混淆變量名 不就可以了。

          相關(guān)的業(yè)務(wù)同學(xué)相當(dāng)高效,將minimize: false 打包配置修改后迅速發(fā)上線重新進(jìn)行壓測,果然這次抓到的堆快照信息充足很多:

          至此其實(shí)這里具體產(chǎn)生閉包引用的元兇已經(jīng)找到,正是lodash模塊在重復(fù)生成,接下來我們要進(jìn)一步分析下lodash 這樣的一個(gè)公共模塊,為什么會(huì)造成內(nèi)存泄露。


          定位原因

          新的快照中context變量名是oldDash,這是一個(gè)比較關(guān)鍵的信息。

          繼續(xù)查看未混淆的線上打包后mobileRealNameAuth.jslodash 模塊的引入邏輯,此文件約 3.6w 行代碼,經(jīng)過分析抽象 lodash在其中的抽象邏輯可以簡化為:

          ;(function ({
            // 這里的 root 在 ssr 服務(wù)端就是 global 對象
            var freeGlobal = typeof global == 'object' && global && global.Object === Object && global;
            var freeSelf = typeof self == 'object' && self && self.Object === Object && self;
            var root = freeGlobal || freeSelf || Function('return this')();

            var oldDash = root._;

            function noConflict({
              if (root._ === this) {
                root._ = oldDash;
              }
              return this;
            }

            var runInContext = (function runInContext(context{
              function lodash({
              }

              // lodash 的各種公共方法...
              lodash.after = function ({ };
              lodash.ary = function ({ };

              return lodash;
            });

            var _ = runInContext();

            root._ = _;
          }).call(this);

          抽象完 bundle 中 lodash 的引入邏輯,泄露原因就很清晰了,noConflict雖然沒有被執(zhí)行,但是它將oldDash加入到了頂層context中,這會(huì)導(dǎo)致lodash實(shí)例的方法函數(shù)指向的閉包對象鏈路也包含oldDash。

          這樣mobileRealNameAuth.js每次被重復(fù)執(zhí)行,都會(huì)創(chuàng)建一個(gè)新的lodash對象,且其實(shí)例方法的閉包對象又會(huì)指向上次創(chuàng)建的lodash對象,從而造成泄露。

          簡單說明下為什么lodash的實(shí)例方法中沒有使用到oldDash也會(huì)在其對應(yīng)的閉包中存在oldDash的引用,這是因?yàn)?v8 的閉包對象實(shí)現(xiàn)本質(zhì)上是一個(gè)FixedArray鏈表,而不是每個(gè)作用域提供單獨(dú)的context 的實(shí)現(xiàn)方式。


          驗(yàn)證泄露

          為了讓上面的原因定位更加具有說服力,我們來以上面簡化的lodash 重復(fù)加載,通過觀察堆內(nèi)存狀態(tài)驗(yàn)證下是不是真的因?yàn)?code style="font-size: 14px;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(92, 157, 255);">noConflict導(dǎo)致的循環(huán)上下文持有,首先編寫 leak.js

          // leak.js
          const lodash = function ({
            var root = global;

            var oldDash = root._;

            // 添加 oldDash 進(jìn)閉包的元兇
            function noConflict({
              if (root._ === this) {
                root._ = oldDash;
              }
              return this;
            }

            var runInContext = (function runInContext(context{
              function lodash({
              }

              // 方便看到內(nèi)存狀態(tài),給 lodash 添加一個(gè)大字符串
              lodash.longStr = new Array(1000000).fill('*');
              lodash.after = function ({ }

              return lodash;
            });

            var _ = runInContext();

            root._ = _;
          }

          // 每 100ms 生成一個(gè) lodash
          setInterval(() => lodash.call(this), 100);

          // 1s 一次打印堆狀況
          setInterval(() => {
            const { heapUsed } = process.memoryUsage();
            const used = heapUsed / 1024 / 1024;
            console.log('------'`${used.toFixed(2)}MB...`);
          }, 1000);

          接著執(zhí)行node \--max-old-space-size=1024 leak.js,可以通過 1s 輸出的堆內(nèi)存狀態(tài)日志,看到此時(shí)堆內(nèi)存完全無法回收,一路上漲很快就 OOM。

          接著我們斷開noConflictoldDash的引用繼續(xù)進(jìn)行觀察:

          // leak.js
          const lodash = function ({
            var root = global;

            var oldDash = root._;

            // 注釋點(diǎn)開對 oldDash 引用
            function noConflict({
              // if (root._ === this) {
              //   root._ = oldDash;
              // }
              // return this;
            }

            var runInContext = (function runInContext(context{
              function lodash({
              }

              // 方便看到內(nèi)存狀態(tài),給 lodash 添加一個(gè)大字符串
              lodash.longStr = new Array(1000000).fill('*');
              lodash.after = function ({ }

              return lodash;
            });

            var _ = runInContext();

            root._ = _;
          }

          // 每 100ms 生成一個(gè) lodash
          setInterval(() => lodash.call(this), 100);

          setInterval(() => {
            const { heapUsed } = process.memoryUsage();
            const used = heapUsed / 1024 / 1024;
            console.log('------'`${used.toFixed(2)}MB...`);
          }, 1000);

          依舊執(zhí)行 node --max-old-space-size=1024 leak.js,觀察到斷開閉包對 oldDash 對象的引用后,內(nèi)存可以被正?;厥眨?/p>

          ------ 64.40MB...
          ------ 102.82MB...
          ------ 25.99MB...
          ------ 26.16MB...
          ------ 26.15MB...
          ------ 25.91MB...
          ------ 18.52MB...
          ------ 18.52MB...

          這樣我們就確認(rèn)了:即使noConflict沒有被任何地方調(diào)用,只要它存在就會(huì)將oldDash添加到頂層閉包對象中造成循環(huán)持有上一次lodash實(shí)例,進(jìn)而導(dǎo)致內(nèi)存泄露。


          為什么重復(fù)

          到這里還沒結(jié)束,快照中的mobileRealNameAuth.js泄露產(chǎn)生的原因找到了,那么下一個(gè)問題其實(shí)是為什么這個(gè)app/view下的打包后的頁面文件會(huì)被重復(fù)執(zhí)行呢?

          這個(gè)問題涉及到 react ssr 的實(shí)現(xiàn),因?yàn)榇隧?xiàng)目為一個(gè) Egg.js[2] 的應(yīng)用,因此我們接下來去跟蹤下對應(yīng)的 render 方法實(shí)現(xiàn)插件 egg-view-react-ssr[3] 的具體實(shí)現(xiàn):

          async render(name, locals, options) {
            const reactElement = require(name);
            return this.renderElement(reactElement, locals, options);
          }

          name其實(shí)對應(yīng)的就是mobileRealNameAuth.js在服務(wù)器上的全路徑。

          問題又來了,Node.js 實(shí)現(xiàn)的 CommonJS 規(guī)范中對于require模塊的結(jié)果是有緩存的,也就是mobileRealNameAuth.js并不應(yīng)該被重復(fù)多次編譯執(zhí)行。

          回想下最開始看到的 window is not defined 錯(cuò)誤信息:

          server render bundle error, try client render, the server render error is:
          ReferenceError: window is not defined.

          可以發(fā)現(xiàn)由于人臉識別的前端 tsx 代碼最終是和lodash等依賴模塊一起打包進(jìn)mobileRealNameAuth.js的,且lodash等公共依賴先執(zhí)行,tsx 業(yè)務(wù)邏輯后執(zhí)行。

          這樣 tsx 打包后的 js bundle 拋錯(cuò),導(dǎo)致了require('/path/to/mobileRealNameAuth.js')始終沒有執(zhí)行成功生成 CommonJS 緩存。

          因此用戶每訪問一次人臉識別頁面業(yè)務(wù)邏輯前的lodash等公共模塊就會(huì)重新編譯加載一次,最后因?yàn)樯厦娣治龅玫降拈]包對象循環(huán)持有 oldDash 產(chǎn)生了內(nèi)存泄露。


          修復(fù)問題

          其實(shí)分析到這邊,整個(gè)泄露產(chǎn)生鏈條和原因都非常清晰了,修復(fù)就變得很容易,只要將 tsx 中在 ssr 服務(wù)端生命周期執(zhí)行的 window 未定義的進(jìn)行一些處理不讓其出錯(cuò)即可。

          更進(jìn)一步的,可以在業(yè)務(wù)中加入 CI 校驗(yàn),對每一個(gè)前端 ssr entry 入口打包后的 js bundle 在 CI 中進(jìn)行一次 require 保證其可以被正常加載緩存起來防止重復(fù)加載。


          一些總結(jié)

          這次的問題是在實(shí)際生產(chǎn)中遇到了比較經(jīng)典的閉包泄露模型,對于 vue/react ssr,除了要注意在服務(wù)端 ssr 生命周期能觸發(fā)的鉤子內(nèi)不要?jiǎng)?chuàng)建資源屬性的內(nèi)容防止內(nèi)存泄露,也要尤其注意到這類的執(zhí)行錯(cuò)誤可能引發(fā)公共模塊重復(fù)編譯導(dǎo)致的閉包泄露。

          關(guān)注我們

          我們將為你帶來最前沿的前端資訊。

              
          Node 社群



          我組建了一個(gè)氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學(xué)習(xí)感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。



          如果你覺得這篇內(nèi)容對你有幫助,我想請你幫我2個(gè)小忙:

          1. 點(diǎn)個(gè)「在看」,讓更多人也能看到這篇文章
          2. 訂閱官方博客 www.inode.club 讓我們一起成長

          點(diǎn)贊和在看就是最大的支持??

          參考資料

          [1]

          輕松排查線上 Node 內(nèi)存泄露問題: https://cnodejs.org/topic/58eb5d378cda07442731569f

          [2]

          Egg.js: https://eggjs.org/

          [3]

          egg-view-react-ssr: https://github.com/easy-team/egg-view-react-ssr/blob/master/lib/engine.js#L70-L73

          瀏覽 107
          點(diǎn)贊
          評論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評論
          圖片
          表情
          推薦
          點(diǎn)贊
          評論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  99视频+国产日韩欧美 | 97香蕉久久国产超碰青草专区 | 五月色影音先锋 | 成人视频网站在线观看18 | 亚洲秘 无码一区二区三区胖子 |