本文為開源書籍《JavaScript 內存調試技巧與泄露分析》第一章節,本書採用「保持署名—非商用」創意共享4.0許可證。如有轉載,請註明作者和出處。

andycall/master-of-javascript-memory?

github.com圖標

內存泄露這類問題的一大特點是它在開發的過程中難以發現。大部分內存泄露問題的發現都是在生產環境階段發現的,因為內存泄露在通常情況下,並不會影響應用的功能,直到應用運行時間足夠長,請求或者操作足夠多的話,問題將會暴露,同時也會帶來一些損失。而且讓開發者更頭疼的是,即使發現的應用存在內存泄露,由於缺乏充足的理論知識和調試方法,導致泄露的原因也很難定位。

本demo選取的是一個簡單的node.js程序,來模擬一次伺服器內存泄露導致應用崩潰的事件,並介紹在發現問題之後的一些基本的調試思路和實戰方法。如果你對內存泄露的定位一點都不了解的話,本章內容將會給你建立一個基本的調試概念,以便利於後面對問題的詳細分析。

一個內存泄露的HTTP伺服器

本demo的代碼都可以在github上進行獲取:獲取地址

小A在某互聯網公司工作,負責一些線上運營活動的後端開發。這個運營活動的服務是一個簡易的HTTP伺服器,它在每次請求都會『讀取』資料庫來返回一段數據。

const http = require(http);
const uuid = require(uuid);

function readDataFromDataBase() {
return new Array(10000).fill(xxxx);
}

const server = http.createServer((req, res) => {
let key = uuid();
let data = readDataFromDataBase(key);
res.end(JSON.stringify({
data: data
}));
});

server.listen(3000);
console.log(Server listening to port 3000. Press Ctrl+C to stop it.);

小A寫完代碼之後,在生產環境伺服器上,使用下面的命令就把服務啟動起來了。

node server.js

由於資料庫的性能並不是特別好,所以整體服務的QPS並不是很高。直到有一天,老闆找到小A,下周公司要做一個運營活動,用戶量應該會增長不少,於是讓他優化這個伺服器,讓它能夠支撐更多的流量。小A於是通過一個簡單的對象,給readDataFromDataBase函數添加了緩存功能。

const http = require(http);
const uuid = require(uuid);

function readDataFromDataBase() {
let cache = {};
return function(key) {
if (cache[key]) {
return cache[key];
}

let data = new Array(10000).fill(xxxx);
cache[key] = data;
return data;
};
}

const database = readDataFromDataBase();

const server = http.createServer((req, res) => {
let key = uuid();
let data = database(key);
res.end(JSON.stringify({
data: data
}));
});

server.listen(3000);
console.log(Server listening to port 3000. Press Ctrl+C to stop it.);

通過這樣的改造,緩存生效了,老闆也很開心,於是小A就把這段代碼上到了生產環境,心裡想著,等下周公司運營活動搞完,年終獎就有著落啦。

公司的運營活動獲得了很大的成功,用戶量一下子就增長了10倍。這時,小A突然接到用戶反饋,說運營頁面打不開了,小A緊忙登錄到線上看報錯日誌,就發現node進程在列印出下圖的錯誤之後就直接跪了。

老闆很生氣,讓小A總結這次事故的原因,並後續給團隊開展一次Case Study。

問題調查

小A在遇到問題之後一臉懵逼,我線下測試都是好好的,為什麼一上線就跪了呢?抱有疑問的他跑去請教公司內的大佬,大佬看了小A的報錯信息就說:這是內存泄露,來,我幫你看一下吧。

內存錄製

大佬拿到他的代碼之後,在啟動的node命令後面,添加了一個特殊的參數--inspect

node --inspect server.js

緊接著,大佬打開了Chrome瀏覽器,在地址欄內輸入: chrome://inspect,發現剛才運行的server程序就在頁面上列出來了。

緊接著,大佬點擊了下面的inspect按鈕,一個Chrome Devtools就彈出來了。大佬選取了頂部Memory的tab,並在Select profilling type下面選擇了Allocation sampling。點擊下面的藍色的Start按鈕,然後錄製就開始了。

大佬對小A說,現在內存錄製搞好了,接下來就是構造一些請求來訪問伺服器了。

請求模擬

大佬打開小A電腦的命令行,輸入了下面的命令:

ab -n 1000000 -c 100 http://localhost:3000/

"來,讓我們再把它打掛吧",大佬邊敲命令,邊對小A說,我現在用ab這個壓力測試工具,向你的伺服器以100的並發發送了10W個請求,應該能模擬線上用戶突增的場景。

大佬執行執行這個命令之後,立刻切換到devtools,發現JavaScript VM Instance顯示的數字突增,不一會兒,就從不到10MB膨脹到了700MB。這時,大佬露出了滿意的微笑,說到:"看,問題復現了",隨後點擊了頁面上的Stop按鈕,停止了內存的錄製,並且退出了剛才執行的ab進程。

內存分析

這時,點擊了Stop按鈕之後,devtools顯示出了下圖的界面。

大佬對小A解釋說,看,這個工具把每一行代碼所佔用的內存給你顯示出來了,注意到第一行沒有,那段代碼佔用了99.81%的內存!

大佬點擊了最右邊的server.js,devtools就自動跳轉到代碼界面了。devtools使用黃色的標識顯示了佔用內存最大的代碼位置——就是小A寫的那段緩存代碼!

問題修復

大佬閱讀了小A寫的緩存代碼,說道:你這樣寫肯定會泄露的!你把每次請求的數據都寫入到cache這個對象中,那請求越來越多,cache肯定會越來越大嘛。小A說道:可是我需要緩存一些請求的數據,那現在我改怎麼辦?

大佬思考了一下,你可以使用LRU Cache這個數據結構,LRU Cache只會緩存最頻繁訪問的內容,那些不經常訪問的內容都會被自動拋棄掉,這樣的話,緩存的大小就不會無限制的增長了,而且還能保證最頻繁的內容可以命中緩存。

說完,大佬就在命令行中執行下面的命令,安裝了一個叫做lru-cache的npm包

npm i lru-cache

然後大佬通過這個包,替換到了小A代碼中的緩存實現。

const http = require(http);
const uuid = require(uuid);
const LRU = require(lru-cache);

function readDataFromDataBase() {
let cache = new LRU({
max: 50
});

return function(key) {
if (cache.has(key)) {
return cache.get(key);
}

let data = new Array(10000).fill(xxxx);
cache.set(key, data);
return data;
};
}

const cachedDataBase = readDataFromDataBase();

const server = http.createServer((req, res) => {
let key = uuid();
let data = cachedDataBase(key);
res.end(JSON.stringify({
data: data
}));
});

server.listen(3000);
console.log(Server listening to port 3000. Press Ctrl+C to stop it.);

然後大佬再重新運行伺服器,並使用ab工具來進行壓力測試,發現整個伺服器的內存會一直穩定在100MB以內。

總結

小A同學在開發過程中不注意緩存的大小限制,導致內存一直飆升直至服務崩潰。通過請教大佬,學會了如何通過Chrome devtools來發現內存問題,並採取LRU cache來作為應用緩存,避免了緩存過大的問題。


推薦閱讀:
相关文章