تخطّى إلى المحتوى الرئيسي
لماذا يستمر استهلاك الذاكرة في عمليات Node بالارتفاع: ملاحظات ميدانية عن لقطات الكومة والكاش في نطاق الوحدة والتسريبات التي تظهر عند إعادة استخدام النسخ

لماذا يستمر استهلاك الذاكرة في عمليات Node بالارتفاع: ملاحظات ميدانية عن لقطات الكومة والكاش في نطاق الوحدة والتسريبات التي تظهر عند إعادة استخدام النسخ

September 15, 2026
هندسة الويب المتكاملة
7 دقيقة قراءة

أهم النقاط

• الدالة process.memoryUsage() تُرجع rss و heapTotal و heapUsed و external و arrayBuffers، وقياس heapUsed بعد دورة جمع مهملات كاملة هو وحده الذي يثبت أن كائنات JavaScript محتجزة فعلاً.

• تسريب الذاكرة ارتفاع في الأرضية لا ارتفاع في الخط. قيمة heapUsed تتذبذب تحت الحمل ويجب أن تعود إلى نفس خط الأساس بعد كل دورة جمع مهملات كبرى.

• نطاق الوحدة هو المكان الذي تتسرب منه خوادم Node.js. أي شيء معرَّف خارج معالج الطلب — Map أو مصفوفة أو مستمع أحداث أو setInterval — يبقى حياً طوال عمر العملية.

• الخيار node --heapsnapshot-signal=SIGUSR2 يكتب ملف .heapsnapshot عند الطلب، ولوحة Memory في Chrome DevTools تقارن لقطتين عبر عرض Comparison حسب عدد الكائنات وحجم الاحتجاز.

• البنية بلا خادم لا تمنع التسريب. خاصية Fluid Compute في Vercel تعيد استخدام نسخ الدوال بين الاستدعاءات، فيبقى نطاق الوحدة حياً بين الطلبات تماماً كما في خادم طويل العمر.

كيف تفرّق بين تسريب ذاكرة حقيقي ونمو طبيعي في الكومة؟

التسريب الحقيقي هو ارتفاع أرضية heapUsed عبر دورات جمع المهملات، أما النمو الطبيعي فتذبذب يعود إلى نفس خط الأساس. محرك V8 يخصص الذاكرة بسرعة ويجمعها بتكاسل، لذلك نمو الكومة بين عينتين عشوائيتين لا يثبت شيئاً.

حقول process.memoryUsage() الخمسة تشير إلى أعطال مختلفة. الحقل rss هو حجم المجموعة المقيمة، أي كل صفحة ذاكرة خصصها النظام للعملية بما فيها التخصيصات الأصلية. والحقل heapUsed هو كائنات JavaScript الحية داخل كومة V8. أما external و arrayBuffers فيغطيان الذاكرة المملوكة لكائنات C++ المرتبطة بـ JavaScript، وهناك تعيش كائنات Buffer. التسريب الظاهر في external دون heapUsed هو مشكلة Buffer أو تدفق لا مشكلة في رسم الكائنات.

فريقنا يأخذ عينة من memoryUsage كل ثلاثين ثانية ويستدعي دائماً ‎.unref()‎ على ذلك المؤقت، لأن مؤقتاً يبقي حلقة الأحداث حية هو نسخة مصغرة من العلة التي نطاردها. وللحصول على قراءة حاسمة نشغّل العملية بالخيار --expose-gc ونستدعي global.gc() ثم نأخذ العينة مباشرة. هذا الرقم هو الأرضية، والأرضية هي الخط الوحيد الذي يستحق التنبيه.

ما الذي يتسرب فعلاً داخل خادم Node.js؟

خمسة أنماط تغطي تقريباً كل تسريب صححناه في Node.js، وجميعها تعيش في نطاق الوحدة.

• خريطة Map أو كائن غير محدود مفهرس بمفتاح على شكل الطلب. الكاش المفهرس بمعرّف مستخدم أو معرّف طلب أو مفتاح idempotency أو رمز جلسة يملك فضاء مفاتيح ينمو مع حركة المرور ولا يتقلص أبداً.

• مستمعو أحداث يُضافون لكل طلب إلى مُصدِر طويل العمر. تحذّر Node.js مرة واحدة عند المستمع الحادي عشر برسالة "MaxListenersExceededWarning: Possible EventEmitter memory leak detected" ثم تصمت بينما يستمر العدد بالارتفاع.

• مؤقتات تحتجز إغلاقات. أي setInterval أو setTimeout غير محلول يلتقط كائن الطلب يبقي رسم الكائنات كاملاً قابلاً للوصول طوال عمر المؤقت.

• مخازن AsyncLocalStorage التي تحتفظ بكائنات الطلب كاملة. وواجهة AsyncLocalStorage هي واجهة Node.js لتمرير سياق الطلب عبر الاستدعاءات غير المتزامنة، والمخزن يبقى قابلاً للوصول ما دامت هناك عملية غير متزامنة معلقة في ذلك السياق.

• مخازن Buffer وقطع التدفقات المحتجزة. جمع أجسام الاستجابات في مصفوفة على مستوى الوحدة يظهر في external و rss بينما يبقى heapUsed ثابتاً، ولهذا ينجو من تحقيق يقتصر على الكومة.

التسريب الأخير الذي طاردناه كان من النمط الأول: خريطة memoization مفهرسة بمفتاح idempotency خاص بكل طلب، فأضاف كل طلب مدخلاً لن يقرأه شيء مرة أخرى.

كيف تلتقط لقطة كومة وتقارنها في بيئة الإنتاج؟

شغّل العملية بالخيار --heapsnapshot-signal=SIGUSR2، وأرسل الإشارة مرتين مع حركة مرور حقيقية بينهما، ثم قارن الملفين. لقطة الكومة رسم لحظي لكل كائن يمكن الوصول إليه داخل كومة V8، فلقطة واحدة تُظهر الحجم ولقطتان تُظهران النمو.

هناك ثلاثة مسارات التقاط أخرى تستحق المعرفة. الدالة v8.writeHeapSnapshot() تلتقط من داخل العملية لكنها متزامنة وتوقف حلقة الأحداث بمقدار يتناسب مع حجم الكومة، لذلك مكانها مسار إداري محمي. والخيار --heapsnapshot-near-heap-limit=1 يكتب لقطة تلقائياً قبل انهيار نفاد الذاكرة مباشرة فيحوّل حادثة غير قابلة لإعادة الإنتاج إلى أثر ملموس. أما الخيار --heap-prof فيشغّل مُحلل الكومة بالعينات في V8 ويجيب عن أي مواضع الاستدعاء خصصت الذاكرة بدل ما يحتجزها الآن.

حمّل الملفين في Chrome DevTools ضمن لوحة Memory، وانتقل إلى عرض Comparison، ورتّب حسب فرق الحجم. الحجم السطحي هو ذاكرة الكائن نفسه، وحجم الاحتجاز هو كل ما يصبح قابلاً للجمع عند تحرير ذلك الكائن، ولهذا يحدد حجم الاحتجاز جذر التسريب. اختر الباني المشتبه به واقرأ لوحة Retainers لترى سلسلة المراجع التي تبقيه حياً. وخذ اللقطة الأولى بعد الإحماء لا عند الإقلاع، وإلا دفن إقلاع إطار العمل الفارق الذي يهمك.

لماذا تظهر تسريبات الذاكرة فقط عند إعادة استخدام النسخ بلا خادم؟

التسريب يحتاج عملية تعيش طويلاً بما يكفي للتراكم، وإعادة استخدام النسخ هي ما يمنحه ذلك. النموذج التقليدي بطلب واحد لكل نسخة كان يخفي التسريبات بالمصادفة، لأن النسخة كانت تُجمَّد أو يُعاد تدويرها قبل أن يصبح النمو مهماً.

وخاصية Fluid Compute، وهي نموذج التنفيذ الافتراضي لدوال Vercel، تعيد استخدام نسخة الدالة عبر الاستدعاءات المتزامنة والمتتابعة. إعادة الاستخدام هذه هي سبب نجاح تجمع اتصالات قاعدة البيانات المعرَّف في نطاق الوحدة بدل فتح مقبس لكل طلب، وهي نفس الخاصية التي تحوّل Map في نطاق الوحدة إلى تسريب. الآلية التي تعتمد عليها في النمط المفرد هي نفسها الآلية التي تحمل التسريب.

وبيئة التطوير المحلية تحمل الفخ المعاكس. إذ يعيد الاستبدال الساخن للوحدات تقييم الوحدات عند كل تعديل، ولهذا وُجد نمط globalThis المفرد لعملاء مثل Prisma — وهذا يعني أن الحالة المتسربة تنجو من إعادة التحميل، فجلسة تطوير طويلة ليست دليلاً على شيء.

أي أنواع الكاش آمنة في نطاق الوحدة؟

الكاش في نطاق الوحدة آمن حين يكون فضاء مفاتيحه محدوداً بشيء غير حركة المرور.

• Map أو كائن عادي: بلا حد، ينمو إلى الأبد. استخدمه فقط حين يكون فضاء المفاتيح صغيراً وثابتاً مثل الإعدادات المحللة أو التعابير النمطية المترجمة أو سجل مزودين.

• WeakMap: يموت المدخل حين يصبح كائن المفتاح غير قابل للوصول. استخدمه لكاش مفهرس بكائن لا تتحكم بعمره.

• مكتبة lru-cache مع max و ttl: حد صارم للمدخلات مع انتهاء صلاحية. استخدمها لأي مفتاح مشتق من مدخلات المستخدم مثل معرّف مستخدم أو معرّف مستأجر أو رمز.

• الدالة cache() في React: محصورة بطلب خادم واحد. استخدمها لإزالة تكرار القراءات المتطابقة داخل تصيير واحد لمكوّنات الخادم.

• كاش بيانات Next.js عبر "use cache": خارج الكومة ويُعاد التحقق منه. استخدمه للبيانات التي يجب أن تبقى عبر الطلبات وعمليات النشر.

وخيار WeakMap أضيق مما يبدو لأن مفاتيحه يجب أن تكون كائنات أو رموزاً غير مسجلة. فالكاش المفهرس بسلسلة معرّف مستخدم لا يمكن أن يكون WeakMap ويحتاج حداً صريحاً بدلاً من ذلك. والاختبار الذي نستخدمه في مراجعة الكود سطر واحد: إذا كان مفتاح الكاش قابلاً للاشتقاق من مدخلات المستخدم فالكاش يحتاج سقفاً.

كيف تلتقط التسريب قبل وصوله إلى الإنتاج؟

نفّذ نفس الطلب آلاف المرات على عملية واحدة، وافرض دورة جمع مهملات، ثم تحقق أن heapUsed لم يتجاوز ميزانية محددة. هذا اختبار انحدار لا اختبار أداء، ويعمل على مشغّل الاختبارات المدمج في Node.js دون أي اعتمادية إضافية: أحمِ بمئتي تكرار، ثم استدعِ global.gc()، وسجّل خط أساس heapUsed، ثم نفّذ ألفي تكرار إضافي، وتحقق أن الفارق بعد استدعاء global.gc() مرة ثانية بقي تحت بضعة ميغابايت.

شغّل ذلك الاختبار بالأمر node --expose-gc --test، وتحقق من ميزانية لا من نمو صفري، لأن V8 يحتجز فعلاً كوداً مترجماً وذاكرات تخزين داخلية وسلاسل نصية موحّدة بعد الإحماء. وحلقة الإحماء قبل خط الأساس هي ما يجعل العتبة ذات معنى.

إعدادان في الإنتاج يكملان المهمة. اضبط --max-old-space-size أقل من حد ذاكرة الحاوية كي يصطدم V8 بسقفه أولاً ويتمكن من كتابة لقطة، بدل أن يزيل OOM killer العملية دون أي أثر. ثم صدّر rss و heapUsed كمقياسين ونبّه على القيمة الدنيا عبر نافذة تمتد ساعات، لأن القمم حركة مرور والحد الأدنى المرتفع تسريب.

الأسئلة الشائعة

س: هل ارتفاع rss يعني دائماً تسريب ذاكرة؟

ج: لا. يشمل rss التخصيصات الأصلية وذاكرة Buffer والصفحات التي لم يُعدها المخصص إلى النظام. تحقق من heapUsed مباشرة بعد دورة جمع مهملات مفروضة قبل وصفه بالتسريب.

س: هل يمكن لدالة بلا خادم أن تسرّب ذاكرة؟

ج: نعم. خاصية Fluid Compute في Vercel تعيد استخدام نسخ الدوال بين الاستدعاءات، لذلك يبقى كل ما في نطاق الوحدة بين الطلبات ويمكن أن ينمو بلا حد.

س: هل WeakMap حل عام لتسريبات الكاش؟

ج: فقط حين يكون مفتاح الكاش كائناً يتحكم بعمره شيء آخر. مفاتيح WeakMap يجب أن تكون كائنات أو رموزاً غير مسجلة، لذلك الكاش المفهرس بسلسلة نصية يظل يحتاج حداً صريحاً مثل lru-cache مع max.

س: لماذا قُتلت العملية دون رسالة خطأ في كومة JavaScript؟

ج: بلغت الحاوية حد ذاكرتها قبل أن يبلغ V8 حد كومته، فأنهى OOM killer في النواة العملية، غالباً برمز الخروج 137. أما فشل V8 فيظهر بالرسالة "FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory".

قراءات إضافية

لماذا لا تنجح ذاكرة CDN أبدًا: ملاحظاتنا الميدانية حول Cache-Control وs-maxage وstale-while-revalidate
اقرأ المقال

هندسة الويب المتكاملة

لماذا لا تنجح ذاكرة CDN أبدًا: ملاحظاتنا الميدانية حول Cache-Control وs-maxage وstale-while-revalidate

كانت نقطة نهاية JSON عامة تصل إلى الخادم الأصلي في كل طلب بينما بدت إعدادات CDN صحيحة. هذه ملاحظاتنا الميدانية حول قراءة ترويسات x-vercel-cache وAge بدلًا من لوحات التحكم، والفرق بين max-age وs-maxage وstale-while-revalidate، ومتى تتفوق CDN-Cache-Control على ترويسة Cache-Control وحدها، والحالات الخمس التي تُعطّل التخزين على الحافة بصمت.

لماذا تنكسر الحزمة داخل تطبيق CommonJS: ملاحظات ميدانية حول require(esm) وخريطة exports وخطر الحزمة المزدوجة
اقرأ المقال

هندسة الويب المتكاملة

لماذا تنكسر الحزمة داخل تطبيق CommonJS: ملاحظات ميدانية حول require(esm) وخريطة exports وخطر الحزمة المزدوجة

نشرنا أداة صغيرة بصيغة ESM فقط، وكان أول من ثبّتها خدمة CommonJS فشلت عند الإقلاع بالخطأ ERR_REQUIRE_ESM. هذه ملاحظاتنا الميدانية حول ما أصلحته فعلاً خاصية require(esm) في Node.js 22.12، وترتيب شروط exports الذي يحل بصمت إلى الملف الخاطئ، وخطر الحزمة المزدوجة الذي يبقى رغم كل ذلك، والأداتين اللتين نشغّلهما قبل كل نشر.