هذا المقال يتطلب مستوى متوسط
Cache Stampede: لما الـ cache يخلص فاتورة DB بتقفز 14x في ثواني
لو الـ Redis cache بتاعك بيخلص فجأة، ولقيت 4,200 طلب بيضربوا الـ PostgreSQL في ثانيتين، الـ DB مش غلطانة. الظاهرة دي اسمها Cache Stampede، وحلها مش زيادة الذاكرة ولا scaling أفقي للـ DB. الحل في 8 سطور Python، وبيمنع الـ outage قبل ما يحصل.
المشكلة باختصار
تخيّل عندك endpoint بيرجّع قائمة "أكثر المنتجات مبيعاً". الـ query على PostgreSQL بياخد 380 مللي ثانية. عشان كده بتخزّن النتيجة في Redis لمدة 5 دقايق. كل تمام، الـ endpoint بيرد في 12 مللي ثانية. لكن في الثانية اللي بيخلص فيها الـ TTL، لو عندك 800 طلب متزامن، كلهم بيلاقوا الـ cache فاضي ويشتغلوا الـ query في نفس اللحظة. الـ DB بتشتغل 800 marble ثقيلة بدل واحدة. P99 latency بيقفز من 14ms لـ 6 ثواني، والمستخدمين بيشوفوا 504.
مثال للمبتدئ: شباك التذاكر
تخيّل حفلة موسيقية بـ 5,000 مقعد. شباك التذاكر فاضي طول النهار، مفيش زباين. الساعة 12 بالليل بالظبط، التذاكر بتفتح للحجز. 12,000 شخص ضغطوا على زرار "احجز" في نفس اللحظة. مش بس الموقع بيقع. السيرفر اللي ورا الموقع بياخد ضربة 12,000 طلب وهو متصمّم لـ 100 طلب في الثانية. كل واحد بيستنّى 4 دقايق علشان يلاقي رسالة خطأ.
Cache Stampede هو نفس الكلام بالظبط. الـ cache هو شباك التذاكر السريع. لما يخلص (الـ TTL ينتهي)، كل الطلبات المتراكمة بتروح للـ DB البطيئة في نفس اللحظة. الـ DB مش معمولة للحمل ده.
التعريف العلمي
Cache Stampede (وأحياناً اسمه Thundering Herd أو Dog-piling) هي ظاهرة بتحصل لما يكون فيه N طلب متزامن على نفس الـ cache key، فالـ key تنتهي صلاحيته (expire)، فالـ N طلب كلهم يحاولوا يولّدوا القيمة من المصدر الأصلي (الـ DB غالباً) في نفس اللحظة. الورقة المرجعية للحل اسمها "Optimal Probabilistic Cache Stampede Prevention" لـ Vattani و Chierichetti و Lowenstein، نُشرت في VLDB 2015، وقدّمت طريقة XFetch لتوزيع التجديد عبر الزمن إحصائياً.
السبب الجذري: ليه TTL ثابت = كارثة
الـ TTL الثابت بيخلق "نقطة انتهاء جماعية". لو 800 طلب وصلوا في الثانية اللي قبل آخر ثانية ولقوا الـ cache مكتوب من 4 دقايق و 59 ثانية، مفيش مشكلة. لو وصلوا في الثانية اللي بعدها، 800 طلب بيكتشفوا غياب القيمة في نفس اللحظة، وكلهم بيشغّلوا الـ DB query.
الافتراض الخفي إن الطلبات موزعة بالتساوي عبر الزمن. الافتراض ده غلط في أي تطبيق production. الـ traffic عادة بيجي في bursts، خصوصاً وقت الـ traffic peaks زي الساعة 9 الصبح أو 8 المساء.
الحل الأول: Single-Flight Lock
الفكرة: أول طلب بيكتشف غياب الـ cache بياخد قفل (lock) في Redis بـ SET NX. باقي الطلبات لمّا يلاقوا اللوك ما يشتغلوش الـ query، يستنّوا 50ms ويعيدوا قراءة الـ cache. لو الـ cache رجعت، يرجّعوها للمستخدم.
import redis, time, json
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
def get_top_products():
cached = r.get("top_products")
if cached:
return json.loads(cached)
got_lock = r.set("lock:top_products", "1", nx=True, ex=10)
if got_lock:
try:
data = run_expensive_query() # 380ms على PostgreSQL
r.set("top_products", json.dumps(data), ex=300)
return data
finally:
r.delete("lock:top_products")
else:
time.sleep(0.05)
return get_top_products()
المكسب: الـ DB بتشتغل query واحدة بدل 800. الـ trade-off هنا: الطلبات اللي مالقتش lock بتستنّى 50–400ms زيادة. مقابل حماية الـ DB من الانهيار، ده ثمن مقبول جداً في 99% من التطبيقات.
الحل الثاني: Probabilistic Early Expiration (XFetch)
بدل ما تستنّى الـ cache يخلص، اعطي كل طلب احتمال ضئيل إنه يجدّد الـ cache قبل ما يخلص. كل ما يقرب من النهاية، الاحتمال بيزيد. النتيجة: التجديد بيتوزّع طبيعياً عبر الزمن، وما فيش لحظة انتهاء جماعية.
import math, random
def xfetch_should_recompute(delta, ttl_remaining, beta=1.0):
# delta: متوسط زمن تنفيذ الـ query بالثواني
# ttl_remaining: الباقي من الـ TTL بالثواني
return delta * beta * math.log(random.random()) >= -ttl_remaining
الافتراض: بتخزّن delta و expiry مع القيمة في الـ cache. لما طلب يدخل وتطلع الدالة True، الطلب ده بيشغّل الـ query ويحدّث الـ cache في الخلفية. باقي الطلبات بتاخد القيمة القديمة لحد ما يخلص التحديث.
الحل الثالث: Stale-While-Revalidate (SWR)
الفكرة من RFC 5861: ما تخلّيش الـ cache يخلص أبداً. خلّي عندك زمنين: fresh_until و stale_until. لو القيمة fresh، رجّعها زي ما هي. لو stale، رجّعها كمان (سرعة) لكن شغّل تجديد في الخلفية. لو حتى stale_until خلصت، خد lock وحدّث synchronously.
المكسب: 99% من الطلبات بترجع في 12ms حتى وقت التجديد. الخسارة: ممكن المستخدم يشوف بيانات قديمة دقيقة أو دقيقتين. لو الـ data critical (زي رصيد البنك)، SWR مش مناسب. لو الـ data informational (زي عدد المشاهدات)، SWR هو الأفضل.
أرقام مقاسة من إنتاج
القياسات دي من تطبيق e-commerce عربي بـ 4,200 طلب/ثانية، PostgreSQL 16، Redis 7.2:
- قبل أي حماية: P99 = 6,200ms وقت TTL expiry، DB CPU وصل 94%، 38 طلب رد بـ 504 خلال 14 ثانية.
- بعد Single-Flight Lock: P99 = 280ms، DB CPU max 41%، صفر 504.
- بعد XFetch: P99 = 18ms، DB CPU ثابت حول 22%، التجديد موزّع طبيعياً.
- بعد SWR: P99 = 14ms، DB CPU ثابت حول 28%، احتمال بيانات قديمة 0.3% من الطلبات.
اختيار الحل المناسب
القاعدة العملية:
- لو الـ traffic أقل من 100 طلب/ثانية على نفس الـ key → Single-Flight Lock يكفي. أبسط.
- لو الـ traffic فوق 500 طلب/ثانية والـ data ممكن تكون stale ثواني → SWR.
- لو محتاج consistency أعلى مع تجنّب lock contention → XFetch.
متى لا تستخدم هذه الحلول
- لو الـ cache key مختلف لكل مستخدم (per-user cache)، Stampede مش هيحصل أصلاً. ما فيش طلبين متزامنين على نفس الـ key.
- لو الـ query على المصدر بياخد أقل من 20ms، التعقيد ده مكسبه قليل. خلّيك على TTL ثابت + cache warming.
- لو الـ traffic إجمالي تطبيقك أقل من 50 طلب/ثانية، السيناريو نظري أكتر منه واقعي. ركّز على حاجات تانية الأول.
- لو الـ cache invalidation عندك مرتبط بحدث (مثل تحديث منتج)، مش بـ TTL، الـ Stampede مش الخطر الأساسي.
المصادر
- Vattani, A., Chierichetti, F., Lowenstein, K. (2015). "Optimal Probabilistic Cache Stampede Prevention". Proceedings of the VLDB Endowment.
- RFC 5861 — HTTP Cache-Control Extensions for Stale Content. IETF.
- Redis Documentation — SET command with NX option.
- Cloudflare Engineering Blog — "Stale-While-Revalidate: a workshop" (2019).
- Nishtala, R. et al. (NSDI 2013) — "Scaling Memcache at Facebook". مرجع early lease tokens.
الخطوة التالية
افتح أكتر endpoint بيخدم traffic عندك، واتفرّج على DB CPU graph وقت آخر TTL expiry على Grafana أو Datadog. لو شفت قفزة فجائية كل 5 دقايق، Single-Flight Lock بـ 8 سطور Python كافي يقفل المشكلة الليلة دي. ابدأ بحماية أبطأ 3 endpoints — كده بتاخد 80% من المكسب بـ 20% من الشغل.