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

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗شبكة توصيل المحتوى التي تُبلّغ عن فشل في كل طلب نادرًا ما تكون مُعدّة بشكل خاطئ — الاستجابة نفسها هي التي تطلب منها عدم التخزين. التغييرات الثلاثة التي أصلحت معدل الإصابة في نقطة النهاية العامة لدينا كانت إزالة ترويسة Set-Cookie غير المقصودة من استجابة GET عامة، وتقسيم ترويسة Cache-Control الواحدة إلى زوج من CDN-Cache-Control وCache-Control حتى تحتفظ الحافة والمتصفح بمدد مختلفة، وإضافة stale-while-revalidate حتى لا يعني انتهاء الصلاحية رحلة كاملة إلى الخادم الأصلي.
أهم النقاط
اقرأ ترويسات الاستجابة قبل لوحة تحكم CDN: ترويسة x-vercel-cache على Vercel، وcf-cache-status على Cloudflare، وترويسة Cache-Status القياسية المعرّفة في RFC 9211، كل منها يوضح سبب فشل الطلب.
التوجيه s-maxage تحترمه الذواكر المشتركة مثل CDN فقط، والمتصفحات تتجاهله. أما max-age فينطبق على كل ذاكرة تخزين بما فيها المتصفح.
التوجيه stale-while-revalidate=N، المعرّف في RFC 5861، يسمح للذاكرة بتقديم نسخة منتهية الصلاحية لمدة N ثانية إضافية بينما تُحدّثها في الخلفية، فلا يدفع أول زائر بعد انتهاء الصلاحية تكلفة الوصول إلى الخادم الأصلي.
الاستجابة التي تحمل ترويسة Set-Cookie لا تخزّنها معظم الذواكر المشتركة، ويكفي مساعد واحد يلمس الجلسة في مسار عام لخفض معدل الإصابة إلى الصفر.
المدد الطويلة في الذاكرة المشتركة مع التفريغ الصريح أفضل من مدد TTL قصيرة مبنية على التخمين: دالة revalidateTag في Next.js وتفريغ المفاتيح البديلة في شبكات CDN الأخرى تُبطل الصلاحية عند الكتابة لا عند انتهاء مؤقت.
لماذا تُبلّغ شبكة CDN عن فشل في كل طلب؟
لأن الاستجابة غير قابلة للتخزين، في معظم الحالات. تُبلّغ شبكة Vercel Edge Network عن قرارها في ترويسة x-vercel-cache، وقيمها هي HIT وMISS وSTALE وBYPASS وPRERENDER. وتُبلّغ Cloudflare عن القرار نفسه في cf-cache-status. وقد وحّد المعيار RFC 9211 ذلك في ترويسة Cache-Status، لذا تعرضها الوسائط الأحدث باسم واحد.
القيمتان MISS وBYPASS تعنيان أمرين مختلفين، وهذا التمييز هو جوهر جلسة تصحيح الأخطاء. تعني MISS أن الذاكرة بحثت ولم تجد شيئًا ثم خزّنت الاستجابة للمرة القادمة، فطلبان متتاليان متطابقان ينتجان فشلًا ثم إصابة. أما BYPASS فتعني أن الذاكرة رفضت تخزين الاستجابة أصلًا، فيفشل الطلب الثاني والثالث وكل ما بعدهما.
أمر واحد يحسم الأمر: تنفيذ curl -sI على الرابط مع تصفية ترويسات cache وage وvary وset-cookie. ترويسة Age هي التأكيد، لأنها تذكر عدد الثواني التي احتفظت فيها الذاكرة المشتركة بهذه النسخة. ظهور Age وتزايدها عبر الطلبات يعني إصابة حقيقية، وغيابها الدائم يعني أن شيئًا لا يُخزَّن.
ما الفرق بين max-age وs-maxage وstale-while-revalidate؟
التوجيه max-age=N يجعل الاستجابة طازجة لمدة N ثانية في كل ذاكرة تخزين، بما فيها الذاكرة الخاصة بالمتصفح. والتوجيه s-maxage=N يتجاوز max-age في الذواكر المشتركة فقط — أي شبكة CDN أو وكيل — وتتجاهله المتصفحات تمامًا. هذا التباين هو الجزء المفيد: ذاكرة المتصفح لا يمكن تفريغها، وذاكرة CDN يمكن تفريغها.
التوجيه stale-while-revalidate=N، من RFC 5861، هو نافذة سماح بعد انتهاء الطزاجة. خلال N ثانية بعد انتهاء الصلاحية يجوز للذاكرة تقديم النسخة القديمة فورًا وتحديثها في الخلفية، ما يعني أن انتهاء الصلاحية يكلّف جلبًا واحدًا في الخلفية بدلًا من طلب مستخدم بطيء. والتوجيه stale-if-error=N شقيقه: يسمح بتقديم النسخة القديمة عندما يعيد الخادم الأصلي خطأ 5xx أو يتعذر الوصول إليه.
هناك توجيهان يقع الخلط بينهما دائمًا. التوجيه no-cache يسمح بتخزين الاستجابة لكنه يفرض إعادة التحقق قبل كل استخدام. والتوجيه no-store يمنع التخزين نهائيًا. للاستجابة التي يجب ألا تُكتب على القرص في أي مكان، التوجيه الصحيح هو no-store.
الترويسة التي استقررنا عليها لبيانات JSON العامة هي: public, max-age=0, s-maxage=300, stale-while-revalidate=86400. يعيد المتصفح التحقق في كل مرة، وتعتبر الحافة النسخة طازجة لخمس دقائق، ولمدة يوم بعدها تجيب الحافة فورًا من النسخة القديمة بينما تُحدّثها خلف الطلب.
متى نستخدم CDN-Cache-Control بدلًا من Cache-Control؟
استخدم ترويسة موجّهة كلما احتاج المتصفح وشبكة CDN إلى مدد مختلفة. يعرّف المعيار RFC 9213 ترويسات التحكم الموجّهة بالتخزين، حيث تلتزم الذاكرة بالترويسة الأكثر تحديدًا الموجّهة إليها وتعود إلى Cache-Control عند غياب أي ترويسة موجّهة.
الترويسة Vercel-CDN-Cache-Control تقرأها شبكة Vercel Edge Network وحدها، وتأخذ الأولوية العليا، ولا تُمرَّر إلى المتصفح.
الترويسة CDN-Cache-Control تقرأها أي شبكة CDN تطبّق RFC 9213، وتقع في منتصف ترتيب الأولوية، وتتجاهلها المتصفحات.
الترويسة Cache-Control تقرأها المتصفحات والوكلاء وشبكات CDN جميعًا، وتعمل كخيار احتياطي عند غياب أي ترويسة موجّهة.
قاعدتنا بعد هذه الحادثة هي إبقاء مدة المتصفح عند الصفر أو قريبًا منه ودفع المدة الحقيقية إلى الذاكرة المشتركة. المتصفح الذي خزّن استجابة لساعة سيقدّمها طوال تلك الساعة مهما نشرنا أو فرّغنا، بينما ذاكرة الحافة بمدة ساعة يمكن إبطالها لحظة تغيّر البيانات.
ما الذي يُعطّل التخزين على CDN بصمت؟
ترويسة Set-Cookie في الاستجابة هي القاتل الأول. يوجّه RFC 9111 الذواكر المشتركة إلى عدم تخزين هذا الحقل، وتستجيب شبكات CDN ومنها Vercel Edge Network برفض تخزين الاستجابة كلها. وهذا بالضبط ما حدث عندنا: مساعد مصادقة مشترك عمل على مسار عام، ولمس الجلسة، وأرفق ملف تعريف ارتباط متجددًا باستجابة لا يحتاج أي زائر إلى تخصيصها.
الترويسة Vary: Cookie أو Vary: * هي القاتل الثاني. تضيف ترويسة Vary الترويسات المذكورة إلى مفتاح الذاكرة، فالتنويع حسب Cookie يمنح كل سلسلة كوكيز مختلفة مدخلًا خاصًا بها وينهار معدل الإصابة نحو الصفر.
ترويسة الطلب Authorization هي الثالث. يمنع القسم 3.5 من RFC 9111 الذواكر المشتركة من إعادة استخدام استجابة لطلب مُصرّح به ما لم تحمل تلك الاستجابة صراحةً public أو s-maxage أو must-revalidate.
غياب ترويسة Cache-Control هو الرابع. تعامل معظم شبكات CDN الاستجابة الديناميكية غير الموسومة على أنها غير قابلة للتخزين بدلًا من تخمين مدة استدلالية، فالصمت ليس مدة TTL افتراضية.
القيمة الافتراضية no-store من إطار العمل هي الخامس. أي واجهة تعمل وقت الطلب وتجعل المسار ديناميكيًا ستُصدرها، ولا شيء لاحق يستطيع تجاوز no-store لم ينتبه أحد إلى إرسالها.
كيف نُبطل صلاحية الاستجابات المخزّنة بدلًا من تخمين مدة TTL؟
وسم المحتوى والتفريغ عند الكتابة. مدة TTL تخمين لطول بقاء البيانات صحيحة، أما التفريغ فحقيقة عن لحظة تغيّرها. في Next.js، استدعاء revalidateTag بوسم مثل products من Server Action أو من معالج مسار يُبطل كل fetch يحمل الوسم نفسه، وتفعل revalidatePath الشيء ذاته لمسار كامل. وفي شبكات CDN الأخرى المكافئ هو المفتاح البديل — ترويسة Surrogate-Key من Fastly مع واجهة التفريغ الخاصة بها، أو وسوم الذاكرة في Cloudflare Enterprise.
الشكل الذي ينجح هو مدة طويلة في الذاكرة المشتركة، ووسم ثابت لكل كيان، وتفريغ داخل مسار الكود نفسه الذي يكتب الكيان. هذا يحوّل صحة الذاكرة من مسألة توقيت إلى مسألة نشر.
ما الذي تغيّر في Next.js 15 بخصوص معالجات المسارات وتخزين fetch؟
جعل Next.js 15 التخزين اختياريًا بالتفعيل بينما كان Next.js 14 يجعله مفعّلًا افتراضيًا، والنموذج الذهني القديم من الإصدار 14 يسبب أخطاء حقيقية. معالجات GET لم تعد مخزّنة افتراضيًا، والتفعيل يتم عبر export const dynamic = 'force-static'. ودالة fetch لم تعد تفترض force-cache، فيُطلب التخزين لكل استدعاء عبر cache: 'force-cache' أو next: { revalidate: N }. كما توقّفت ذاكرة الموجّه في جانب العميل عن إعادة استخدام أجزاء الصفحات افتراضيًا، ويمكن ضبطها عبر إعداد staleTimes في next.config.
عندما يضبط معالج المسار ترويسة Cache-Control بنفسه على Vercel، فهذه الترويسة هي ما تتبعه شبكة الحافة، ولذلك كتابة زوج الترويسات الصريح أأمن من الاعتماد على قيمة افتراضية في إطار عمل تغيّرت مرة بالفعل.
الأسئلة الشائعة
س: هل يتحكم s-maxage في ذاكرة المتصفح؟
ج: لا. التوجيه s-maxage تحترمه الذواكر المشتركة فقط مثل شبكات CDN والوكلاء. تستخدم المتصفحات max-age، لذا الاستجابة التي تحمل s-maxage وحده يعيد المتصفح التحقق منها في كل تنقل.
س: ما الفرق بين no-cache وno-store؟
ج: التوجيه no-cache يسمح بالتخزين لكنه يفرض إعادة التحقق مع الخادم الأصلي قبل كل استخدام. أما no-store فيمنع تخزين الاستجابة في أي مكان. استخدم no-store لأي محتوى حساس.
س: هل stale-while-revalidate آمن لصفحات HTML؟
ج: هو آمن لصفحات HTML المشتركة غير المخصصة، حيث يكون تقديم صفحة قديمة قليلًا خلال نافذة السماح مقبولًا. وهو غير آمن للصفحات الخاصة بكل مستخدم، والتي يجب وسمها بـ private أو no-store.
س: هل يمكن اختبار ترويسات ذاكرة CDN على localhost؟
ج: لا، لعدم وجود ذاكرة مشتركة أمام خادم التطوير المحلي. انشر معاينة، ونفّذ curl على الرابط مرتين، وتأكد أن الاستجابة الثانية تُبلّغ عن إصابة مع قيمة Age أكبر من صفر.
س: هل ما زلنا بحاجة إلى ETag عند ضبط s-maxage؟
ج: توفّر ETag عرض النطاق عبر السماح باستجابة 304، لكن استجابة 304 تكلّف رحلة ذهاب وإياب وعملًا على الخادم الأصلي غالبًا. للذواكر المشتركة، s-maxage مع stale-while-revalidate يزيل زمن استجابة أكثر مما تزيله إعادة التحقق.
قراءات إضافية
هندسة الويب المتكاملة
لماذا يستمر استهلاك الذاكرة في عمليات Node بالارتفاع: ملاحظات ميدانية عن لقطات الكومة والكاش في نطاق الوحدة والتسريبات التي تظهر عند إعادة استخدام النسخ
خدمة Node تبقى بصحة جيدة لساعة بعد كل نشر ثم يبدأ استهلاكها بالارتفاع تعني تسريباً فوق الطلب لا داخله. هذه ملاحظاتنا الميدانية حول التفريق بين تسريب حقيقي وتذبذب جمع المهملات، والتقاط لقطات الكومة عبر --heapsnapshot-signal، وقراءة لوحة Retainers وصولاً إلى Map في نطاق الوحدة، ولماذا تجعل إعادة استخدام النسخ في Fluid Compute الكود بلا خادم يتسرب كخادم طويل العمر.
هندسة الويب المتكاملة
لماذا تنكسر الحزمة داخل تطبيق CommonJS: ملاحظات ميدانية حول require(esm) وخريطة exports وخطر الحزمة المزدوجة
نشرنا أداة صغيرة بصيغة ESM فقط، وكان أول من ثبّتها خدمة CommonJS فشلت عند الإقلاع بالخطأ ERR_REQUIRE_ESM. هذه ملاحظاتنا الميدانية حول ما أصلحته فعلاً خاصية require(esm) في Node.js 22.12، وترتيب شروط exports الذي يحل بصمت إلى الملف الخاطئ، وخطر الحزمة المزدوجة الذي يبقى رغم كل ذلك، والأداتين اللتين نشغّلهما قبل كل نشر.