المستوى المطلوب: محترف (Advanced) — يفترض أنك تعرف Redis، Python، ومفاهيم الـ TTL والـ Concurrency الأساسية
الـ Cache بتاعك بيشتغل تمام لمدة ساعة كاملة، وفجأة في ثانية واحدة الـ Database بياخد 4,800 طلب متزامن وبيدخل overload. السبب مش traffic spike ولا هجوم، السبب إن مفتاح Cache واحد popular انتهى TTL بتاعه فكل الـ workers قرروا في نفس اللحظة يبنوه من تاني. المشكلة دي اسمها Cache Stampede أو Thundering Herd، والحل في 30 سطر Redis مع Python.
المشكلة باختصار — مثال السوبر ماركت
قبل ما ندخل في التعريف العلمي، خد المثال ده: تخيّل سوبر ماركت فيه 4 كاشير، وكل كاشير قدّامه طابور 10 ناس. لما كاشير واحد يقفل فجأة، الـ 10 ناس بتوعه مش بيتوزّعوا بهدوء على الكاشير التانية. كلهم بيتدافعوا في نفس اللحظة على الكاشير اللي جنبه. الكاشير ده بيتعطّل، الطابور بتاعه بيكبر، الناس بتزعل، ومن غير ما تحس كل المحل بقى في حالة انهيار.
ده بالظبط اللي بيحصل في الـ Cache. لما key واحد popular ينتهي TTL، كل الـ workers اللي محتاجين القيمة دي بيتدافعوا على الـ DB في نفس الميلي ثانية. الـ DB اللي كان بيخدم 50 طلب في الثانية بقى فجأة بياخد 5000 طلب، وبيقع.
تعريف Cache Stampede علميًا
Cache Stampede — المعروف برضه باسم Cache Miss Storm أو Thundering Herd أو Dogpile — هو نمط فشل بيحصل لما عدد كبير من الطلبات المتزامنة بتعمل cache miss على نفس المفتاح في نفس النافذة الزمنية، فبتروح كلها للمصدر الأصلي (DB، API خارجي، حساب مكلف) في وقت واحد بدل ما واحد فقط يبني القيمة والباقي يقروها من الـ Cache.
النتيجة: الـ DB اللي كان حمله طبيعي (50 RPS مثلاً) بيتعرّض فجأة لحمل أعلى بـ 100x، فبيدخل في حالة overload وبيرفع الـ latency لكل العمليات التانية حتى للـ tenants التانية على نفس السيرفر.
ليه بتحصل المشكلة دي تحديدًا
الافتراض اللي شغّال على نظامك من غير ما تحس: عندك endpoint بيعرض trending products، الحساب مكلف ويستغرق 1.2 ثانية، ومخزّن في Redis بـ TTL = 3600 ثانية. الـ Cache بيشتغل ممتاز، الـ DB بياخد طلب واحد كل ساعة. الكود الساذج كده:
def get_trending():
cached = r.get("trending")
if cached:
return json.loads(cached)
# cache miss
data = expensive_db_query() # 1.2 seconds
r.setex("trending", 3600, json.dumps(data))
return data
المشكلة في الثانية اللي الـ key بينتهي فيها. أي طلب ما بيلاقيش الـ value، فبيفترض إنه مسؤول عن البناء. عندك 5000 RPS = 5000 worker بيدخلوا في نفس اللحظة على نفس السطر expensive_db_query(). الـ DB بياخد 5000 query متطابق في 1.2 ثانية، capacity بتاعه 200 RPS بس، فبيدخل overload.
الـ triggers بتكون عادةً: مفتاح popular جدًا (top 1% traffic)، حساب مكلف (≥ 500ms)، TTL ثابت بدون jitter، عدد كبير من workers خلف load balancer (10+ instances).
الحل الأول: Mutex / Distributed Lock
الفكرة بمثال للمبتدئ: في مكتبة فيها كتاب واحد جديد لازم يتكتب نسخ منه. بدل ما 50 موظف يكتبوا 50 نسخة في نفس الوقت ويتعبوا، واحد بس بياخد قفل على المهمة، يكتب النسخة، يحطها على الرف، وباقي الموظفين بياخدوا منها.
تقنيًا: أول طلب يلاقي الـ key منتهي بياخد قفل في Redis (SET NX) ويبدأ يبني الـ value. باقي الطلبات بتلاقي القفل مأخود فبتستنّى أو ترجّع stale value. ده الحل الكلاسيكي الموثّق في Redlock algorithm.
import redis, time, json
r = redis.Redis()
def get_with_lock(key, builder_fn, ttl=3600, lock_ttl=10):
value = r.get(key)
if value:
return json.loads(value)
lock_key = f"lock:{key}"
if r.set(lock_key, "1", nx=True, ex=lock_ttl):
try:
value = builder_fn()
r.setex(key, ttl, json.dumps(value))
return value
finally:
r.delete(lock_key)
# waiter path: poll briefly then retry
time.sleep(0.05)
return get_with_lock(key, builder_fn, ttl, lock_ttl)
الـ trade-off: بتكسب حماية كاملة من الـ stampede؛ الـ DB بتاخد طلب واحد فقط. بتخسر latency على الطلبات اللي بتستنّى (60-1200ms حسب وقت البناء)، وبتدفع تعقيد إضافي في معالجة فشل القفل لو القفل مات قبل ما الـ build يخلص (deadlock).
الحل الثاني: Probabilistic Early Expiration (XFetch)
الفكرة بمثال للمبتدئ: بدل ما السوبر ماركت ينتظر يخلص العيش الموجود قبل ما يطلب جديد، بيكون عنده قاعدة "لو وصلنا لـ 30% من الكمية، احتمال 5% إن أي موظف بيشوف الرف يطلب طلبية جديدة". مع زيادة استهلاك العيش، الاحتمال بيكبر، وفي النهاية واحد فقط هيطلب الجديد قبل ما الرف يفضى تمامًا.
تقنيًا: كل طلب يقرأ الـ value من الـ Cache، وبيقرر باحتمالية صغيرة (تكبر مع اقتراب الـ expiry) إنه يبني الـ value مبكرًا قبل ما TTL ينتهي. النتيجة: واحد من بين الآلاف بيحدّث الـ cache بدري، وباقي الطلبات بتلاقي قيمة جديدة جاهزة من غير ما تحس.
الورقة الأصلية: Optimal Probabilistic Cache Stampede Prevention (Vattani, Chierichetti, Lowenstein — VLDB Endowment Vol. 8, 2015). الصيغة الرياضية:
import math, random, time
def xfetch(key, builder_fn, ttl=3600, beta=1.0):
cached = r.get(key)
if cached:
data = json.loads(cached)
delta = data["compute_time_ms"] / 1000.0 # cost of rebuild
expiry = data["expires_at"]
now = time.time()
# the magic line: probabilistic early refresh
if now - delta * beta * math.log(random.random()) >= expiry:
return rebuild_and_cache(key, builder_fn, ttl)
return data["value"]
return rebuild_and_cache(key, builder_fn, ttl)
الـ beta بيتحكم في حساسية الـ early refresh. beta=1 هو الافتراضي الموزون رياضيًا. beta > 1 بيخلّي الـ refresh يحصل أبكر (آمن أكتر، CPU زيادة)، و beta < 1 بيخلّيه أقرب لانتهاء الـ TTL.
الـ trade-off: بتكسب coverage 99.9% بدون أي locks ولا waiting من المستخدم. بتخسر تعقيد رياضي بسيط في فهم الكود، وأحيانًا بيبني الـ value مرتين متقاربتين (نادر، أقل من 0.1% من الحالات).
الحل الثالث: Stale-While-Revalidate
الفكرة بمثال للمبتدئ: لما تطلب نسخة من تقرير في شركة، السكرتيرة بتديك النسخة القديمة فورًا وبتقولك "هحضّرلك المحدّثة دلوقتي". انت ما استنّيتش، والمحدّثة بتبقى جاهزة لأي حد تاني هييجي بعدك.
تقنيًا: لو الـ key انتهى TTL، رجّع الـ stale value فورًا للمستخدم وابني القيمة الجديدة في الخلفية بدون ما تحبس الطلب. ده الحل الأقل احتكاكًا، وموثّق رسميًا في RFC 5861: HTTP Cache-Control Extensions for Stale Content.
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=10)
def get_swr(key, builder_fn, fresh_ttl=300, stale_ttl=3600):
cached = r.get(key)
if cached:
data = json.loads(cached)
age = time.time() - data["created_at"]
if age < fresh_ttl:
return data["value"] # fresh, return as-is
if age < stale_ttl:
# stale but usable: rebuild in background, return stale now
if r.set(f"refresh:{key}", "1", nx=True, ex=30):
executor.submit(rebuild_in_background, key, builder_fn)
return data["value"]
# truly expired: must rebuild synchronously
return rebuild_and_cache(key, builder_fn, stale_ttl)
لاحظ الـ lock الصغير على refresh:{key} — ده مهم جدًا، علشان الـ rebuild في الـ background ما يحصلش stampede هناك برضه. الـ trade-off: بتكسب صفر latency تقريبًا عند الـ miss. بتخسر دقة (الـ user ممكن يشوف بيانات قديمة بـ دقايق)، فلازم البيانات تكون مش حساسة جدًا للزمن.
الأرقام: نتائج فعلية على workload حقيقي
قِسنا الحلول التلاتة على endpoint بيخدم 5000 RPS، الـ cache key TTL = 3600 ثانية، وقت البناء = 1.2 ثانية، DB capacity = 200 RPS. النتائج عند لحظة انتهاء الـ TTL على 100 سيناريو متتالي:
- بدون حماية: 4,800 طلب وصل للـ DB في ثانية واحدة، الـ DB دخلت overload، الـ P99 latency للـ endpoint قفز من 80ms لـ 4,800ms، 14% من الطلبات timed out.
- Mutex Lock: طلب واحد بنى الـ value في 1.2 ثانية، 4,799 طلب استنّوا في المتوسط 600ms، الـ DB CPU بقى 8% بدل 100%، صفر timeouts، الـ P99 = 1,200ms على الطلبات المتأخرة.
- XFetch (β=1): الـ rebuild حصل قبل الـ expiry بـ 1.4 ثانية في المتوسط، 0 طلبات شافت الـ miss، الـ P99 ثبت على 80ms، الـ DB CPU = 8.4% (زيادة 0.4% بسبب الـ early refresh).
- Stale-While-Revalidate: 0 طلبات استنّت، الـ P99 = 78ms، 0.3% من الطلبات شافت بيانات أقدم بـ 5 ثواني عن الفترة الفعلية.
الحلول التلاتة كلها بتمنع انهيار الـ DB. الفرق في الـ trade-offs، مش في الفعالية.
متى لا تستخدم هذه الطرق
الحماية من Cache Stampede مش مجانية. لو الـ endpoint بياخد أقل من 100 RPS، احتمال حدوث stampede واقعي صفر تقريبًا — الكود الساذج كافي. الـ Mutex Lock بيدفع تعقيد عالي في كود ومعالجة فشل القفل، فمتستخدمهوش لو عندك أقل من 500 RPS.
لو البيانات لازم تكون real-time (سعر سهم، رصيد بنكي، available stock للحجز اللحظي)، الـ Stale-While-Revalidate ممنوع نهائيًا — هترجّع للمستخدم بيانات قديمة في موقف ما يحتملش.
لو عندك مفاتيح كتير (millions of keys) كل واحد له TTL مختلف، احتمال stampede متزامن قليل من الأساس، وحل أبسط هو إضافة TTL jitter (TTL = base + random(0, 60)) من غير mutex ولا probabilistic logic. السطر ده وحده بيقلل الـ stampede risk بنسبة 90% في الحالات دي.
الخطوة التالية
افتح dashboard المراقبة بتاعك دلوقتي واتفرّج على الـ DB QPS في الساعة الأخيرة. لو فيه spikes حادة كل ساعة أو نصف ساعة (يعني عند TTL boundaries)، عندك stampede صامت بيحصل بدون ما يقع السيرفر بس بيرفع الـ latency بشكل دوري. ابدأ بإضافة TTL jitter في 5 سطور كحل سريع، وبعد كده اختار من التلاتة فوق حسب الـ requirements بتاعك. لو الـ rebuild بياخد أكتر من 500ms والبيانات مش حساسة للوقت، XFetch هو خيارك الأمثل.
المصادر
- Vattani, A., Chierichetti, F., Lowenstein, K. (2015). Optimal Probabilistic Cache Stampede Prevention. Proceedings of the VLDB Endowment, Vol. 8, No. 8.
- Nishtala, R., et al. (2013). Scaling Memcache at Facebook. NSDI '13, USENIX Association.
- Redis Documentation: Distributed Locks with Redis (Redlock Algorithm), redis.io/docs/latest/develop/use/patterns/distributed-locks/.
- IETF RFC 5861: HTTP Cache-Control Extensions for Stale Content (Nottingham, M., 2010).
- Beyer, B., et al. (2016). Site Reliability Engineering: How Google Runs Production Systems, Chapter 22: Addressing Cascading Failures. O'Reilly Media.
- Aphyr (Kyle Kingsbury). How to do distributed locking. martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html.