المستوى: متوسط — هذا المقال يفترض أنك تعرف ما هو HTTP Cache-Control وعملت إعداد reverse proxy من قبل (NGINX أو CDN). لو لسه مبتدئ في الـ caching، اقرأ مقال HTTP Cache-Control الأساسي قبل ده.
endpoint بيرد في 240ms علشان بيعمل JOIN على 3 جداول كل request. لو ضيفت سطر واحد في الـ Cache-Control بيرد في 12ms على 95% من الطلبات، بدون أن البيانات تبقى قديمة لأكثر من دقيقة واحدة. المسؤول عن السحر ده توجيه اسمه stale-while-revalidate من RFC 5861.
Stale-While-Revalidate: استجابة فورية وتحديث في الخلفية
المشكلة باختصار
لو الـ API بتاعك بيرجع home feed أو list of products، الناس بتطلبه آلاف المرات في الدقيقة. كل طلب بيمشي على نفس الـ DB query، نفس الـ aggregation، ونفس الـ serialization. 99% من النتايج هتكون مكرّرة.
الحل المعتاد: cache عادي بـ Cache-Control: max-age=60. هنا بتقع في فخ تاني. ساعة انتهاء الكاش، أول طلب يجي بيلاقي cache miss، يستنى الـ origin يرد كامل، والمستخدم يدفع 240ms cold latency. ده اللي بيتسمى cache stampede أو thundering herd لو أكثر من طلب جه في نفس اللحظة.
مثال للمبتدئ: محل العصير في الصبح
تخيّل محل عصير بيعمل عصير برتقال طازج. عصرة دورق واحد بتاخد 4 دقائق. لو كل زبون يدخل المحل والبائع يبدأ يعصر له من الأول، طابور هيتكوّن في ثواني وكل واحد هيستنى دقايق.
اللي بيعمله البائع الذكي: بيعصر دورق كبير الصبح ويحطه على الـ counter. أي زبون يجي ياخد كوباية في 5 ثواني من الدورق الجاهز. لما الدورق يقرب يخلص، البائع يبدأ يعصر دورق جديد في الخلفية، والزباين يفضلوا يأخذوا من القديم لحد ما الجديد يبقى جاهز.
ده بالظبط Stale-While-Revalidate. الكاش بيرد بسرعة من النسخة القديمة، وفي نفس الوقت بيبعت request للـ origin يجيب نسخة جديدة في الخلفية. ولا زبون استنى. ولا origin اتحمّل أكتر من اللازم.
التعريف العلمي الدقيق
توجيه stale-while-revalidate موجود في RFC 5861 منذ 2010. الصياغة الكاملة:
Cache-Control: max-age=60, stale-while-revalidate=600اللي بيقوله الـ header ده للـ cache (متصفح أو CDN أو reverse proxy):
- اعتبر النسخة دي طازجة لمدة 60 ثانية. خلال الفترة دي، رد منها فوراً بدون أي اتصال بالـ origin.
- بعد الـ 60 ثانية، النسخة بقت stale (قديمة). لو طلب جديد جا، رد عليه من النسخة القديمة دي مباشرة، وفي الخلفية ابعت طلب async للـ origin علشان تجيب نسخة جديدة.
- الكاش يقدر يقدّم نسخة قديمة لمدة أقصاها 600 ثانية إضافية بعد انتهاء max-age.
- بعد 660 ثانية كاملة، أي طلب لازم ينتظر الـ origin (cache miss عادي).
القيمتين مش عشوائيتين. max-age = الفترة اللي مقبول فيها أن البيانات تبقى طازجة 100%. stale-while-revalidate = الفترة الإضافية اللي مقبول فيها بيانات قديمة كحد أقصى.
إعداد NGINX قابل للنسخ
NGINX يدعم stale-while-revalidate عبر التوجيه proxy_cache_background_update. التهيئة دي بتشتغل من إصدار 1.11.10 وما فوق:
proxy_cache_path /var/cache/nginx levels=1:2
keys_zone=api_cache:10m
max_size=1g
inactive=60m
use_temp_path=off;
server {
listen 80;
location /api/products {
proxy_pass http://backend;
proxy_cache api_cache;
proxy_cache_valid 200 60s;
proxy_cache_use_stale updating error timeout;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status;
}
}تفسير الأسطر المهمة:
- proxy_cache_use_stale updating: يقدّم نسخة قديمة لو في تحديث جاري في الخلفية.
- proxy_cache_background_update on: يفعّل التحديث في الخلفية بدلاً من جعل الطلب الحالي ينتظر.
- proxy_cache_lock on: لو 100 طلب وصلوا بنفس الوقت والكاش miss، يخلّي طلب واحد بس يروح للـ origin والباقي يستنوا الرد منه. ده يمنع cache stampede الكلاسيكي.
- X-Cache-Status: header للديباج، بيرجع HIT, MISS, STALE, UPDATING, EXPIRED. لازم تعرضه في الـ DevTools لتشخيص أي مشكلة.
إعداد Cloudflare والـ CDNs
Cloudflare يدعم stale-while-revalidate تلقائياً لو الـ origin بيرسل الـ header الصحيح. اضبط الـ origin:
// في Express أو Next.js Route Handler
res.setHeader(
'Cache-Control',
'public, s-maxage=60, stale-while-revalidate=600'
);
return res.json(data);s-maxage بدلاً من max-age يعني التعليمات دي مخصصة للـ shared caches (CDN) فقط، مش للمتصفح. ده مفيد لما تحب تتحكم في الـ CDN بدون التأثير على cache المتصفح.
Fastly و Vercel Edge Network بيدعموا نفس الـ syntax. AWS CloudFront يدعم stale-while-revalidate من 2023.
أرقام مقاسة من production
الأرقام دي من تطبيق Node.js يخدم 8M request يومياً، endpoint بيرجع home feed يعمل aggregation على PostgreSQL:
- قبل أي كاش: p50 = 180ms, p99 = 480ms, origin RPS = 1200.
- مع max-age=60 فقط: p50 = 12ms (أثناء HIT), p99 = 480ms (لما الكاش يخلص والناس يستنوا).
- مع max-age=60, stale-while-revalidate=600: p50 = 12ms, p99 = 18ms, origin RPS = 14.
الفرق الحقيقي مش في p50 (الكاش العادي بيحلّه برضه). الفرق في p99. ساعة انتهاء الكاش، النسخة العادية بتدفع الزمن الكامل، أما SWR بيقدّم النسخة القديمة فوراً ويعمل التحديث في الخلفية.
الـ origin RPS نزل من 1200 لـ 14. تخفيف 98% من الحمل. ده معناه إنك ممكن تستغني عن instances إضافية أو تخفض حجم RDS وتوفر فعلياً.
الـ Trade-offs (مكسب مقابل خسارة)
كل قرار في الأنظمة الموزّعة معاه ثمنه. هنا الثمن:
- بيانات قديمة لمدة قصيرة. ممكن مستخدم يشوف نسخة عمرها 599 ثانية. لو ده مش مقبول، استخدم قيمة أصغر لـ stale-while-revalidate (مثلاً 120 بدل 600).
- زيادة استخدام مساحة التخزين. الكاش بيحتفظ بالنسخ القديمة. على cluster كبير، الفرق ممكن يوصل لـ 30% زيادة في حجم disk المخصص للكاش.
- تعقيد في invalidation. لو أنت بتعتمد على purge فوري بعد كل update، SWR ممكن يقدّم نسخة قديمة لـ 600 ثانية بعد الـ purge (إلا لو الـ CDN بيدعم purge فوري حقيقي للـ stale entries، مثل Cloudflare).
- صعوبة في debugging. لما يجيلك تذكرة "البيانات قديمة"، لازم تعرف هل الكاش UPDATING ولا STALE ولا حصل error في الـ background fetch. خلّي X-Cache-Status ظاهر دايماً في staging.
الافتراض اللي شغّال عليه الكلام ده
الأرقام والـ config فوق مبنية على فرضيات معينة. لو الفرضيات دي مختلفة عندك، النتايج هتختلف:
- endpoint عام (مش per-user authenticated).
- RPS عالي (≥ 100/sec) عشان تستفيد من معدل HIT عالي.
- الـ DB query تكلفته أعلى من زمن الشبكة (≥ 100ms على origin).
- البيانات تتقبل تأخير لمدة دقائق (مش real-time).
متى لا تستخدم Stale-While-Revalidate
SWR ممتاز لكنه مش حل عام. ابعد عنه في الحالات دي:
- بيانات real-time. أسعار الأسهم، حالة الدفع، رصيد المحفظة. عرض رصيد قديم لمدة 30 ثانية ممكن يخلق تجربة سيئة أو خطأ مالي حقيقي.
- محتوى شخصي per-user. لو الرد يعتمد على المستخدم (Authorization header)، الكاش بيحتاج
Vary: Authorizationأو cache-key مخصص. ساعتها معدّل الـ HIT بينخفض جداً وفايدة SWR بتقل. - كتابة (POST/PUT/DELETE). الـ caching directives دي للـ GET/HEAD فقط. متستخدمهاش على mutations.
- محتوى منخفض الزيارات. endpoint بيتطلب 5 مرات في الساعة، الكاش هيبقى cold طول الوقت. الفايدة الفعلية صغيرة.
- بيانات حساسة من حيث الـ inventory. response feed يحتوي على inventory في e-commerce. عرض منتج "متوفر" وهو خلص ممكن يخلق طلب فاشل.
الخطوة التالية
افتح أكثر 3 endpoints ضغطاً عندك (الـ p99 الأطول والـ RPS الأعلى). ضيف على واحد منهم بس الـ header ده:
Cache-Control: public, s-maxage=60, stale-while-revalidate=600قس origin RPS و p99 قبل وبعد لمدة 24 ساعة. لو الفرق أقل من 30%، الـ endpoint ده مش candidate جيد لـ SWR (محتوى شخصي أو منخفض الزيارات). لو الفرق أعلى من 60%، طبّقها على باقي الـ endpoints المماثلة.