المستوى: متوسط — مناسب لمن يعرف أساسيات الكاش (Redis / Memcached) ويشتغل على خدمة تحت ضغط حقيقي.
Cache Stampede: ليه انتهاء صلاحية مفتاح واحد بيوقّع قاعدة بياناتك
لو خدمتك بتمشي حلو، وفجأة الـ CPU بتاع قاعدة البيانات بيقفز لـ 100% لمدة ثواني كل شوية، من غير زيادة في عدد الزوّار — ركّز في التوقيت. غالبًا اللحظة دي بتتزامن مع انتهاء صلاحية مفتاح كاش مهم. المقال ده هيوريك ليه بيحصل، وهيديك حل قابل للنسخ يمنعه في أقل من ربع ساعة.
المشكلة باختصار
الكاش بيخبّي نتيجة عملية غالية (query تقيل أو حساب معقّد) لمدة محددة. طول ما المفتاح موجود، كل الطلبات بتترد من الذاكرة في أقل من مللي ثانية. لكن في اللحظة اللي بتنتهي فيها صلاحية المفتاح، كل الطلبات اللي بتوصل في نفس اللحظة بتلاقي الكاش فاضي، فبتروح كلها لقاعدة البيانات في نفس الوقت عشان تعيد الحساب. ده اسمه Cache Stampede (أو thundering herd أو dogpile).
الفكرة بمثال بسيط الأول
تخيّل مطعم فيه طبق مشهور. الشيف بيحضّر منه صينية كبيرة كل ساعة، وبيحطها على البار. أي زبون بيطلب الطبق، الجرسون بياخد منه على طول — ثانية واحدة. لكن في اللحظة اللي بتخلص فيها الصينية، لو 200 زبون طالبين في نفس الدقيقة، الجرسونات الـ 200 كلهم هيجروا على المطبخ في نفس الوقت يقولوا للشيف "اعمل الطبق". الشيف الواحد مش هيقدر، فالمطبخ بيقف، وكل الطلبات بتتأخّر — حتى الطلبات التانية اللي مالهاش علاقة.
"الصينية" هي الكاش. "خلاص الصينية" هو انتهاء الصلاحية. "الجرسونات الـ 200 اللي جِروا مع بعض" هي طلباتك المتزامنة. "الشيف الواحد" هو قاعدة البيانات.
ليه بيحصل فعلاً؟ التفسير الدقيق
الكود الشائع للكاش بيكون بالشكل ده: اقرا من الكاش، لو مش موجود احسب من قاعدة البيانات ثم خزّن. المشكلة إن الخطوة "لو مش موجود" مفيهاش تنسيق بين الطلبات. مفيش طلب بيقول للطلبات التانية "استنّوا، أنا بحسبها دلوقتي".
خلّينا نحط أرقام. لو عندك خدمة بتستقبل 10,000 طلب/ثانية على نفس المفتاح، والـ query اللي بتعيد الحساب بتاخد 200 مللي ثانية. في الـ 200 مللي ثانية اللي بعد انتهاء الصلاحية مباشرة، بيوصل حوالي 2,000 طلب — وكلهم بيلاقوا الكاش فاضي فبيضربوا قاعدة البيانات مع بعض. لو قاعدة بياناتك بتتحمّل 500 query/ثانية مريحة، فجأة اتحطّ عليها 2,000 في لحظة. النتيجة: طابور، بطء، ووقت استجابة بيتصاعد لكل المستخدمين، مش بس اللي طلبوا المفتاح ده. أسوأ سيناريو إن الحمل بيمنع الحساب من الانتهاء، فالكاش يفضل فاضي، والدنيا بتلف في دايرة انهيار.
الحل الأساسي: قفل الرحلة الواحدة (single-flight lock)
الفكرة بسيطة: أول طلب بيلاقي الكاش فاضي بياخد قفل. هو بس اللي بيروح لقاعدة البيانات. باقي الطلبات بتلاقي القفل مقفول، فبتستنّى شوية ثم تقرا النتيجة الجاهزة من الكاش. بدل 2,000 ضربة على قاعدة البيانات، بتبقى ضربة واحدة.
ده مثال شغّال بـ Python و Redis. القفل بيتعمل بـ SET key value NX EX — يعني "اكتب المفتاح فقط لو مش موجود، وحطله مهلة":
import time
import redis
r = redis.Redis()
def get_expensive_value(key, ttl=60, lock_ttl=5):
# 1) جرّب تقرا من الكاش
cached = r.get(key)
if cached is not None:
return cached
lock_key = f"lock:{key}"
# 2) حاول تاخد القفل (طلب واحد بس هينجح)
got_lock = r.set(lock_key, "1", nx=True, ex=lock_ttl)
if got_lock:
try:
value = run_expensive_query(key) # الضربة الوحيدة على قاعدة البيانات
r.set(key, value, ex=ttl)
return value
finally:
r.delete(lock_key)
else:
# 3) طلب تاني ماسك القفل، استنّى ثم اقرا النتيجة
for _ in range(50): # حد أقصى ~500ms انتظار
time.sleep(0.01)
cached = r.get(key)
if cached is not None:
return cached
# لو القفل اتعطّل، ارجع احسب بنفسك كخطة بديلة
return run_expensive_query(key)
النقطة المهمة هي nx=True: العملية دي ذرّية في Redis، يعني مستحيل طلبين ياخدوا القفل مع بعض. و ex=lock_ttl ضروري عشان لو الطلب اللي ماسك القفل مات فجأة، القفل يفكّ لوحده وميعلّقش الخدمة.
حلول أخرى حسب الحالة
- الخدمة من نسخة قديمة أثناء التحديث (stale-while-revalidate): بدل ما الطلبات تستنّى، ردّ عليها بالنسخة القديمة اللي لسه محفوظة، وخلّي طلب واحد بس يحدّث في الخلفية. ده بيدّي أفضل زمن استجابة، بس بيسمح بلحظات قصيرة من بيانات قديمة.
- إعادة الحساب الاحتمالي المبكّر (probabilistic early expiration): بدل ما تستنّى المفتاح يخلص، كل طلب بيحسب احتمال صغير إنه يعيد الحساب قبل انتهاء الصلاحية بشوية. كل ما اقتربنا من موعد الانتهاء زاد الاحتمال. كده طلب واحد بس بيجدّد المفتاح مبكرًا، ومحدش بيلاقيه فاضي أصلاً. الصيغة المعروفة من ورقة VLDB 2015 هي إعادة الحساب لو
now - delta * beta * ln(random()) >= expiry، حيثdeltaهو زمن الحساب وbetaعادة 1. - توزيع مواعيد الانتهاء (jitter): لو بتكاش آلاف المفاتيح بنفس الـ TTL، هتنتهي كلها مع بعض. ضيف عشوائية بسيطة على كل TTL (مثلاً 60 ثانية زائد رقم عشوائي من 0 لـ 10) عشان متنتهيش دفعة واحدة.
الـ trade-offs — كل حل وتمنه
قفل single-flight بيوفّر لقاعدة بياناتك حِمل ضخم (من 2,000 ضربة لواحدة)، بس بيخلّي باقي الطلبات تستنّى زمن الحساب (200 مللي ثانية في مثالنا). لو ده مش مقبول، استخدم stale-while-revalidate: بتكسب زمن استجابة ثابت، بتخسر إن ممكن يشوف المستخدم بيانات أقدم بثانية أو اتنين. الاحتمالي المبكّر أنعم حل، بس أصعب في الفهم والضبط، ومحتاج تخزّن زمن الحساب مع القيمة. القاعدة: الافتراض إن مفاتيحك القليلة الساخنة هي مصدر المشكلة، مش كل المفاتيح.
متى لا تستخدم هذه الحلول
لو خدمتك بتستقبل عدد طلبات قليل (مثلاً أقل من 50 طلب/ثانية على المفتاح الواحد)، احتمال حصول stampede ضعيف جدًا، والقفل هيزوّد تعقيد بلا فايدة حقيقية. كمان لو المفاتيح كلها باردة ونادرة الطلب، مفيش herd أصلاً. وأخيرًا، لو الحساب رخيص (query على index بترجع في مللي ثانية)، حتى مئات الضربات المتزامنة مش هتفرق. طبّق الحل على المفاتيح الساخنة الغالية بس، وسيب الباقي بسيط.
المصادر
- Nishtala et al., "Scaling Memcache at Facebook", USENIX NSDI 2013 — يشرح آلية leases لمنع الـ stampede على مستوى إنتاج فيسبوك.
- Vattani, Chierichetti, Lowenstein, "Optimal Probabilistic Cache Stampede Prevention", VLDB 2015 — الأساس الرياضي لإعادة الحساب الاحتمالي المبكّر (XFetch).
- توثيق Redis الرسمي لأمر
SETوخياريNXوEX— redis.io/commands/set. - مقالة "Cache stampede" على ويكيبيديا — تلخيص للمصطلح وطرق العلاج (locking, external computation, probabilistic early expiration).
الخطوة التالية
افتح لوحة مراقبة قاعدة بياناتك، وحدّد أعلى 3 مفاتيح كاش من حيث تكلفة إعادة الحساب. لفّهم بدالة single-flight زي اللي فوق، وراقب زمن استجابة قاعدة البيانات عند نقاط انتهاء الصلاحية. لو شفت القفزات اختفت، ده معناه إن الـ stampede كان فعلاً مصدر المشكلة.