Node.js

خطأ "JavaScript heap out of memory" في Node.js: ماذا يعني وكيف تصلحه

نفد heap «المساحة القديمة» في V8 فأنهى العملية. وهل الإصلاح علم تشغيل أم مطاردة تسريب — يعتمد على شكل منحنى الذاكرة.

ماذا يعني هذا الخطأ فعليًا

خطأ "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory" يصدر من V8، محرك JavaScript داخل Node.js. يدير V8 كائنات JavaScript في heap بجمع مهملات أكبر مناطقه هي «المساحة القديمة» (old space). وعندما يفشل تخصيصٌ ولا تستطيع حتى دورة mark-sweep كاملة تحرير مساحة كافية ضمن الحد المضبوط، ينهي V8 العملية كلها — ولا استثناء يمكن التقاطه. يعتمد الحد على إصدار Node والذاكرة المتاحة في النظام، ويمكن رفعه صراحة بـ --max-old-space-size (بالميغابايت).

وانتبه لما يغطيه الحد: heap كائنات JavaScript في V8 فقط. أما الـ Buffers وتخصيصات الإضافات الأصلية وسائر الذاكرة خارج heap فتعيش خارجه — لذا قد تقتل الحاويةُ العملية بسبب الذاكرة الإجمالية بينما يبدو heap V8 سليمًا، أو تنهار العملية على حد V8 والحاوية ما زالت تملك غيغابايتات حرّة. والتشخيص الصحيح يعني معرفة أي حدّ اصطدمت به.

الأسباب الجذرية الشائعة

تسريبات عبر closures والكاشات والمستمعين

closures تلتقط كائنات كبيرة تعيش بعد انتهاء الطلب، وخرائط ومصفوفات على مستوى الوحدة تُستخدم ككاشات بلا حدود، ومستمعو أحداث يُضافون مع كل طلب ولا يُزالون أبدًا (تحذير MaxListenersExceededWarning الشهير تلميح مبكر). كل طلب يترك خلفه قليلًا؛ وبعد ساعات تمتلئ المساحة القديمة.

معالجة بيانات ضخمة في الذاكرة

قراءة ملف بمئات الميغابايتات بـ readFile، أو JSON.parse على استجابة هائلة، أو بناء مجموعة النتائج كاملة في مصفوفة قبل الرد — تخصيص ضخم واحد يقفز بالـ heap فوق الحد. والإصلاح هو البث (streaming): عالج البيانات على دفعات بدل تجسيدها كلها.

حد heap أدنى من حمل مشروع

أدوات البناء والمجمّعات والخدمات كثيفة البيانات قد تحتاج فعلًا مساحة قديمة أكبر من الافتراضي. إذا استقرّت الذاكرة عند مستوى صحي تحت الحمل العادي ولم تنهر إلا أثقل عملية مشروعة، فقد تجاوز الحمل الحدَّ الافتراضي — ارفعه عن قصد.

تراكم تحت الضغط: وعود وطوابير

عندما يتجاوز الوارد قدرة المعالجة — وعود متزامنة بلا حدود، أو طابور في الذاكرة بلا backpressure، أو استجابات تتكدّس أسرع مما يصرّفها طرف خلفي بطيء — تنمو الذاكرة مع المرور وتنهار في الذروة. ليس تسريبًا تقليديًا: يتصرّف عند توقف الحمل، لكنه ينهار حين لا يتوقف.

كيف تحقّق فيه وتصلحه

حدّد شكل منحنى الذاكرة أولًا — صعود مطّرد يعني تسريبًا، وقفزة حادة تعني تخصيصًا ضخمًا — ثم استخدم لقطات heap لتسمية ما يُحتجز.

  1. 1

    حدّد نمط نمو الذاكرة

    ارسم process.memoryUsage() (الـ heapUsed مقابل rss) عبر الزمن، أو راقب أداة المراقبة لديك. أرضية تصعد باطّراد عبر الساعات تسريبٌ؛ وذاكرة ثابتة بقفزة مفاجئة عند عملية واحدة تخصيصٌ ضخم منفرد؛ ونموّ يتبع المرور ويتصرّف بعده يعني غياب backpressure.

  2. 2

    أعد الإنتاج مع توصيل المفتّش

    شغّل الخدمة بـ node --inspect، وافتح chrome://inspect، ووجّه إليها مرورًا واقعيًا. وللعمليات الإنتاجية التي لا يمكن إعادة تشغيلها، أرسل SIGUSR1 لتفعيل المفتّش على عملية جارية، أو استخدم مكتبة تكتب لقطات heap إلى القرص عند الطلب.

  3. 3

    التقط لقطات heap عبر الزمن وقارن

    التقط لقطة، طبّق حملًا، التقط أخرى، واستخدم عرض المقارنة في Chrome DevTools. رتّب بفارق الحجم المحتجز: أنواع الكائنات التي تنمو بين اللقطات — وسلاسل الاحتجاز التي تُظهر أي متغيّر بالضبط يمسكها — هي تسريبك، مسمًّى ومحدَّد الموقع.

  4. 4

    افحص المشتبهين المعتادين في الشيفرة

    مجموعات على مستوى الوحدة لا تكفّ عن النمو، وكاشات بلا حجم أقصى أو عمر، ومستمعون مسجَّلون داخل معالجات الطلبات، ومؤقّتات لا تُلغى، وclosures تمسكها كائنات معمّرة (مقابس، singletons). وسلسلة الاحتجاز من اللقطة تشير عادة إلى أحدها مباشرة.

  5. 5

    اضبط حجم heap مقابل الحاوية

    إذا كان الحمل مشروعًا، فاضبط --max-old-space-size على نحو 75–80% من حد ذاكرة الحاوية، تاركًا هامشًا للـ Buffers والذاكرة الأصلية والمكدّس. أما ضبطه مساويًا لحد الحاوية فدعوة لقاتل OOM في النواة — خروج صامت برمز 137 بدل خطأ V8.

  6. 6

    ابثّ البيانات الكبيرة بدل تخزينها مؤقتًا

    استبدل readFile مع التحليل الكامل بالـ streams والمحلّلات التدريجية، وقسّم قراءات قاعدة البيانات صفحات، وأضف backpressure (طوابير محدودة وسقوف تزامن على غرار p-limit). وبعد الإصلاح، أعد الحمل نفسه وتأكد أن heapUsed يبقى محدودًا.

كيف تمنع انهيارات نفاد heap

  • راقب heapUsed وRSS لكل عملية ونبّه على ارتفاع الأرضية — فالتسريبات تعلن عن نفسها قبل الخطأ القاتل بكثير.
  • امنح كل كاش حجمًا أقصى وعمرًا (lru-cache أو مثيلاتها) — فخرائط مستوى الوحدة هي مخبأ تسريبات Node المفضّل.
  • ابثّ الملفات والاستجابات الكبيرة افتراضيًا؛ وعامل أي مصفوفة نتائج بلا حدود كعلّة تنتظر بيانات الإنتاج.
  • اضبط --max-old-space-size صراحةً داخل الحاويات، بحجم محسوب مقابل حد الحاوية وبهامش حقيقي.
  • أزل المستمعين وألغِ المؤقّتات في مسارات التنظيف، وتعامل مع تحذير MaxListenersExceededWarning كخطأ لا كضجيج.

كيف يساعدك AllStak مع مشاكل ذاكرة Node.js

ترسم مراقبة البنية التحتية في AllStak الذاكرة لكل خادم وحاوية عبر الزمن، وهذه بالضبط النظرة التي يتطلّبها هذا الخطأ: أرضية تصعد باطّراد (تسريب)، أو قفزة عند عملية واحدة (تخصيص ضخم)، أو نموّ بشكل المرور (غياب backpressure) — لكلٍّ شكله المميّز على الرسم. والتنبيه على الاتجاه يمنحك أيامًا من الإنذار بدل انهيار قاتل.

يلتقط تتبّع الأخطاء في Node.js SDK الاستثناءات وإعادات التشغيل حول الانهيار مع سياق الإصدار، وتُبقي السجلات المركزية رسالة V8 القاتلة بجانب أسطر إعادة التشغيل من مدير العمليات. لن يلتقط AllStak لقطات heap عنك — فتلك مهمة Chrome DevTools — لكنه يخبرك أي عملية تنمو، ومنذ متى، وتحت أي نشر بدأ النمو.

أسئلة شائعة عن نفاد heap في Node.js

هل أكتفي برفع --max-old-space-size؟

فقط إذا قال منحنى الذاكرة إنها مسألة نقص حجم: أرضية مستقرة وانهيار يقع عند عملية ثقيلة مشروعة. أمام تسريب، الـ heap الأكبر يؤجّل الانهيار فقط ويطيل وقفات جامع المهملات. افحص الاتجاه أولًا — فرفع الحد على خدمة تسرّب يشتري ساعات لا إصلاحًا.

نفاد heap أم قتل الحاوية OOMKilled — أيهما لديّ؟

إذا رأيت رسالة "FATAL ERROR ... JavaScript heap out of memory" مع مكدّس V8 فقد اصطدمت بحد V8. أما إذا اختفت العملية بصمت برمز خروج 137 وسطر oom-kill في dmesg أو kubectl describe، فالنواة قتلتها بسبب الذاكرة الإجمالية — أي RSS بما فيه الـ Buffers والتخصيصات الأصلية، وهو ما لا يحكمه --max-old-space-size.

هل تُحتسب الـ Buffers ضمن حد heap في V8؟

لا. بيانات الـ Buffer تعيش في ذاكرة خارج heap V8 (تظهر كـ "external"/arrayBuffers في process.memoryUsage())، لذا قد تُسقط خدمةٌ غارقة في الـ Buffers الحاويةَ بينما يبدو heapUsed سليمًا. راقب RSS إلى جانب heapUsed، وابثّ البيانات الثنائية بدل مراكمتها.

كيف أعثر على التسريب فعليًا؟

بلقطات heap مقارنةً عبر الزمن. التقط واحدة كخط أساس، طبّق حملًا، التقط أخرى، ورتّب المقارنة في Chrome DevTools حسب نمو الحجم المحتجز. نوع الكائن المتنامي مع سلسلة احتجازه — مسار المراجع الذي يبقيه حيًا — يسمّي المتغيّر والملف بالضبط. وجولتان أو ثلاث من اللقطات تحسم الأمر عادة.

راقب عمليات Node لديك قبل أن تنهار

يرسم AllStak ذاكرة كل عملية وحاوية، ويلتقط الأخطاء حول كل إعادة تشغيل، ويحفظ السجلات التي تفسّرها — في مكان واحد.