توسيع Node.js: النسخة غير المثيرة
معظم مقالات "توسيع Node.js" يكتبها أشخاص وسّعوا خدمة واحدة إلى مليون طلب يوميًا وافترضوا أن الأمر ينطبق على كل الحالات. نحن شغّلنا عدة أنظمة تجاوزت 10 ملايين طلب يوميًا، والدروس المستفادة ليست برّاقة.
هذا هو الدليل العملي الذي كنا نتمنى لو امتلكناه قبل أربع سنوات.
توقفوا عن الجدال حول الخدمات المصغّرة
أكثر خطأ مكلف شاهدنا فرقًا ترتكبه هو الانتقال إلى الخدمات المصغّرة قبل الحاجة الفعلية إليها. النظام الأحادي (monolith) ذو حدود وحدات نظيفة يتوسّع أبعد مما يتوقعه معظم المهندسين، وتشغيله أرخص بمقدار مرتبة كاملة.
قاعدتنا: ابقَ على البنية الأحادية إلى أن يتحقق أحد هذه الشروط.
- مسار معيّن له خصائص توسّع مختلفة عن بقية النظام (مثل نقطة نهاية ثقيلة لاستدلال تعلّم آلي).
- مسار معيّن له وتيرة نشر مختلفة (مثل كود مرتبط بالامتثال يجب إصداره شهريًا بينما يُنشر باقي النظام يوميًا).
- تجاوز الفريق 25 مهندسًا وأصبح تعارض الدمج (merge contention) مشكلة حقيقية.
إذا لم يتحقق أي من هذه الشروط، فإن تقسيم الخدمات يضيف تكلفة تشغيلية — اكتشاف الخدمات (service discovery)، والتتبع الموزّع (distributed tracing)، وتنسيق النشر، وأنماط فشل الشبكة — دون أن يمنحك أي شيء لا يمكنك الحصول عليه من مستودع أحادي (monorepo) نظيف.
عنق الزجاجة الحقيقي هو قاعدة البيانات
في كل مشروع توسيع لـ Node.js قمنا به، انتقل عنق الزجاجة في النهاية إلى قاعدة البيانات. المعالج والذاكرة في خدمات Node.js يكونان عادةً بخير لأن Node جيد في التعامل مع تزامن الإدخال/الإخراج (I/O). المشكلات هي:
- مجمّعات اتصال (connection pools) غير محدودة تُنهك Postgres
- فهرس واحد شديد الاستخدام يتحول إلى عنق زجاجة للكتابة
- استعلامات N+1 في أدوات ORM تبدو بريئة أثناء مراجعة الكود
- معاملات (transactions) طويلة الأمد تحتفظ بالأقفال أثناء تنفيذ منطق العمل
- توجيه حركة القراءة إلى الخادم الأساسي بدلًا من نسخة قراءة (read replica)
الخطوات العملية الأولى:
- PgBouncer أو ما يعادله أمام Postgres، في وضع تجميع المعاملات (transaction pooling)، مع سقف اتصالات يُحدَّد بناءً على استهلاك المعالج الفعلي لقاعدة البيانات لا على حدس Node.js.
- نسخ قراءة (read replicas) مع توجيه صريح. لا تعتمد على ORM لاتخاذ القرار. اجعل هناك عميلين (clients) في الكود:
db.readوdb.write. أجبر كل مطوّر على الاختيار بينهما. - قياس زمن الاستعلامات في بيئة الإنتاج. استخدم Pino مع أداة تسجيل للاستعلامات البطيئة. تريد تنبيهًا على Slack عندما يتجاوز أي استعلام 200ms.
- تشغيل EXPLAIN ANALYZE ضمن CI للاستعلامات الجديدة. نضيف هذا كفحص إلزامي على طلبات السحب (PR) التي تمسّ المخطط (schema).
التخزين المؤقت على ثلاث طبقات
التخزين المؤقت ينقذك بهذا الترتيب: CDN، ثم الذاكرة الداخلية (in-memory)، ثم Redis. تجاوز أي طبقة منها وستندم على ذلك.
CDN. كل ما يمكن تخزينه مؤقتًا عند الحافة (edge) يجب أن يُخزَّن هناك. ويشمل هذا استجابات API لعمليات القراءة غير الموثّقة (unauthenticated). لا داعي للتعقي د — ترويسات Cache-Control مع CDN يحترمها تحلّ 60% من حركة القراءة مجانًا.
الذاكرة الداخلية (In-memory). ذاكرة تخزين مؤقت صغيرة من نوع LRU (نستخدم lru-cache) أمام Redis تلتقط الطلبات المتكررة ضمن العملية (process) الواحدة. يبدو الأمر بسيطًا، لكنه يوفّر 30–50% من حركة Redis في تحليلاتنا.
Redis. للحالة المشتركة والقيم المحسوبة التي يجب أن تبقى عبر العمليات المختلفة. استخدمه بوعي — كل مفتاح يحتاج إلى مالك، ومدة صلاحية (TTL)، واستراتيجية إبطال موثّقة. المفاتيح غير المتابَعة في Redis هي السبب في أن تنتهي ذاكرات التخزين المؤقت في الإنتاج بتقديم بيانات عمرها 6 أشهر يوم Black Friday.
اجعل كل ما يمكن أن يكون غير متزامن، غير متزامن
يجب أن تقوم مسارات الطلبات المتزامنة (synchronous) بالحد الأدنى فقط: التحقق من الصحة، والحفظ، والرد. أي شيء آخر — رسائل البريد الإلكتروني، إطلاق webhooks، معالجة الصور، استدعاءات API لأطراف ثالثة، فهرسة البحث — يجب أن يذهب إلى طابور (queue).
نستخدم BullMQ فوق Redis في كل شيء تقريبًا، لسببين:
- عزل الأعطال. لا يمكن لـ API خارجي غير مستقر أن يُسقط مسار الطلب لديك إذا كان الاستدعاء يحدث داخل عامل (worker).
- الضغط العكسي (Backpressure). عندما ترتفع حركة المرور فجأة، يمتص الطابور هذا الارتفاع ويقوم العمال بتفريغه بمعدل مستدام. أما الكود المتزامن فينهار ببساطة تحت هذا الضغط.
مثال ملموس: مسار دفع (checkout) أعدنا بناءه لأحد العملاء كان يطلق 6 استدعاءات لأطراف ثالثة بشكل مباشر (الضرائب، كشف الاحتيال، التنفيذ، البريد الإلكتروني، الرسائل النصية، التحليلات). كان الوقت الوسيط لإتمام الدفع 1.4 ثانية، وارتفع p99 إلى 8 ثوانٍ عندما يتباطأ أحد المزوّدين. وبعد نقل كل شيء باستثناء الضرائب وكشف الاحتيال إلى عمال BullMQ، انخفض الوقت الوسيط إلى 220ms وبقي p99 دون 600ms.
قابلية الرصد قبل الهندسة الذكية
الغريزة عند التوسّع هي تصميم أنظمة ذكية. لكن التحرك الصحيح هو تجهيز النظام الذي لديك بأدوات القياس (instrumentation) إلى أن ترى بدقة أين يُصرف الوقت.
نعتمد بشكل موحّد على:
- تتبع OpenTelemetry، يُصدَّر إلى Honeycomb أو Tempo. لكل طلب معرّف تتبع (trace ID)، وكل استدعاء خارجي هو span.
- سجلات منظّمة (structured logs) بصيغة JSON، تتضمن معرّف التتبع. Pino خيار جيد.
- مقاييس RED لكل نقطة نهاية — المعدّل (Rate)، الأخطاء (Errors)، المدة (Duration). لوحات Grafana. والتنبيهات على p99 وليس p50.
- فحوصات اصطناعية (synthetic checks) من خارج شبكتك، تعمل كل دقيقة على المسارات الحرجة.
لن تتمكن من التنبؤ بمواضع عنق الزجاجة لديك بدقة. ستقيسها فعليًا. والفريق الذي يُطلق قابلية الرصد أولًا هو من يفوز بالأشهر الستة القادمة من العمل على التوسّع.
الأخطاء الشائعة التي شهدناها مرارًا
قائمة قصيرة بأشياء رأيناها تُسقط بيئة الإنتاج:
- الإفراط في التسجيل (logging) إلى درجة إسقاط خط معالجة السجلات، وبالتالي فقدان السجلات كليًا.
- نسيان ضبط
--max-old-space-sizeفي Node. القيمة الافتراضية 1.5GB، بينما حاوية (container) لديك سعتها 8GB. المعادلة واضحة. - الكتابة إلى قاعدة البيانات ضمن حلقة
Promise.allدون تحديد سقف للتزامن. ما يؤدي إلى استنفاد مجمّع الاتصالات في أسوأ لحظة. - فحوصات سلامة (health checks) تنجح رغم أن الخدمة معطّلة فعليًا. اختبر سلسلة الاعتماديات (dependency chain) لا مجرد العملية نفسها.
- الثقة بأن SDK طرف ثالث سيضبط مهلات (timeouts) مناسبة. لفّها دائمًا بمهلاتك الخاصة.
الحقيقة غير البرّاقة
عند 10M طلب يوميًا، نادرًا ما يكون الفرق بين نظام يعمل وآخر لا يعمل عبقريةً معمارية. الفرق هو فريق يراقب لوحاته باستمرار، ويُصلح الاستعلامات البطيئة في الأسبوع الذي تظهر فيه، ويجري مراجعات صادقة للحوادث، ويقاوم إغراء إعادة الهيكلة عندما يكون المطلوب هو التجهيز بأدوات القياس.
اضبط هذه الثقافة بشكل صحيح، وستصمد Node.js إلى مدى أبعد مما توحي به محاضرات ال مؤتمرات. وهذا بالضبط نوع العمل الذي يتولاه فريقا الخلفية البرمجية والسحابة وعمليات DevOps لدينا للمنتجات في مرحلة النمو.



