JSON Toolkit

دفتر ذاكرة JSON: التكلفة الفعلية للنصوص والكائنات والمصفوفات الكبيرة

الخلاصة: ملف JSON بحجم 10 ميغابايت يمكن أن يصبح 30-50 ميغابايت في كومة V8. تعرّف على التكلفة الفعلية للنصوص والكائنات والأرقام، وكيفية أخذ خط أساس عبر process.memoryUsage، ومتى يساعد التدفق.

متقدمقراءة نحو 11 دقيقةنُشر في 2026-10-31حُدِّث في 2026-10-31
دفتر ذاكرة JSON: تكلفة الكومة للنصوص والكائنات والمصفوفات في V8

الخلاصة: ابدأ بخط أساس عبر process.memoryUsage، وسجّل النص والرسم البياني للكائنات كزيادتين منفصلتين، ثم أزل نسخة كاملة واحدة قبل تجربة أي شيء آخر — لا JSON.parse(JSON.stringify(x))، ولا تغليف تفاعلي للوثيقة الخام. اقطع المراجع طويلة العمر بعد ذلك، واعتبر التدفق آخر الخيارات: منحنى ذاكرته المسطّح يستحق خسارة الوصول العشوائي فقط عندما لا يتسع الملف أو عندما تحتاج بضعة حقول فقط.

وثيقة JSON بحجم 10 ميغابايت يمكن أن تصبح 30 إلى 50 ميغابايت في الكومة بعد JSON.parse، والذروة العابرة أعلى من ذلك. بدون إجراء هذه الحسابات، تكون "التحسين" مجرد تخمين.

1. النص والرسم البياني للكائنات دفتران مختلفان

على القرص وفي جسم الطلب، JSON هو تتابع من البايتات. وبمجرد دخوله إلى الذاكرة، يصبح شيئين: نصاً، ورسمًا بيانيًا للكائنات مكوّنًا من كائنات ومصفوفات وأرقام وقيم منطقية. الحفاظ على هذين الدفترين منفصلين هو السبيل الوحيد لتفسير كيف يمكن لملف بحجم 10 ميغابايت أن يجعل عملية تستهلك 80 ميغابايت.

شكل البيانات الرسم البياني للكائنات مقابل حجم النص التكلفة المهيمنة
مصفوفة مسطّحة من الكائنات (نحو 8 حقول لكل منها) نحو 2.5–4 أضعاف ترويسة كائن وخانات خصائص لكل عنصر
إعداد متداخل بعمق نحو 3–5 أضعاف كائنات صغيرة كثيرة، لكل منها ترويستها
مصفوفة من نصوص طويلة جداً نحو 1.2–2 ضعف تراويس النصوص مع بيانات المحارف
أرقام كبيرة بعشريات نحو 2–3 أضعاف HeapNumber واحد لكل قيمة
وثائق بمفاتيح متكررة كثيرة نسبي إلى عدد المفاتيح تجزئة المفاتيح وإعداد الخصائص أثناء التحليل

هذه المضاعفات مراجع من حيث رتبة المقدار، وتعتمد على إصدار V8، وضغط المؤشرات، وعدد الحقول، ومحتويات النص. والجدول يوصل نقطة واحدة: يُقاس حجم الرسم البياني للكائنات بـ"عدد العقد × التكلفة الإضافية لكل عقدة"، لا بعدد بايتات النص.

2. النصوص: البند الأكثر استهانة

نص V8 ليس مصفوفة محارف عارية. إنه يحمل ترويسة كائن (مؤشر map، وحقل hash، وطول)، وتكلفة ذلك نحو دستة بايتات في بناء 64-بت نموذجي مع ضغط المؤشرات، قبل أي محرف. وتُخزّن المحارف بإحدى طريقتين:

  • نصوص بايت واحد للمحارف في نطاق Latin-1: بايت واحد لكل محرف؛
  • نصوص بايتين عندما يحتوي المحتوى على محارف CJK أو إيموجي أو أي شيء خارج Latin-1: بايتان لكل محرف.

المحرف الصيني يشغل 3 بايتات في ملف UTF-8 لكن بايتين لكل محرف في نص بايتين، إضافة إلى الترويسة. حجم النص وحجم الكومة ليسا القياس نفسه، ولا يتحركان دائماً في الاتجاه نفسه.

ثلاث تفاصيل تهم مباشرة في عمل JSON:

  1. الشرائح تُبقي الأصل حياً. تمثّل V8 النصوص الفرعية الأطول كـSlicedString، التي تخزّن مرجعاً للأصل مع إزاحة وطول. إذا خزّنت 200 محرف مقتطعة من سجل بحجم 80 ميغابايت، فسيظل الـ80 ميغابايت كاملة قابلاً للوصول حتى تُحرَّر تلك الشريحة.
  2. الدمج ينشئ ConsString. += المتكرر على نصوص كبيرة يبني شجرة من الشظايا لا تُسطّح في ذاكرة متجاورة إلا عند قراءة المحارف فعلياً. الدمج عشرة آلاف مرة ثم القراءة مرة واحدة يُنتج ارتفاعاً كبيراً في التخصيص في تلك اللحظة.
  3. القيم المتكررة لا تُشارَك تلقائياً. عشرة آلاف سجل يحتوي على "status": "paid" يحصل عادةً على عشرة آلاف كائن نص مستقل؛ فالمحلل لا يوجّهها إلى ثابت واحد. لتوفير تلك التكلفة يجب أن تنقل النصوص المتكررة إلى قيم enum أو أرقام بنفسك.

عندما تحتاج إلى الاحتفاظ بمقتطف قصير لمدة طويلة، حوّله إلى نص مستقل وحرّر الأصل:

// سيئ: الشريحة تشير إلى الوثيقة كاملة، لذا لا يمكن جمع logText
const summary = logText.slice(0, 200);

// جيد: أنشئ نصاً مستقلاً، ثم حرّر logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');

3. الكائنات: كل مفتاح يشغل خانة

الكائن العادي يحتوي على ثلاثة أشياء على الأقل: مؤشر إلى فئته المخفية (map)، ومؤشر إلى مخزن خصائصه، ومؤشر إلى مخزن عناصره. مع ضغط المؤشرات يكون كل مؤشر 4 بايتات، وترويسة كائن الكومة تضيف المزيد، لذا حتى الكائن الفارغ يكلف نحو ستة عشر إلى عشرين شيئاً من البايتات. وفي بناء 64-بت بدون ضغط مؤشرات، ضاعف ذلك.

تستقر الخصائص المسماة في أحد مكانين:

  • خصائص داخل الكائن: خانات موضوعة مباشرة بعد ترويسة الكائن، وهذا يمنح أسرع وصول؛
  • PropertyArray: بعد استنفاد خانات الكائن، تنتقل الخصائص إلى مصفوفة منفصلة ويحمل الكائن مؤشراً إليها.

كل خانة 4 أو 8 بايتات، ويُحسب نص المفتاح بشكل منفصل. لهذا تصبح أسماء الحقول الطويلة تكلفة حقيقية عبر مئات الآلاف من السجلات، لا مجرد مسألة جمالية.

الخبر الجيد أن الفئات المخفية تُشارَك: مليون كائن بالمفاتيح نفسها وبالترتيب نفسه يتشاركون map واحداً بدلاً من نسخ المعلومات البنيوية. والخبر السيئ أن هذه الأنماط تكسر ذلك المشاركة أو تدفع الكائن إلى تمثيل أغلى:

  • بعض السجلات تحتوي على حقل remark وبعضها لا، فينتج شكلان وفحص شكل إضافي في كل وصول؛
  • ترتيبات إدراج مختلفة يمكن أن تنتج أشكالاً مختلفة؛
  • أعداد الخصائص الكبيرة جداً (تجاوز نحو ألف) أو عمليات delete المتكررة تدفع الكائن إلى وضع القاموس، حيث تحمل كل خاصية أيضاً مدخلاً في جدول تجزئة؛
  • Object.create(null) في وضع القاموس منذ ولادته. إنه جدول بحث جيد وخيار سيئ لسجل بمخطط ثابت يُقرأ في حلقة ساخنة.

هناك مفهوم خاطئ شائع يستحق تصحيحاً: JSON يسمح بمفاتيح مكررة، و{"a":1,"a":2} وثيقة صالحة نتيجتها a === 2. الكائن النهائي يحتفظ بقيمة واحدة، لكن المحلل ما زال يمسح كل ظهور ويسنده. المفاتيح المكررة لا توفّر ذاكرة؛ إنها تكلف وقت تحليل فقط.

4. المصفوفات والأرقام: أنواع العناصر تحدد العرض

تتتبع V8 عدة "أنواع عناصر" للمصفوفات، ولكل منها عرض مختلف لكل خانة:

محتويات المصفوفة نوع العنصر التكلفة التقريبية لكل خانة
أعداد صحيحة صغيرة (داخل نطاق Smi) PACKED_SMI_ELEMENTS 4 بايتات
عشريات أو أعداد صحيحة كبيرة جداً PACKED_DOUBLE_ELEMENTS 8 بايتات
نصوص أو كائنات PACKED_ELEMENTS مؤشر 4 بايتات زائد القيمة نفسها
مصفوفات بها فراغات منشأة بـdelete HOLEY_* نفسها، زائد فحوصات الفراغات
مصفوفات متباعدة جداً DICTIONARY_ELEMENTS مدخلات تجزئة، عادةً 20+ بايتاً

قاعدتان تستحقان الحفظ:

أولاً، أنواع العناصر تتدهور فقط. المصفوفة التي تبدأ بأعداد صحيحة صغيرة تصبح مصفوفة double في اللحظة التي تُدخل فيها 0.5، وتصبح مصفوفة عناصر عامة عند إدخال نص. وهي لا تستعيد حالتها تلقائياً أبداً؛ عليك بناء مصفوفة جديدة.

ثانياً، المكان الذي يستقر فيه رقم JSON يعتمد على قيمته. الأعداد الصحيحة داخل نطاق Smi (نحو زائد أو ناقص 1.07 مليار) تُخزّن مباشرة كـSmi وتشغل خانة مؤشر واحدة. والأرقام ذات العشريات أو الأسس، أو القيم خارج ذلك النطاق، تُخصّص HeapNumber بحجم نحو 16 بايتاً، مع مؤشر إليها مخزّن في المصفوفة. لذلك [1, 2, 3] و[0.1, 0.2, 0.3] يختلفان ليس في القيمة فقط بل في "خانة واحدة بـ4 بايتات" مقابل "خانة بـ8 بايتات زائد كائن كومة".

هذا أيضاً أصل مشكلة دقة معرفات الأعداد الصحيحة الكبيرة: خارج نطاق الأعداد الصحيحة الآمنة، تُعامَل أرقام JSON كـdoubles وتختفي الأرقام المنخفضة ببساطة. تصل مصفوفات JSON مضغوطة وخالية من الفراغات، لذا لا تستخدم delete على العناصر بعد التحليل؛ فذلك يدفع المصفوفة كلها إلى تمثيل أغلى.

5. ذروتا التحليل والتسلسل: اللحظة التي تنهار فيها العملية فعلاً

ذاكرة الحالة المستقرة دفتر؛ والذروة دفتر آخر. أثناء تشغيل JSON.parse(text)، تحمل العملية ثلاثة أشياء في آن واحد:

  1. نص الإدخال text؛
  2. الرسم البياني للكائنات قيد البناء وغير مكتمل بعد؛
  3. البنى المؤقتة الخاصة بالمحلل.

ذروة التحليل تقريباً "نص + رسم بياني للكائنات + تكلفة مؤقتة". بعد التحليل، إذا ظل text مُشاراً إليه من متغير أو إغلاق أو ذاكرة تخزين مؤقت على مستوى الوحدة أو مخزن تفاعلي، فلن تُعاد ذاكرة النص أبداً.

للتسلسل ذروة أيضاً. JSON.stringify(data) يحتفظ بالرسم البياني الكامل للكائنات أثناء بناء نص الخرج، مع مخازن الدمج في البين. وعندما يقترب الخرج من حد طول النصوص في V8 (نحو 500 مليون محرف)، فإنه يرمي RangeError؛ وهذا الفشل يظهر عادةً في السكربتات التي تصدّر قاعدة بيانات كاملة كوثيقة JSON واحدة.

النمط الشائع الأغلى يستخدم التسلسل كنسخة عميقة:

// الذروة تقريباً: الرسم البياني الأصلي + نص مؤقت + رسم بياني جديد
const safeCopy = JSON.parse(JSON.stringify(bigData));

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

6. القياس باستخدام process.memoryUsage

في Node، تعيد process.memoryUsage() خمسة أرقام في آن واحد: rss (الذاكرة الفعلية المستخدمة من العملية)، وheapTotal، وheapUsed (كومة JS المستخدمة)، وexternal (الذاكرة المحتجزة بواسطة كائنات C++)، وarrayBuffers (ذاكرة ArrayBuffer وBuffer). عند دراسة معالجة JSON، heapUsed هو العدسة المناسبة للرسم البياني للكائنات، وrss يخبرك ما إذا كانت العملية كلها خارجة عن السيطرة.

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

// التشغيل: node --expose-gc measure-json-memory.mjs
import { readFileSync } from 'node:fs';

global.gc();
const baseline = process.memoryUsage().heapUsed;

const text = readFileSync('big.json', 'utf8');
const afterRead = process.memoryUsage().heapUsed;

const data = JSON.parse(text);
const afterParse = process.memoryUsage().heapUsed;

console.log({
  textOnHeap: afterRead - baseline,
  graphOnHeap: afterParse - afterRead,
  parsePeak: afterParse - baseline,
});

بعض المخاطر:

  • يجب تمكين --expose-gc صراحةً؛ وإلا فلا وجود لـglobal.gc؛
  • heapUsed يغطي كومة JS فقط. مخازن قراءة الملفات تعيش تحت external وarrayBuffers، لذا لا تستخدم رقماً واحداً لتفسير كل شيء؛
  • لا تستخدم console.log على كائن كبير بين العينات؛ فهو يُبقي مرجعاً مؤقتاً حياً؛
  • تتضمن التشغيلة الأولى تسخين JIT والترجمة الكسولة. شغّل ثلاث دورات على الأقل وخذ الوسيط؛
  • للمقارنة في المتصفح، التقط لقطات كومة في لوحة Memory في DevTools واقرأ Retained Size بدلاً من التحديق في منشار Allocation Timeline.

بدون خط الأساس هذا، تكون ادعاءات مثل "التبديل إلى التدفق سيعالج الأمر" أو "ذاكرة التخزين المؤقت ستجعله أسرع" مجرد أحاسيس.

7. التدفق يشتري ذاكرة الذروة لا الوقت الكلي

المقايضة في التحليل التدفقي صريحة: الوصول العشوائي مقابل ذاكرة لم تعد تتزايد مع حجم الملف.

النهج ذاكرة الذروة الوصول العشوائي الأنسب لـ
JSON.parse كامل النص زائد الرسم البياني الكامل للكائنات نعم أقل من نحو 100 ميغابايت ومعظم الحقول مطلوبة
محلل SAX مثل stream-json قطعة واحدة زائد العنصر الحالي لا ملفات ضخمة، أو عندما تكون بضعة حقول مطلوبة فقط
JSON Lines سطراً بسطر كائن سطر واحد لا بيانات هي بطبيعتها دفعة من السجلات
فهرس خارجي أو قاعدة بيانات لا شيء يُحمَّل كاملاً نعم استعلامات متكررة على البيانات نفسها
import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';

const pipeline = createReadStream('huge.json')
  .pipe(parser())
  .pipe(pick({ filter: 'items' }))
  .pipe(streamArray());

for await (const { value } of pipeline) {
  handleItem(value);
}

هذا الكود لا يبني الشجرة الكاملة أبداً، لذا يبقى منحنى الذاكرة مسطّحاً. والتكاليف حقيقية أيضاً: لا يمكنك العودة إلى حقل تخطيته، وتصبح التجميعات العامة صعبة، ومسؤولية التعافي من الأخطاء عليك، ومسح محرف-بمحرف في JS خالص عادةً أبطأ برتبة مقدار من JSON.parse الأصلي.

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

8. نسخ أقل وإفراج أبكر

في عمل الذاكرة، إزالة نسخة كاملة واحدة غالباً تستحق أكثر من تبديل المحللات:

  • مرّر المراجع للعمل للقراءة فقط. لا تستنسخ نسخة عميقة "من أجل الأمان"، خصوصاً ليس بـJSON.parse(JSON.stringify(x))؛
  • لا تُغلّف الوثيقة الخام في نظام تفاعلي. reactive في Vue تمثّل الرسم البياني كاملاً بشكل عودي، ولطبقة الوسيط تكلفة ذاكرة خاصة بها؛
  • احسب وذاكر البيانات المشتقة بشكل منفصل. إذا كانت القائمة تحتاج id وtitle وstatus فقط، فلا تضع السجلات كاملة في المخزن؛
  • وحّد النصوص عالية التكرار. انقل قيماً مثل "paid" و"pending" إلى enums رقمية لتوفير كائنات النصوص وتكلفة المقارنة معاً؛
  • اقطع المراجع طويلة العمر. أفرّغ الكائنات الكبيرة عند الانتهاء، وامسح ذاكرات التخزين المؤقت، وألغِ تسجيل ردود النداء حتى لا تنقلها الإغلاقات إلى الصفحة التالية؛
  • حسب تكاليف نسخ Worker. postMessage يستخدم structured clone افتراضياً؛ إرسال رسم بياني بحجم 50 ميغابايت إلى الخيط الرئيسي يبني نسخة ثانية. أعِد ملخصاً كلما أمكن.

النصف الآخر من دفتر الحجم يعيش في توقّف عن ضغط JSON بترتيب خاطئ: بايتات القرص وبايتات الشبكة حساب مختلف عن الرسم البياني للكائنات في الكومة، ولا نسبة واحدة تفسر كليهما. لمعرفة سلوك المحررات وأدوات CLI مع الذاكرة، انظر المحررات وأدوات سطر الأوامر والأتمتة.

9. قائمة الفحص

العَرَض السبب الأرجح الإجراء الأول
ملف 10 ميغابايت يدفع العملية إلى 50 ميغابايت الرسم البياني للكائنات والنص كلاهما يبقى حياً قِس على مراحل؛ تحقق مما إذا كان النص محتفظاً به
OOM عند التحليل، وضع طبيعي في الحالة المستقرة ذروة التحليل تتداخل مع البيانات القديمة حرّر الكائنات القديمة قبل تحليل الملف الجديد
الذاكرة ترتفع ببطء ولا تنخفض أبداً شريحة أو ذاكرة تخزين أو إغلاق يحمل مرجعاً التقط لقطة كومة ورتّب حسب الحجم المحتفظ به
المصفوفات أكبر من المتوقع عشري أضعف نوع العنصر وحّد الأنواع الرقمية؛ تجنّب delete
إضافة ذاكرة تخزين جعلت الأمور أبطأ المفاتيح والقيم تستهلك ذاكرة وترفع ضغط GC حدّد حجم الذاكرة وخزّن الملخصات فقط

لماذا تنمو الذاكرة ثلاثة أو أربعة أضعاف بعد JSON.parse؟

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

هل يجب تحليل ملف JSON بحجم 10 ميغابايت بالكامل أم تدفقه؟

انظر إلى الغرض أولاً. إذا كنت تحتاج إلى وصول عشوائي، وتحتاج إلى معظم الحقول، والملف يتسع في الذاكرة، فاستخدم JSON.parse كاملاً. وإذا كنت تحتاج بضعة حقول فقط، أو لا يتسع الملف، فدفّقه. 10 ميغابايت ليس حداً؛ يصبح التدفق إلزامياً عادةً في نقطة ما بعد 100 ميغابايت.

لماذا تختلف process.memoryUsage().heapUsed عن مراقبة النظام؟

heapUsed يغطي فقط الجزء المستخدم من كومة JS، باستثناء external وarrayBuffers ومساحة الكود. مراقبة النظام تُبلغ عن rss، وهو قياس مختلف. تتبّع heapUsed وrss معاً؛ فالفجوة الكبيرة تشير إلى Buffers والكائنات الأصلية.

هل JSON.parse(JSON.stringify(x)) نسخة عميقة آمنة؟

لا. إنه يحتفظ بالرسم البياني الأصلي والنص المؤقت والرسم البياني الجديد في آن واحد، لذا الذروة نحو ثلاثة أضعاف الحالة المستقرة، وهو يُسقط undefined والدوال ودلالات Date. مرّر المراجع للعمل للقراءة فقط؛ وعندما تكون العزلة مطلوبة، استخدم structuredClone واقبل تكلفة إعادة البناء نفسها.