تدافع الكاش في Node.js: كيف نستخدم دمج الطلبات وأقفال Redis ونمط stale-while-revalidate للمفاتيح الساخنة
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗يحدث تدافع الكاش (cache stampede) عندما تنتهي صلاحية مفتاح كاش شائع، فتفشل كل الطلبات المتزامنة في إيجاده في اللحظة نفسها وتعيد حساب القيمة نفسها من قاعدة البيانات. في Devya نمنع ذلك في خدمات Node.js لدينا عبر طبقات: ندمج عمليات التحميل المتطابقة الجارية داخل كل عملية، ونأخذ قفلًا قصيرًا في Redis عبر SET NX PX حتى تعيد نسخة واحدة فقط الحساب، ونحتفظ بانتهاء صلاحية مرن حتى يحصل القرّاء على القيمة القديمة بينما يحدّثها عامل واحد في الخلفية.
كان كود cache-aside الذي يكتبه فريقنا يبدو صحيحًا ويجتاز كل الاختبارات: نقرأ من Redis، ونستعلم من Postgres عند عدم وجود القيمة، ثم نكتب النتيجة مع TTL. لم يفشل إلا تحت تزامن حقيقي، عندما انتهت صلاحية مفتاح مكلف مثل خلاصة الصفحة الرئيسية أو جدول الأسعار أو تجميعة لوحة التحكم بينما كانت طلبات كثيرة تقرؤه. هذه ملاحظاتنا الميدانية عن الطبقات التي توقف هذا النمط من الأعطال فعلًا في خدمات Node.js وNestJS، وعن تكلفة كل طبقة.
أهم النقاط
تدافع الكاش، ويُسمّى أيضًا thundering herd أو dog-piling، هو موجة من حالات cache miss المتطابقة التي تعيد حساب القيمة نفسها في الوقت نفسه، وغالبًا ما يحدث مباشرة بعد انتهاء صلاحية مفتاح ساخن.
دمج الطلبات داخل العملية (request coalescing)، أي مشاركة Promise واحد جارٍ لكل مفتاح كاش، يزيل التحميل المكرر داخل عملية Node.js واحدة بنحو عشرة أسطر من الكود.
قفل Redis المأخوذ عبر SET key token NX PX ttl يحصر إعادة الحساب في نسخة واحدة على مستوى كل الخوادم. إنه قفل للكفاءة، وليس ضمانًا للصحة.
انتهاء الصلاحية المرن المخزّن داخل القيمة، مع TTL صارم أطول على مفتاح Redis، يتيح للقرّاء الاستمرار في الحصول على القيمة القديمة بينما يحدّثها عامل واحد، فلا يحجب انتهاء الصلاحية أي طلب أبدًا.
التذبذب العشوائي في TTL والانتهاء المبكر الاحتمالي يوزّعان عمليات التحديث على الزمن، فلا تنتهي صلاحية المفاتيح المكتوبة معًا في اللحظة نفسها.
لماذا يسبّب نمط cache-aside تدافع الكاش؟
يسبّب نمط cache-aside التدافع لأن كل قارئ لا يجد القيمة يصبح مسؤولًا عن إعادة ملء الكاش، ولا شيء يخبر بقية القرّاء بأن أحدًا يفعل ذلك بالفعل. ونمط cache-aside، ويُسمّى أيضًا التحميل الكسول (lazy loading)، هو أن يفحص التطبيق الكاش أولًا، ثم عند عدم وجود القيمة يحمّلها من قاعدة البيانات ويكتب النتيجة في الكاش.
عندما تنتهي صلاحية مفتاح ساخن مثل feed:home، فإن كل طلب يصل خلال نافذة إعادة الحساب، أي المدة التي يستغرقها الاستعلام المكلف، لن يجد القيمة وسيشغّل الاستعلام نفسه. الاستعلامات الإضافية تبطئ قاعدة البيانات، والاستعلام الأبطأ يوسّع نافذة إعادة الحساب، والنافذة الأوسع تسمح لطلبات أكثر بعدم إيجاد القيمة. هذه الحلقة المرتدة هي ما يحوّل انتهاء صلاحية عاديًا في الكاش إلى حادثة في قاعدة البيانات بينما الكاش يعمل نظريًا.
كيف ندمج عمليات تحميل الكاش المكررة داخل عملية Node.js واحدة؟
دمج الطلبات يعني أن المستدعين المتزامنين الذين يطلبون المفتاح نفسه يتشاركون Promise واحدًا جاريًا بدلًا من أن يبدأ كل منهم تحميله الخاص. نشرت حزمة singleflight في لغة Go هذا النمط، وفي Node.js يكون مجرد Map من مفتاح الكاش إلى Promise. يخزّن المستدعي الأول الـ Promise الخاص به في الـ Map، ويعيد المستدعون اللاحقون الـ Promise المخزّن، ويزيل معالج finally() المدخل عند انتهاء التحميل.
ثلاث تفاصيل مهمة. إزالة المدخل في finally() تمنع إعادة استخدام Promise مرفوض إلى الأبد: يتلقى كل منتظر الخطأ نفسه مرة واحدة، ويبدأ المستدعي التالي تحميلًا جديدًا. ويجب أن يتضمن مفتاح الدمج كل ما يغيّر النتيجة، مثل معرّف المستأجر واللغة ونطاق الصلاحيات، وإلا ستُسلَّم بيانات مستخدم إلى طلب مستخدم آخر. كما يجب ألا يلغي AbortSignal الخاص بمستدعٍ واحد التحميل المشترك، لأن مستدعين آخرين ما زالوا ينتظرونه.
دالة cache() في React ليست بديلًا. فدالة cache() في React وخاصية request memoization في Next.js تزيلان التكرار داخل عملية render واحدة على الخادم، بينما التدافع هو طلبات منفصلة كثيرة تصل في الوقت نفسه.
كيف نمنع عدة نسخ من Node.js من إعادة حساب المفتاح نفسه؟
قفل Redis قصير العمر يحصر إعادة الحساب في نسخة واحدة، لأن أمر SET في Redis مع خيار NX لا ينجح إلا للمستدعي الأول طالما مفتاح القفل موجود. دمج الطلبات يعمل لكل عملية على حدة، لذا فإن عشر نسخ خلف موازن الأحمال ستشغّل عشر عمليات إعادة حساب من دون قفل مشترك. في ioredis يكون الاستدعاء redis.set(lockKey, token, 'PX', ttlMs, 'NX')، ولا يعيد OK إلا للفائز.
قيمة القفل رمز عشوائي من crypto.randomUUID()، ويُحرَّر القفل عبر سكربت Lua صغير يحذف المفتاح فقط إذا كان ما زال يحمل ذلك الرمز. هذه المقارنة ثم الحذف تمنع نسخة من حذف قفل انتهت صلاحيته وأخذته نسخة أخرى. نضبط TTL القفل أعلى بوضوح من أبطأ إعادة حساب نتوقعها، وإذا تجاوزت إعادة الحساب عمر القفل فأسوأ الاحتمالات إعادة حساب إضافية واحدة، وهذا مقبول في الكاش.
نتعامل مع هذا القفل بوصفه قفل كفاءة فقط. فقفل Redis على عقدة واحدة من دون fencing tokens لا يضمن الاستبعاد المتبادل خلال failover أو توقف طويل لجامع القمامة، لذلك لا نستخدمه أبدًا لحماية المدفوعات أو المخزون. ويترك القفل سؤالًا مفتوحًا: ماذا تعيد النسخ الخاسرة؟ الانتظار الدوري حتى تظهر القيمة يضيف زمن استجابة وحملًا على Redis، ولهذا نقرن القفل بالقيم القديمة.
كيف نمنع انتهاء صلاحية الكاش من حجب أي طلب؟
تخزين انتهاء صلاحية مرن داخل القيمة المخزّنة، مع TTL صارم أطول على مفتاح Redis، يتيح لكل قارئ الاستمرار في تقديم القيمة القديمة بينما يحدّثها عامل واحد. إنها فكرة stale-while-revalidate في HTTP مطبّقة على كاش التطبيق. يحمل كل مدخل القيمة، وطابعًا زمنيًا freshUntil، وcomputeMs أي المدة التي استغرقتها آخر إعادة حساب.
في كل قراءة يُعاد المدخل الحديث كما هو. ويُعاد المدخل القديم فورًا أيضًا، مع بدء تحديث في الخلفية عبر الدمج داخل العملية وقفل Redis، بحيث تشغّل نسخة واحدة على الأكثر دالة التحميل. فقط حالة cold miss، بعد مسح الكاش أو لمفتاح جديد تمامًا، تجعل القارئ ينتظر، وفي هذه الحالة تحمّل النسخ الخاسرة للقفل البيانات مباشرة مع بقائها مدموجة في استعلام واحد لكل عملية. بدأنا بـ TTL صارم يساوي عشرة أضعاف الـ TTL المرن، ثم قصّرناه للمفاتيح التي قد تضلّل فيها البيانات القديمة جدًا المستخدمين.
في عملية NestJS طويلة العمر، ينتهي التحديث الذي يُطلق دون انتظار من تلقاء نفسه. أما على Vercel Functions أو أي بيئة serverless، فقد تُعلَّق النسخة بمجرد إرسال الاستجابة، لذلك نمرّر Promise التحديث إلى waitUntil() من @vercel/functions، أو نجدوله عبر after() في Next.js، لإبقائه حيًا بعد الاستجابة.
كيف نوزّع تحديثات الكاش على الزمن؟
الانتهاء المبكر الاحتمالي والتذبذب العشوائي في TTL يوزّعان التحديثات على الزمن، فلا يتحدّث مفتاح ساخن واحد ولا آلاف المفاتيح المكتوبة معًا في اللحظة نفسها. الانتهاء المبكر الاحتمالي يجعل كل قارئ يقرّر، باحتمال يزداد كلما اقترب موعد الانتهاء، تحديث القيمة قبل أن تنتهي صلاحيتها فعلًا. تأتي هذه التقنية من ورقة بحثية نُشرت عام 2015 بعنوان Optimal Probabilistic Cache Stampede Prevention لـ Vattani وChierichetti وLowenstein، وتُسمّى عادة XFetch.
تطلق XFetch التحديث عندما يكون الوقت الحالي مطروحًا منه حاصل ضرب computeMs في beta في اللوغاريتم الطبيعي لرقم عشوائي أكبر من أو يساوي وقت الانتهاء، مع beta تساوي 1 افتراضيًا. ولأن لوغاريتم رقم عشوائي بين 0 و1 سالب، فإن المعادلة تدفع الوقت الحالي إلى الأمام بمقدار عشوائي يتناسب مع مدة آخر إعادة حساب، فتبدأ المفاتيح البطيئة التحديث مبكرًا وتتحدّث المفاتيح الرخيصة قرب موعد انتهائها. لا تحتاج XFetch إلى أي تنسيق، ما يجعلها مفيدة مع كاش لا يدعم set-if-absent ذريًا.
أما التذبذب العشوائي في TTL فيحل مشكلة مختلفة. سكربت تسخين الكاش، أو نشر يمسح الكاش، أو استيراد جماعي، كلها تكتب آلاف المفاتيح خلال ثوانٍ، ومع TTL متطابق تنتهي صلاحيتها كلها في الثانية نفسها لاحقًا. تذبذب عشوائي بنحو عشرة في المئة حول الـ TTL الأساسي يوزّع مواعيد الانتهاء هذه. لكن التذبذب لا يفيد مفتاحًا ساخنًا واحدًا، ولهذا فهو يكمّل الدمج والأقفال ولا يحل محلها.
كيف نخزّن الصفوف غير الموجودة وعمليات التحميل الفاشلة في الكاش؟
تخزين علامة قصيرة العمر تعني غير موجود، وهو ما يُسمّى negative caching، يمنع عمليات البحث المتكررة عن مفاتيح غير موجودة من الوصول إلى قاعدة البيانات في كل مرة. ومن دونه، فإن زاحف ويب أو سكربت يطلب معرّفات عشوائية يتجاوز الكاش تمامًا، وهو نمط عطل يُسمّى عادة cache penetration. نحصر المدخلات السلبية في بضع ثوانٍ حتى تظهر الصفوف المُنشأة حديثًا بسرعة.
أما عمليات التحميل الفاشلة فتُعامل بالعكس: لا نخزّن الخطأ في الكاش أبدًا. عندما ترمي دالة التحميل خطأً أثناء تحديث في الخلفية، يبقى المدخل القديم في Redis حتى انتهاء الـ TTL الصارم، فيستمر القرّاء في الحصول على آخر قيمة سليمة بينما تتعافى قاعدة البيانات. وهذا هو المكافئ في كاش التطبيق لتوجيه stale-if-error في HTTP.
أي حماية من تدافع الكاش يجب أن يستخدمها الفريق؟
نستخدم هذه التقنيات في طبقات، لأن كل واحدة تغطي نطاقًا مختلفًا:
دمج الطلبات: نطاقه عملية واحدة، ويمنع التحميل المتزامن المكرر داخلها، وتكلفته Map وتصميم دقيق للمفتاح.
قفل Redis عبر SET NX PX: نطاقه كل النسخ، ويمنع إعادة الحساب المتوازية بين الخوادم، وتكلفته رحلتان إضافيتان إلى Redis لكل تحديث.
TTL مرن مع تقديم القيم القديمة: نطاقه كل القرّاء، ويمنع حجب الطلبات عند انتهاء الصلاحية، وتكلفته أن القرّاء قد يرون بيانات متأخرة بمقدار تحديث واحد.
الانتهاء المبكر الاحتمالي (XFetch): نطاقه كل القرّاء، ويمنع التحديث المتزامن في لحظة الانتهاء بالضبط، وتكلفته أن بعض التحديثات تحدث مبكرًا.
التذبذب العشوائي في TTL: نطاقه مفاتيح كثيرة، ويمنع الانتهاء الجماعي للمفاتيح المكتوبة معًا، وتكلفته مواعيد انتهاء أقل قابلية للتنبؤ.
Negative caching: نطاقه المفاتيح غير الموجودة، ويمنع حالات miss المتكررة لصفوف غير موجودة، وتكلفته تأخير قصير قبل ظهور الصفوف الجديدة.
قبل بناء كل ذلك يدويًا، نتحقق مما تفعله مكتبة الكاش بالفعل. توثّق مكتبة Bentocache لـ Node.js الحماية من التدافع وفترات السماح للقيم القديمة، بينما تختلف دوال wrap() في مكتبات أخرى في ما إذا كانت تدمج الاستدعاءات المتزامنة. نتحقق من السلوك باختبار تزامن بدلًا من الوثوق بملف README: نحذف المفتاح، ونطلق بضع مئات من القراءات المتزامنة عبر Promise.all، ونتأكد عبر node:test من أن دالة التحميل عملت مرة واحدة بالضبط.
الأسئلة الشائعة
س: ما الفرق بين تدافع الكاش وcache penetration؟
ج: تدافع الكاش هو حالات miss متزامنة كثيرة لمفتاح موجود في قاعدة البيانات انتهت صلاحيته للتو في الكاش. أما cache penetration فهو حالات miss متكررة لمفاتيح غير موجودة في قاعدة البيانات أصلًا، فلا يمتلئ الكاش أبدًا، وحلّه هو negative caching.
س: هل قفل Redis عبر SET NX آمن بما يكفي لإعادة حساب الكاش؟
ج: نعم، في سياق الكاش. إعادة الحساب المكررة لا تهدر إلا بعض عمل قاعدة البيانات، لذا فإن فشلًا عرضيًا لقفل Redis على عقدة واحدة لا يضر. أما أي حالة يُفسد فيها كاتبان متزامنان البيانات فتحتاج إلى معاملة في قاعدة البيانات أو fencing tokens.
س: هل تمنع دالة cache() في React أو request memoization في Next.js تدافع الكاش؟
ج: لا. تزيل دالة cache() في React وrequest memoization في Next.js التكرار داخل render واحد لطلب واحد. أما التدافع فهو طلبات منفصلة كثيرة، ويحتاج إلى دمج على مستوى العملية إضافة إلى قفل أو تقديم القيم القديمة بين النسخ.
س: كم يجب أن يكون الـ TTL الصارم مقارنة بالـ TTL المرن؟
ج: يجب أن يغطي الـ TTL الصارم أطول انقطاع لمصدر البيانات يريد الفريق تجاوزه بالبيانات القديمة. عشرة أضعاف الـ TTL المرن نقطة بداية معقولة، مع تقصيره للمفاتيح التي قد تضلّل فيها البيانات القديمة جدًا المستخدمين.
س: هل ما زالت الحماية من التدافع على مستوى التطبيق مطلوبة خلف CDN يدعم stale-while-revalidate؟
ج: غالبًا نعم. يحمي stale-while-revalidate في الـ CDN استجابات HTTP القابلة للتخزين على الحافة، لكن الاستجابات المخصّصة لكل مستخدم، واستدعاءات API بين الخدمات، والاستعلامات المكلفة داخل الطلب ما زالت تمر عبر كاش التطبيق، حيث يحدث التدافع نفسه.
قراءات إضافية
هندسة الأنظمة الخلفية
إدارة الموارد الصريحة في TypeScript وNode.js 24: كيف نستخدم using وawait using وDisposableStack
يأتي تسرب الاتصالات غالبًا من مسار خطأ واحد تخطّى استدعاء release(). يشغّل Node.js 24 التعليمتين using وawait using بشكل أصلي، ويترجمهما TypeScript منذ الإصدار 5.2. نشرح هنا كيف يجعل فريقنا عملاء قواعد البيانات والمعاملات والمجلدات المؤقتة وتجهيزات الاختبارات قابلة للتحرير، ومتى نستخدم DisposableStack، والفخين اللذين يجب تجنبهما: SuppressedError والبث الذي يعيش بعد نطاقه.
هندسة الأنظمة الخلفية
قراءة EXPLAIN ANALYZE في PostgreSQL: كيف نجد الفهرس الناقص وراء نقطة API بطيئة في Node.js
توقّفنا عن إضافة فهارس PostgreSQL بالتخمين. طريقتنا: نحدّد الاستعلام الأعلى تكلفة عبر pg_stat_statements، ونقرأ خطة EXPLAIN (ANALYZE, BUFFERS) بحثًا عن الصفوف التي تُقرأ ثم تُهمَل، ثم نضيف الفهرس المركّب أو الجزئي أو فهرس التغطية الذي تطلبه الخطة، ونبنيه باستخدام CREATE INDEX CONCURRENTLY.