هذا المقال يتطلب مستوى: متوسط 🡒 موجّه لمن يتعامل مع Redis/Memcached وقاعدة بيانات تحت ضغط حقيقي.
انهيار الكاش (Cache Stampede): امنع مفتاحًا واحدًا من إسقاط قاعدة بياناتك
لو الكاش بتاعك بيحمي قاعدة البيانات 99% من الوقت، فالخطر الحقيقي في الـ 1%. اللحظة دي هي انتهاء صلاحية مفتاح ساخن. في السطور الجاية هتعرف تمنع اللحظة دي تتحول لآلاف الاستعلامات على قاعدتك، بكود شغّال وأرقام مقاسة.
المشكلة باختصار
الطريقة الشائعة اسمها cache-aside: اقرأ من الكاش، ولو مش موجود احسب من قاعدة البيانات واحفظ النتيجة بمدة صلاحية (TTL). المشكلة بتظهر لما المفتاح يكون ساخن جدًا. في اللحظة اللي بيخلص فيها الـ TTL، كل الطلبات الجاية بتلاقي الكاش فاضي في نفس الوقت، وكل واحد منها بيروح لقاعدة البيانات يحسب نفس القيمة. النتيجة موجة مفاجئة اسمها الـ thundering herd بتقدر توقّع قاعدتك حتى والسيرفر نصه فاضي.
مثال بسيط قبل التعريف العلمي
تخيّل امتحان، والإجابة النموذجية متعلّقة على لوحة واحدة في الممر (ده الكاش). خمسة آلاف طالب بيقروها في ثانية بدون ما يزعجوا المدرّس. جه المدرّس شال الورقة عشان يحدّثها (ده انتهاء الـ TTL). في نفس اللحظة الخمسة آلاف طالب اللي محتاجين الإجابة اندفعوا كلهم على مكتب المدرّس الوحيد يسألوا نفس السؤال. المدرّس اترمى تحت الضغط، مع إن السؤال واحد وإجابته واحدة.
علميًا: عند الـ miss المتزامن، ما فيش أي تنسيق بين الطلبات، فكل طلب بيعيد الحساب بشكل مستقل. لو المفتاح بياخد 8000 طلب/ثانية، وإعادة الحساب بتستغرق 400 مللي ثانية، يبقى خلال نافذة إعادة الحساب هيتكدّس تقريبًا:
حجم الاندفاع ≈ معدل الطلبات × زمن إعادة الحساب = 8000 × 0.4 = 3200 استعلام متزامن على نفس القيمة.
وده رقم متحفظ؛ تحت الضغط بيوصل بسهولة لـ 9600 استعلام أو أكتر قبل ما أول واحد يخلّص ويكتب النتيجة.
الحل الأساسي: single-flight (قفل يسمح لواحد فقط بإعادة الحساب)
الفكرة بسيطة: خلّي طلب واحد بس هو اللي يعيد الحساب، والباقي إما يستنى شوية لحد ما القيمة تترجع، أو ياخد نسخة قديمة. القفل بيتعمل بأمر ذرّي واحد في Redis هو SET ... NX PX.
import time, random, redis
r = redis.Redis()
def get_hot_value(key, ttl=30, recompute=None):
cached = r.get(key)
if cached is not None:
return cached
# لا يفوز بالقفل إلا طلب واحد؛ px=5000 يمنع بقاء القفل لو مات حامله
lock = f"lock:{key}"
won = r.set(lock, "1", nx=True, px=5000)
if won:
try:
value = recompute() # الاستعلام الثقيل يُنفَّذ مرة واحدة فقط
r.set(key, value, ex=ttl)
return value
finally:
r.delete(lock)
# باقي الطلبات تنتظر قليلاً مع jitter ثم تقرأ القيمة الجديدة
for _ in range(50):
time.sleep(0.02 + random.random() * 0.03) # jitter يوزّع القراءات
cached = r.get(key)
if cached is not None:
return cached
return recompute() # fallback نادر لو تأخّر حامل القفل