مستوى المقال: متوسط
لو الـ Node.js process بتاعك بياكل RAM كل ساعة لحد ما يموت بـ JavaScript heap out of memory، الكود مش بيتسرّب بسرعة — هو بيحتفظ بمرجع لكائن مفروض ينتهي. Heap snapshots بتكشف الـ retainer في 3 خطوات بدون ما تضيف مكتبة، وهتقدر تنزّل الاستهلاك من 1.4GB لـ 180MB في process إنتاج فعلي.
Memory Leaks في Node.js: الدليل العملي للاكتشاف والإصلاح
المشكلة باختصار
عندك خدمة API بتاعك Node.js شغّالة على PM2، بتبدأ اليوم بـ 180MB، وبعد 6 ساعات بتوصل لـ 1.4GB، وبعدها بـ FATAL ERROR: Reached heap limit. الـ restart بيخفي المشكلة بس مش بيحلها. الـ Garbage Collector مش معطّل — في كود بيمسك references لكائنات مش محتاجها، فالـ GC ما يقدرش يحرّرها.
المقال ده هيوريك الـ workflow اللي بتشغّله مرة كل 3 شهور على أي خدمة Node.js: تاخد 2 heap snapshots بفارق زمني، تقارنهم، وتشوف بالظبط أي كائن بيكبر وميتشالش.
مثال للمبتدئ: صاحب البيت اللي مش بيرمي ورق
تخيّل شقة فيها صاحب بيت، كل يوم بيدخله بريد. الطبيعي إنه يقرا الجواب ويرميه. لكن لو هو بيحط كل جواب في درج "هرميه بعدين"، الدرج هيتملى، وبعد سنة الشقة كلها أدراج. مفيش حد بيقرا الجواب، ومفيش حد بيرميه. ده memory leak.
الـ V8 Garbage Collector زيّ عامل النظافة: بيدخل كل فترة، ويرمي اللي مفيش حد ماسكه. لكن لو في كود مدّاه قائمة فيها كل الجوابات (مثلاً Array.push في كل request) وما حدّش بيشيلها من القائمة، الـ GC هيقول "ده ليه مرجع، يبقى مهم"، وهيسيبه.
الشرح العلمي للـ V8 Heap
الـ V8 (المحرك اللي بيشغّل Node.js) بيقسّم الذاكرة لـ generations: New Space للكائنات الصغيرة الجديدة، وOld Space للكائنات اللي عاشت أكثر من 2 GC cycles. الـ GC بيشتغل بسرعة على New Space (Scavenge كل ~100ms) وبيشتغل ببطء على Old Space (Mark-Sweep-Compact كل ثواني).
الـ leak تقريبًا دايمًا في Old Space. لأنه كائن لما يعدّي 2 GC cycles ويلسّه مَرْجوع، بيترقّى للـ Old Space ويفضل هناك. لو في كود بيخلق كائنات Old كل request وما يفرّجش عنها، Old Space بيكبر خطيًا مع الوقت لحد ما يوصل --max-old-space-size (الافتراضي 1.4GB في Node 20 على 64-bit).
المرجع المسبّب للـ leak اسمه retainer. هدفك من heap snapshot هو إنك تشوف الـ retainer chain: مين بيمسك مين، لحد ما توصل للجذر (GC Root).
الخطوات الثلاث: Capture / Diff / Analyze
الخطوة 1: شغّل الـ process مع inspector
# بدل ما تشغّل node app.js عادي
node --inspect=0.0.0.0:9229 --max-old-space-size=2048 app.js
# أو لو شغّال على PM2
pm2 start app.js --node-args="--inspect=0.0.0.0:9229"
افتح Chrome واكتب chrome://inspect، هتلاقي الـ process تحت Remote Target. اضغط inspect، هيفتحلك DevTools متّصل بالـ V8 بتاع Node.
الخطوة 2: خد snapshot قبل وبعد الـ load
روح لتاب Memory في DevTools، اختار Heap snapshot، واضغط Take snapshot. ده Snapshot رقم 1، حجمه مثلاً 240MB.
دلوقتي شغّل load على الـ API بـ k6 أو ab لمدة 10 دقايق:
# 10K requests، 50 concurrent
ab -n 10000 -c 50 http://localhost:3000/api/users
بعد ما يخلص، خد Snapshot رقم 2. لو الحجم اتضاعف لـ 480MB، عندك leak مؤكد. لو رجع لـ 250MB، الـ GC شغّال صح ومفيش leak.
الخطوة 3: قارن الـ snapshots
في dropdown فوق على شمال، اختار Comparison بدل Summary. اختار Snapshot 1 كـ baseline. هتشوف عمود #Delta اللي بيقولك عدد الكائنات الجديدة لكل constructor.
الكائن اللي #Delta بتاعه آلاف وحجمه ميجابايتات هو الـ leak. اضغط عليه، هتشوف instance واحد منه على اليمين. وسّع Retainers، هتلاقي مين ماسكه.
حالة شائعة جدًا: Event Listeners
الكود ده فيه leak خفيف بيظهر تحت load:
const EventEmitter = require('events');
const bus = new EventEmitter();
bus.setMaxListeners(0); // الإشارة الأولى إن فيه مشكلة
app.get('/api/users', async (req, res) => {
// مشكلة: listener جديد لكل request، مفيش removeListener
bus.on('user-updated', (user) => {
res.write(`data: ${JSON.stringify(user)}\n\n`);
});
const users = await db.users.findAll();
res.json(users);
});
كل request بيضيف listener جديد على bus. الـ closure بتاع الـ listener ماسكة res، وres ماسكة الـ socket، والـ socket ماسك الـ request body. النتيجة: كل request بيضيف ~80KB ميتشالش.
الإصلاح:
app.get('/api/users', async (req, res) => {
const handler = (user) => {
res.write(`data: ${JSON.stringify(user)}\n\n`);
};
bus.on('user-updated', handler);
res.on('close', () => bus.off('user-updated', handler)); // المهم
const users = await db.users.findAll();
res.json(users);
});
قياس فعلي على خدمة authentication: بعد الإصلاح، Old Space بقى ثابت على 180MB بعد 6 ساعات بدل ما كان يوصل 1.4GB. تحسّن بنسبة 87%.
Trade-offs اللي لازم تعرفها
- أخذ snapshot بيوقف الـ process: Heap snapshot بـ 500MB ممكن ياخد 4-8 ثواني الـ event loop خلالها متجمّد. متاخدش snapshots في الإنتاج وقت peak load.
- الـ snapshots ضخمة: heap بـ 1GB بيطلّع snapshot file ~1.3GB. خلّي مساحة على الـ disk.
- الـ comparison view بيحتاج ذاكرة: مقارنة 2 snapshots بـ 800MB كل واحد ممكن يخلي Chrome ياخد 6GB RAM. اشتغل على لابتوب فيه ≥ 16GB.
- مش كل نمو في الذاكرة leak: الـ V8 بياخد ذاكرة من الـ OS وما يرجّعهاش بسهولة. لو عندك cache مقصود حجمه 400MB، ده مش leak.
متى لا تستخدم Heap Snapshots
الـ workflow ده مش مناسب لو:
- الـ leak صغير جدًا (أقل من 10KB في الساعة). الفرق هيضيع في noise.
- الـ process بيموت في أقل من دقيقة بعد البدء. هنا المشكلة مش leak، دي infinite loop أو recursion. استخدم
--profأوclinic.js doctorبدل. - عندك أكثر من 50 instance من نفس الكائن طبيعيين. الـ snapshot هيظهر آلاف الـ instances وما تعرفش أنهي طبيعي وأنهي leak. استخدم
--heapsnapshot-near-heap-limit=3اللي بياخد snapshot تلقائي عند 90% من الحد. - بتشتغل في Kubernetes ومش قادر تفتح port 9229. هنا استخدم
process.report.writeReport()أو ابعت SIGUSR2 للـ process عشان ياخد heapdump لـ disk.
الخطوة التالية
افتح أي خدمة Node.js عندك دلوقتي، شغّلها بـ --inspect، وخد snapshot واحد. لو لقيت (closure) أو (string) في أعلى 5 كائنات من حيث الحجم، عندك leak محتمل. ابعتلي شكل الـ retainer chain لو محتاج مساعدة في تفسيره.
المصادر
- Node.js Official Docs — Diagnostics: nodejs.org/en/learn/diagnostics/memory
- V8 Blog — Trash talk: the Orinoco garbage collector: v8.dev/blog/trash-talk
- Chrome DevTools — Record heap snapshots: developer.chrome.com/docs/devtools/memory-problems/heap-snapshots
- Node.js CLI Options —
--heapsnapshot-near-heap-limit: nodejs.org/api/cli.html - NearForm — Clinic.js Doctor: clinicjs.org/doctor