🌐 Detecting your location…

كيفية إصلاح الخطأ “نفاد الذاكرة في كومة جافا سكريبت” في Node.js

⏱️3 min read  ·  537 words

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryيعني أن محرك V8 وصل إلى سقف الكومة. هناك حالتان مختلفتان تمامًا وراء ذلك: عبء العمل المشروع الذي يحتاج إلى المزيد من الذاكرة، وتحميل التسرب أو الخوارزمية أكثر بكثير مما ينبغي. ورفع الحد يثبت الأول ويخفي الثاني.

أولاً: أي موقف هذا؟

أجب على هذا قبل تغيير أي شيء.

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

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

حل سريع: رفع حد الكومة

مناسب للإنشاءات والمهام المجمعة التي تحتاج حقًا إلى الذاكرة.

# Per invocation, in megabytes
node --max-old-space-size=4096 script.js

# For anything Node spawns, including build tools
export NODE_OPTIONS="--max-old-space-size=4096"
npm run build
{
  "scripts": {
    "build": "NODE_OPTIONS=--max-old-space-size=4096 next build"
  }
}

لا تقم بتعيين هذا فوق الذاكرة المتوفرة بالفعل. إذا كانت الحاوية تحتوي على 2 غيغابايت وسمحت بكومة سعة 4 غيغابايت، فإن قاتل OOM الخاص بالنواة ينهي العملية باستخدام SIGKILL – ولن تحصل على أي خطأ في JavaScript على الإطلاق، فقط رمز خروج 137، وهو أمر أصعب بكثير في التشخيص.

# Exit code 137 = 128 + 9 (SIGKILL) — killed by the OS, not by V8
docker inspect <container> --format='{{.State.ExitCode}}'
dmesg | grep -i "killed process"

السبب 1: قراءة الملفات بالكامل في الذاكرة

السبب الأكثر شيوعًا في كود معالجة البيانات.

// ❌ A 2GB file needs 2GB+ of heap, plus overhead for the string
import fs from 'node:fs/promises';
const content = await fs.readFile('huge.csv', 'utf8');
const lines = content.split('\n');       // now a second copy exists
// ✅ Constant memory regardless of file size
import fs from 'node:fs';
import readline from 'node:readline';

const stream = fs.createReadStream('huge.csv', { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

for await (const line of rl) {
  await processLine(line);
}

الأمر نفسه ينطبق على استجابات HTTP ونتائج قاعدة البيانات. دفقهم. لا تتراكم لهم.

// ❌ Buffers the entire response
const res = await fetch(url);
const buffer = await res.arrayBuffer();

// ✅ Pipes straight to disk
import { pipeline } from 'node:stream/promises';
import { Writable } from 'node:stream';

await pipeline(res.body, fs.createWriteStream('output.bin'));

السبب 2: نتائج الاستعلام غير المحدودة

// ❌ Ten million rows, all resident at once
const users = await db.query('SELECT * FROM users');
for (const user of users) await sendEmail(user);
// ✅ Keyset-paginated batches — constant memory
let lastId = 0;
const BATCH = 1000;

while (true) {
  const rows = await db.query(
    'SELECT * FROM users WHERE id > $1 ORDER BY id LIMIT $2',
    [lastId, BATCH]
  );
  if (rows.length === 0) break;

  for (const user of rows) await sendEmail(user);
  lastId = rows[rows.length - 1].id;
}

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

السبب 3: تسريبات حقيقية في الخوادم طويلة التشغيل

أربعة أنماط تمثل معظمها.

ذاكرة تخزين مؤقت غير محدودة. كائن عادي أو خريطة تنمو باستمرار.

// ❌ Grows forever
const cache = new Map();
function get(key) {
  if (!cache.has(key)) cache.set(key, expensive(key));
  return cache.get(key);
}
// ✅ Bounded with eviction
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 5000, ttl: 1000 * 60 * 10 });

المستمعون الذين لا تتم إزالتهم مطلقًا.

// ❌ Adds a listener on every request
app.get('/data', (req, res) => {
  emitter.on('update', () => res.write('...'));
});

// ✅ Remove it when the request ends
app.get('/data', (req, res) => {
  const onUpdate = () => res.write('...');
  emitter.on('update', onUpdate);
  res.on('close', () => emitter.off('update', onUpdate));
});

العقدة تحذر من هذا —MaxListenersExceededWarning هو مؤشر تسرب، وليس ضوضاء لقمعها.

الموقتات التي لم يتم مسحها أبدًا. كلsetInterval يحمل إغلاقه وكل ما يشير إليه حي إلى الأبد.

إغلاق التقاط الأشياء الكبيرة. رد الاتصال الذي يشير إلى حقل واحد من كائن ضخم يبقي الكائن بأكمله قابلاً للوصول.

العثور على تسرب باستخدام لقطات الكومة

التخمين بطيء. خذ لقطات وقارن.

# Start with the inspector attached
node --inspect server.js
# Open chrome://inspect in Chrome, click "inspect", go to the Memory tab

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

يمكنك أيضًا تشغيل لقطات من داخل العملية، وهو أمر مفيد في الإنتاج.

import v8 from 'node:v8';
import fs from 'node:fs';

process.on('SIGUSR2', () => {
  const file = `/tmp/heap-${Date.now()}.heapsnapshot`;
  fs.writeFileSync(file, v8.getHeapSnapshot());
  console.log('Heap snapshot written to', file);
});
// Then: kill -SIGUSR2 <pid>

قم بتسجيل استخدام الكومة بشكل مستمر لتأكيد التسرب قبل البحث عنه.

setInterval(() => {
  const m = process.memoryUsage();
  console.log({
    rss:       `${(m.rss / 1e6).toFixed(0)}MB`,
    heapUsed:  `${(m.heapUsed / 1e6).toFixed(0)}MB`,
    heapTotal: `${(m.heapTotal / 1e6).toFixed(0)}MB`,
    external:  `${(m.external / 1e6).toFixed(0)}MB`,
  });
}, 30_000);

نمط مسنن صحي – جمع القمامة يستعيد الذاكرة. الدرج الذي لا يرتفع إلا هو تسرب.

السبب 4: إنشاء نفاد الذاكرة

تعد إصدارات TypeScript أو webpack أو Next.js الكبيرة متعطشة للذاكرة وتفشل كثيرًا في حاويات CI ذات الحدود المتواضعة.

# Give the build more headroom
NODE_OPTIONS=--max-old-space-size=6144 npm run build

# TypeScript: incremental builds reuse previous work
tsc --incremental --noEmit

# Split very large builds into projects
tsc --build tsconfig.json

في CI، تأكد من أن العداء لديه بالفعل الذاكرة التي تسمح بها. يؤدي حد الكومة البالغ 6 جيجابايت على مشغل سعة 4 جيجابايت إلى قتل OOM بدلاً من حدوث خطأ مفيد.

حدود عامل الميناء والحاويات

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

services:
  api:
    image: my-api
    environment:
      # Keep the heap below the container limit, leaving room for
      # native allocations, buffers, and the runtime itself.
      NODE_OPTIONS: "--max-old-space-size=1536"
    deploy:
      resources:
        limits:
          memory: 2G

الفجوة بين حد الكومة وحد الحاوية مهمة. يتم تخصيص المخازن المؤقتة والوحدات النمطية الأصلية ووقت التشغيل خارج كومة JavaScript، و--max-old-space-size لا حساب لهم.

التسلسل التشخيصي

  1. حدد ما إذا كان هذا خطأ V8 أو إنهاء نظام التشغيل – رمز الخروج 137 يعني أن النواة هي التي فعلت ذلك.
  2. سجلprocess.memoryUsage() متأخر , بعد فوات الوقت. الارتفاع بشكل رتيب يعني حدوث تسرب.
  3. إذا فشل عند الإدخال الكبير فقط، فابحث عن تحميل الملف بالكامل أو تحميل مجموعة النتائج بالكامل.
  4. إذا كان خادمًا يعمل لفترة طويلة، فالتقط لقطتين من الكومة وقارن بين عمليات الدلتا.
  5. تحقق من شجرة التجنيب بحثًا عن الكائنات المتنامية للعثور على ما يحملها.
  6. قم برفع حد الكومة فقط بمجرد التأكد من أن الاستخدام مشروع.

الأسئلة المتداولة

س: ما هو الحد الافتراضي للكومة؟
ج: يعتمد ذلك على إصدار العقدة وذاكرة النظام المتوفرة، والإصدارات الحديثة حجمها أكثر منطقية من الإصدارات القديمة. تحقق لك معnode -e "console.log(v8.getHeapStatistics().heap_size_limit / 1e6)".

س: هل رفع الحد الأقصى لحجم المساحة القديمة آمن؟
ج: بالنسبة لمهام البناء والدفعات التي تتطلب ذاكرة حقيقية، نعم – بشرط أن يكون الجهاز مزودًا بالذاكرة. بالنسبة للخادم المتسرب، فإن ذلك يؤدي إلى تأجيل التعطل ويجعل تشخيصه أكثر صعوبة.

س: لماذا يتعطل تطبيقي مع رمز الخروج 137 ولا يوجد خطأ؟
ج: قام قاتل OOM الخاص بالنواة بإرسال SIGKILL، والذي لا يمكن القبض عليه. يتجاوز حد الكومة حد الحاوية، أو أن شيئًا ما خارج الكومة يستهلك الذاكرة.

س: هل يمكنني فرض جمع القمامة؟
ج: مع--expose-gc يمكنك الاتصال بـglobal.gc()، وهو أمر مفيد لاختبار ما إذا كان يتم الاحتفاظ بالذاكرة أم لا. إنه ليس حلاً – إذا لم تتم استعادة الذاكرة، فسيظل هناك شيء يشير إليها.

س: هل تساعد مؤشرات الترابط العاملة؟
ج: يحصل كل عامل على الكومة الخاصة به، لذا فإن تقسيم العمل عليها يؤدي إلى زيادة السعة الإجمالية. لا يصلح التسرب. فهو يوزعها.

الخلاصة

ابدأ بتصنيف الفشل:عادة ما يعني خطأ كومة V8 في أحد الإصدارات أن العمل يحتاج حقًا إلى المزيد من الذاكرة، في حين أن النمو المطرد في خادم يعمل لفترة طويلة يعني حدوث تسرب. يمكنك دفق الملفات ونتائج استعلام ترقيم الصفحات بدلاً من تحميل كل شيء مرة واحدة، وربط كل ذاكرة تخزين مؤقت بحدود الحجم ومدة البقاء (TTL)، وإزالة المستمعين ومسح المؤقتات عندما ينتهي نطاقهم، واستخدام مقارنة لقطات الكومة للعثور على ما يتم الاحتفاظ به بالفعل. رفع--max-old-space-size فقط بعد التأكد من شرعية الاستخدام، واحتفظ به دائمًا بشكل مريح أقل من الحد الأقصى لذاكرة الحاوية.

MD Rafikul Islam

Written by

MD Rafikul Islam is a software developer and the editor of TechPulse. He writes about developer tooling, hardware, and the practical decisions that come up in day-to-day engineering work — which laptop to buy, which framework to commit to, why a build broke at 2am. He tests the tools he writes about and says plainly when something is not worth the money. Corrections and corrections requests are welcome at rony.yf25@gmail.com.

✍️ Leave a Comment

Your email address will not be published. Required fields are marked *

🌐 Read in:🇬🇧 English🇩🇪 Deutsch🇧🇷 Português🇸🇦 العربية🇮🇳 हिन्दी🇧🇩 বাংলা