لو السيرفر بتاعك بياخد 200MB أول ما يشتغل، وبعد 6 ساعات بقى 1.4GB، انت مش محتاج RAM زيادة. عندك Memory Leak. الـ heap snapshot هيبيّنلك السبب بالظبط في أقل من 10 دقايق بدون ما تعدّل سطر كود واحد في production.
كشف Memory Leak في Node.js بـ Heap Snapshot
المشكلة باختصار
التطبيق شغّال كويس أول ساعة. بعد كده استهلاك الذاكرة بيتزايد بشكل خطي لحد ما الـ process يضرب الـ OOM (Out Of Memory) ويعمل restart. الحل اللي ناس كتير بتروح ليه: زيادة الـ RAM أو cron job يعمل restart كل ساعتين. ده مش حل، ده تأجيل. في الإنتاج الحقيقي الـ leak بيكبر مع الـ traffic وبيكسر التطبيق في وقت الذروة بالظبط.
مثال للمبتدئين: غرفة بتمتلي بكراتين متفتحتش
تخيّل عندك غرفة بتدخلها كل يوم 50 كرتونة. اللي بتخلص منها بترميها في الزبالة. المفروض بعد شهر الغرفة فاضية. لكن اللي بيحصل: الغرفة بتمتلي للسقف. السبب: في كرتونة كبيرة في الركن مكتوب عليها "أرشيف" بتحط فيها ورقة صغيرة فيها رقم كل كرتونة قبل ما ترميها. الزبّال لمّا بيشوف الكرتونة في الزبالة، بيلاقي إن في حد لسه فاكر رقمها في الأرشيف، فيرجّعها مكانها لإنه بيفترض إنها مهمة.
ده بالظبط اللي بيحصل في Node.js. الـ Garbage Collector (الزبّال) ما بيقدرش يحذف أي كائن طول ما في reference واحد ليه من مكان "عايش". كائن صغير زي Map ممكن يمسك مليون كائن تاني، وانت مش حاسس بإنك بتسرّب ذاكرة.
التعريف العلمي بدقة
الـ V8 engine بيقسّم الـ heap لجيلين أساسيين: Young generation للكائنات الجديدة (بيتنضّفوا بسرعة بـ Scavenger GC)، وOld generation للكائنات اللي عاشت أكتر من cycle (بيتنضّفوا بـ Mark-Sweep-Compact). الـ leak بيحصل لمّا كائنات تتنقل لـ Old generation وتفضل عندها reference path وصولًا للـ root (مثل global object، closure حيّ، event emitter، أو cache بدون حد). الـ GC بيمشي في الـ graph وبيلاقيهم "reachable"، فما بيحذفهمش. المقياس المهم اسمه Retained Size: إجمالي الذاكرة اللي هتتحرر فعلًا لو الكائن ده اتقطع reference بتاعه.
الأنماط الأربعة اللي بتسبب 90% من الـ leaks
- Closures في setInterval بدون clearInterval: الـ closure ماسك متغير كبير، والـ interval فاضل شغّال طول حياة الـ process.
- Event listeners ما اتشالتش:
emitter.on('data', handler)بيتضاف مع كل request جديد بدونoff. - Cache بدون TTL أو max size:
const cache = new Map()بتكبر طول النهار. - Arrays عمومية بتتعمل push بدون pop: زي
logs.push(req)على array global.
الـ playbook في 5 خطوات (three-snapshot technique)
- شغّل التطبيق وسيبه ياخد warmup لمدة دقيقتين.
- خد Snapshot 1 (baseline).
- اعمل load صناعي بـ
autocannonأوk6(1000 request مثلاً)، وبعدين استنى 30 ثانية لـ GC يخلّص شغله. - خد Snapshot 2.
- كرّر الخطوة 3 و4 لتاخد Snapshot 3.
في Chrome DevTools افتح Snapshot 3، اختار من dropdown "Comparison" مع Snapshot 1، ورتّب بـ Delta. أي object type بيزيد بشكل خطي بين الـ snapshots هو الـ leak بتاعك. الفكرة هنا: لو الزيادة عشوائية يبقى ده شغل GC طبيعي. لو خطية ومتكررة في كل round، يبقى عندك leak مؤكد.
الكود التنفيذي القابل للنسخ
# 1) شغل Node.js مع inspector
node --inspect=0.0.0.0:9229 server.js
# 2) افتح chrome://inspect واضغط "inspect" قدام target
# في تاب Memory > Take heap snapshot
# 3) load test بسيط
npx autocannon -c 50 -d 30 http://localhost:3000/api/users
لو مش هتستخدم Chrome في الإنتاج (موصى به)، استخدم signal لتشغيل snapshot على الـ process مباشرة:
// snapshot.js
const v8 = require('v8');
const fs = require('fs');
process.on('SIGUSR2', () => {
const file = `./heap-${Date.now()}.heapsnapshot`;
const stream = v8.getHeapSnapshot();
stream.pipe(fs.createWriteStream(file));
console.log('Snapshot saved:', file);
});
// من الـ shell:
// kill -USR2 <pid>
// وبعدين حمّل الملف وافتحه في Chrome DevTools > Memory > Load
أرقام من حالة حقيقية
تطبيق Express بيخدم حوالي 50K request/يوم. الـ RSS كان بيبدأ 180MB ويوصل 1.6GB قبل ما الـ container يموت. بعد three-snapshot، اتحدد إن النوع LRUNode من lru-cache بيزيد بمعدل ثابت مع كل round. السبب: الـ cache اتعمل بدون max ولا ttl. التعديل كان سطر واحد:
const cache = new LRU({ max: 5000, ttl: 1000 * 60 * 10 });
النتيجة بعد 24 ساعة في إنتاج: RSS مستقر عند 240MB ± 20MB. انخفاض 85% في استهلاك الذاكرة، وصفر crashes على مدار 30 يوم. أفضل طريقة تتأكد إن المشكلة اتحلت: راقب process.memoryUsage().rss كل دقيقة لمدة 6 ساعات. لو الـ slope بقى أفقي بدل ما كان مائل، انت كسبت.
الـ trade-offs اللي لازم تعرفها
الـ heap snapshot في إنتاج بيوقف الـ event loop لمدة 2 لـ 15 ثانية حسب حجم الـ heap. الـ requests خلال الفترة دي هتتأخر أو تفشل. الافتراض هنا إن عندك load balancer بيقدر يحوّل الـ traffic لـ instance تاني وقت الـ snapshot. لو عندك instance واحد، اعمل الـ snapshot في staging بـ traffic مماثل للإنتاج.
كمان حجم الملف: heap بحجم 1GB بيعمل snapshot file حوالي 800MB-1.2GB. لو مساحة القرص ضيقة، احفظه على volume منفصل أو ارفعه على S3 فورًا. الـ trade-off: بتكسب تشخيص دقيق، وبتخسر 10 ثواني availability + مساحة قرص مؤقتة.
متى لا تستخدم هذه الطريقة
لو الـ leak مش في الـ JavaScript heap لكن في native modules (مثل sharp، canvas، أو C++ addons)، الـ heap snapshot مش هيشوفها. في الحالة دي راقب process.memoryUsage().external وarrayBuffers، أو استخدم Valgrind على الـ Node binary. كمان لو التطبيق بيشتغل في Lambda أو serverless بـ lifetime قصير (أقل من 5 دقايق)، الـ leak مش هيظهر لإن الـ process بيموت قبل ما يكبر.
المصادر
- Node.js Official Docs — Diagnostics: Memory: nodejs.org/en/learn/diagnostics/memory
- Chrome DevTools — Record heap snapshots: developer.chrome.com/docs/devtools/memory-problems/heap-snapshots
- V8 Blog — Trash talk: the Orinoco garbage collector: v8.dev/blog/trash-talk
- clinic.js heapprofiler: clinicjs.org/heapprofiler
- Node.js v8 module — getHeapSnapshot: nodejs.org/api/v8.html
- autocannon — HTTP benchmarking: github.com/mcollina/autocannon
الخطوة التالية
افتح dashboard المراقبة بتاعك دلوقتي (Datadog، Grafana، أو حتى pm2 monit). لو RSS بيتزايد بشكل خطي على مدار 4 ساعات وما بيرجعش لقيمة مستقرة، الإجابة جاهزة قبل ما تكمل القراءة. شغّل خطوة الـ three-snapshot النهارده على staging، وبعدين قارن delta. لو محتار في تفسير الناتج، ابعت screenshot من تاب Comparison.