記一次生產問題的排查,讓我領略了演算法的重要性

孤舟蓑翁發表於2022-05-03

前段時間,客戶反饋,有個PC端的功能頁面,一點開就卡死,通過檢視網路請求,發現有個部門組織架構樹的請求資料有點大,共有兩萬條資料,1.57M。剛開始我以為是表單中的部門選擇框渲染的時候,一次性渲染的dom節點過多,把頁面記憶體撐爆了。於是我把專案中使用的antd3的TreeSelect元件,升級到具有無限滾動載入功能的antd5版本,始終只渲染10條資料,按理說頁面卡死的問題應該就消失了。結果頁面操作幾次之後,頁面仍舊百分之百會崩掉, 頁面卡死問題並未徹底解決。

於是我沉下心來,把出問題的頁面的邏輯從頭到尾看了一遍,發現有一處採用遞迴方式查詢某個部門id在不在部門樹之中的邏輯,可能存在效能問題。沒優化之前的寫法是這樣的:


const findTreeItem = (data, id) => {
  for (let i = 0,len=data.length; i < len; i++) {
    let item = data[i];
    if (item.id === id) {
      return true;
    } else {
      if (item.children) {
        if (findTreeItem(item.children, id)) {
          return true;
        }
      }
    }
  }
};
const isInclude = findTreeItem(treeData,deptId);

這種寫法的缺點是,當樹的層級很深時,可能會引起暴棧。讓我們分析一下這種遞迴演算法的空間複雜度。假設要判斷id="1-1-1-0"是否存在於treeData中

const treeData = [
  {
    id: "0",
    children: [
      {
        id: "1-0",
        children: [
          {
            id: "1-0-0",
            children: [
              {
                id: "1-0-0-0",
              },
              {
                id: "1-0-0-1",
              },
              {
                id: "1-0-0-2",
              },
              {
                id: "1-0-0-3",
              },
            ],
          },
          {
            id: "1-0-1",
          },
          {
            id: "1-0-2",
          },
        ],
      },
      { id: "1-1" },
    ],
  },
];

我們想知道,在遞迴呼叫的過程中,最大的記憶體佔用量。那就要對遞迴呼叫進行拆解,每一次遞迴函式呼叫自己,會佔用多少記憶體空間,從方法 findTreeItem(treeData,'1-1-1-0') 呼叫方法 findTreeItem(treeData[0].children,'1-1-1-0') 時,將建立findTreeItem(treeData[0].children,'1-1-1-0') 相對應的堆疊幀。該堆疊幀將保留在記憶體中,直到函式對findTreeItem(treeData[0].children,'1-1-1-0') 的呼叫終止。該堆疊幀負責儲存函式findTreeItem(treeData[0].children,'1-1-1-0') 的引數,函式findTreeItem(treeData[0].children,'1-1-1-0') 中的區域性變數以及呼叫方函式findTreeItem(treeData,'1-1-1-0')的返回地址。接著,當此函式 findTreeItem(treeData[0].children,'1-1-1-0') 呼叫函式 findTreeItem(treeData[0].children[0].children,'1-1-1-0') 時,也會生成findTreeItem(treeData[0].children[0].children,'1-1-1-0') 相對應的堆疊幀,並將其保留在記憶體中,直到對findTreeItem(treeData[0].children[0].children,'1-1-1-0') 的呼叫終止。呼叫 findTreeItem(treeData[0].children[0].children,'1-1-1-0') 時,堆疊框架的呼叫堆疊如下所示:

image.png

當呼叫到 findTreeItem(treeData[0].children[0].children[0].children,'1-1-1-0') ,執行完畢,返回對函式 findTreeItem(treeData[0].children[0].children,'1-1-1-0') 的呼叫時,由於不再需要findTreeItem(treeData[0].children[0].children,'1-1-1-0') 相對應的堆疊幀,js引擎將從記憶體中刪除該堆疊幀。函式 findTreeItem(treeData[0].children,'1-1-1-0')和函式 findTreeItem(treeData,'1-1-1-0') 的堆疊幀也是如此。

  通過分析可以看出遞迴演算法的空間複雜度與所生成的最大遞迴樹的深度成正比。如果遞迴演算法的每個函式呼叫都佔用 O(m) 空間,並且遞迴樹的最大深度為 n,則遞迴演算法的空間複雜度將為 O(n·m)。

從performance屬性可以知道,一個頁面可以使用的記憶體量級是30M左右,假如2萬多條資料佔用1.5M左右記憶體空間,最理想的情況下,能支撐的遞迴深度也就20級左右,實際上要減去儲存程式碼佔用的空間,儲存基本型別資料和引用型別引用地址,儲存引用型別佔用的空間,三下五除二,留給遞迴方法使用的空間就所剩無幾了。無怪乎會造成頁面卡死。

image.png

於是對上面的查詢方法進行了一番優化,將深度遍歷優先改成廣度遍歷優先,頁面出現卡死的問題徹底解決。

findTreeItem(tree, curKey, keyField, childField, node = null) {
  const stack = [];
  for (const item of tree) {
    if (item) {
      stack.push(item);
      while (stack.length) {
        // 重點是這裡--邊查詢邊釋放記憶體空間
        const temp = stack.pop();

        if (temp[keyField] === curKey) {
          node = temp;
          break;
        }

        const children = temp[childField] || [];
        for (let i = children.length - 1; i >= 0; i--) {
          stack.push(children[i]);
        }
      }
    }
  }
  return node;
}

當資料量比較小的時候,好的演算法與差的演算法,沒有致命的差別。當資料量比較大的時候,演算法的優劣,有天壤之別。所以平日在寫資料處理邏輯的時候,要對資料處理的演算法,保持一定的敏感度。之前對好的演算法的優勢,僅僅停留在概念和理論上,實際感受不太深切。就好比讀了好多書,卻依然過不好這一生。以為對書中的道理,看過一遍,知道了就等於懂了。實際上真正要用到的時候,大概率想不起來。因為沒有特別深刻的感性認知。這次遭遇到生產問題的毒打之後,讓我感受到了好的演算法與壞的演算法,質的差別,演算法還是要重視起來。

相關文章