Cache Stampede في Laravel: كيف تمنع انهيار القاعدة عند انتهاء الكاش
الـ Cache Stampede هو أن ينتهي عمر مفتاح كاش مزدحم فتندفع مئات الطلبات لإعادة حسابه معًا. إليك كيف يحدث في Laravel وكيف تمنعه بـ Cache::flexible وCache::lock.
تخيّل صفحة رئيسية تعرض إحصاءات ثقيلة تحتاج استعلامًا يستغرق ثلاث ثوانٍ، وقد خزّنتها في الكاش لعشر دقائق عبر Cache::remember. طوال الوقت يبدو كل شيء ممتازًا: الطلبات تُخدم من الكاش في أجزاء من الثانية. ثم في لحظة انتهاء الصلاحية يصل 500 زائر في الثانية نفسها، فيجد كلٌّ منهم الكاش فارغًا ويبدأ بتنفيذ الاستعلام الثقيل. النتيجة: 500 نسخة من الاستعلام نفسه على قاعدة البيانات في وقت واحد، وبطء، وربما انهيار كامل. هذا هو الـ Cache Stampede، ويسمى أيضًا Thundering Herd أو Dogpile.
في هذا المقال نفهم المشكلة بدقة، ثم نعرض ثلاثة حلول متاحة في Laravel نفسه، ونتعلم متى نستخدم كل واحد منها.
لماذا يحدث الـ Cache Stampede؟
الكود التالي صحيح ومنتشر، لكنه يخفي المشكلة:
$stats = Cache::remember('home.stats', 600, function () {
return DB::table('orders')->selectRaw('...')->get(); // استعلام ثقيل
});
بحسب توثيق Laravel، إذا لم يكن العنصر موجودًا في الكاش فسيُنفَّذ الـ closure ويوضع ناتجه في الكاش. المشكلة أن الفحص والتنفيذ ليسا عملية واحدة ذرّية عبر الطلبات: كل طلب يرى الكاش فارغًا قبل أن يُنهي أي طلب آخر عمله، فيحسب الجميع القيمة نفسها بالتوازي.
الظروف التي تجعل الأمر خطيرًا:
- مفتاح كاش يُقرأ كثيرًا (صفحة رئيسية، إعدادات، قائمة منتجات).
- حساب القيمة بطيء أو مكلف (استعلام ثقيل، استدعاء API خارجي).
- انتهاء صلاحية متزامن، مثل مفاتيح كثيرة بنفس الـ TTL أو تفريغ كامل للكاش بعد نشر نسخة جديدة.
الحل الأول: Stale While Revalidate عبر Cache::flexible
أبسط حل هو أن تسمح بتقديم قيمة قديمة قليلًا بينما تُحسب القيمة الجديدة في الخلفية. يوفر Laravel هذا النمط عبر Cache::flexible، وهو ما يسميه التوثيق «stale-while-revalidate»:
$stats = Cache::flexible('home.stats', [300, 600], function () {
return DB::table('orders')->selectRaw('...')->get();
});
المصفوفة تحوي رقمين بالثواني، وفق التوثيق:
- الرقم الأول (300): المدة التي تُعتبر فيها القيمة «طازجة». أي طلب خلالها يحصل على القيمة فورًا دون إعادة حساب.
- الرقم الثاني (600): المدة التي يمكن فيها تقديم القيمة كـ «قديمة» (stale). أي طلب يصل في هذه الفترة يحصل على القيمة القديمة فورًا، ويُسجَّل deferred function يعيد تحديث الكاش بعد إرسال الاستجابة للمستخدم.
- بعد الرقم الثاني: يُعتبر الكاش منتهيًا وتُحسب القيمة فورًا، وهنا قد يتأخر الرد على المستخدم.
الفائدة: لا يرى المستخدمون بطء إعادة الحساب في الحالة المعتادة، لأن التحديث يحدث بعد الاستجابة. العيب: قد يرى المستخدم بيانات أقدم قليلًا، فهي غير مناسبة لبيانات يجب أن تكون دقيقة لحظيًا مثل رصيد حساب.
الحل الثاني: قفل ذرّي بـ Cache::lock
عندما لا تقبل بيانات قديمة، فاسمح لطلب واحد فقط بإعادة الحساب، ودع الباقين ينتظرون. يوفر Laravel الأقفال الذرّية (Atomic Locks) لهذا الغرض، وتتطلب أن يكون الكاش الافتراضي من النوع memcached أو redis أو dynamodb أو database أو file أو array، وأن تتصل كل الخوادم بخادم الكاش المركزي نفسه.
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
function homeStats(): mixed
{
if (($value = Cache::get('home.stats')) !== null) {
return $value;
}
try {
return Cache::lock('home.stats.lock', 30)->block(10, function () {
// أعد الفحص: ربما حسبه طلب آخر أثناء انتظارنا
return Cache::remember('home.stats', 600, function () {
return DB::table('orders')->selectRaw('...')->get();
});
});
} catch (LockTimeoutException $e) {
// لم نحصل على القفل خلال 10 ثوانٍ: قرّر سلوكًا بديلًا
return [];
}
}
نقاط مهمة في هذا الكود:
Cache::lock('home.stats.lock', 30)ينشئ قفلًا مدته 30 ثانية كحد أقصى، حتى لا يبقى القفل عالقًا إن انهار الطلب الذي يحمله.block(10, ...)ينتظر حتى 10 ثوانٍ للحصول على القفل ثم يحرره تلقائيًا بعد انتهاء الـ closure، وإن لم يحصل عليه يرميLockTimeoutException.- إعادة الفحص داخل القفل ضرورية. الطلبات التي انتظرت ستحصل على القفل واحدًا تلو الآخر، وبدون إعادة الفحص سيعيد كل واحد منها الحساب. مع
Cache::rememberداخل القفل، يجد الطلب الثاني القيمة التي وضعها الأول ولا يحسب شيئًا. - اختر مدة القفل أطول من أبطأ حساب متوقع، وإلا انتهى القفل والحساب لم ينتهِ وعاد الازدحام.
يوفر Laravel أيضًا Cache::withoutOverlapping الذي يسمح بتنفيذ closure واحد فقط عبر كل البنية، بانتظار افتراضي 10 ثوانٍ، لكن الأقفال الصريحة أوضح هنا لأنك تحتاج إعادة الفحص بنفسك.
الحل الثالث: لا تحسب أثناء الطلب أصلًا
للقيم الثقيلة جدًا، أفضل ما تفعله هو إخراج الحساب من مسار الطلب كليًا: جدولة أمر يحدّث الكاش دوريًا.
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
Cache::forever('home.stats', computeHomeStats()); // دالة من عندك تحسب الإحصاءات
})->everyFiveMinutes();
الطلبات تقرأ فقط ولا تحسب أبدًا، ولا يوجد وقت «انتهاء» يتدافع عليه أحد. استخدم forever لأن المُجدوِل هو من يجدد القيمة. الكلفة: لو توقف المُجدوِل ستبقى البيانات قديمة بصمت، فراقبه، وجهّز الكاش مسبقًا عند النشر (cache warming) كي لا يبدأ فارغًا.
كيف تكتشف أن لديك Stampede؟
الأعراض النموذجية في الإنتاج: ارتفاع مفاجئ ومتكرر في عدد الاتصالات بقاعدة البيانات أو في زمن الاستجابة على فترات منتظمة تطابق الـ TTL، مع عودة الأمور لطبيعتها بعد دقائق دون تدخل. لتأكيد الشك:
- راقب الاستعلامات البطيئة في قاعدة البيانات وابحث عن الاستعلام نفسه يظهر عشرات المرات في الثانية ذاتها.
- استمع لأحداث الكاش التي يوفرها Laravel مثل
CacheMissedوCacheHitوسجّل المفاتيح التي تتكرر عليها حالات الفشل بكثافة في لحظة واحدة. - في بيئة الاختبار، احذف المفتاح يدويًا ثم أطلق عدة طلبات متزامنة عليه بأداة تحميل، وعدّ كم مرة نُفّذ الاستعلام الثقيل. إن كان أكثر من مرة واحدة فالمشكلة موجودة.
إضافة عشوائية صغيرة إلى الـ TTL
إن كان لديك مئات المفاتيح تُنشأ معًا (مثل كاش لكل منتج بعد تسخين الكاش) وكلها بـ TTL واحد، فستنتهي كلها في اللحظة نفسها. أضف انحرافًا عشوائيًا بسيطًا لتتوزع الانتهاءات:
$ttl = 600 + random_int(0, 60); // بين 10 و11 دقيقة
Cache::put("product.{$product->id}", $data, $ttl);
هذه خدعة رخيصة لا تغني عن الأقفال للمفتاح الواحد الساخن، لكنها تمنع موجة انتهاء جماعية.
متى تختار أي حل؟
| الحالة | الحل الأنسب |
|---|---|
| بيانات تتحمل تأخرًا قصيرًا (إحصاءات، قوائم) | Cache::flexible |
| بيانات يجب ألا تكون قديمة | قفل ذرّي مع إعادة فحص |
| حساب ثقيل جدًا أو يعتمد على API خارجي | تحديث مجدول مسبق |
وهناك إجراءات تخفف الخطر مهما كان الحل: لا تضع المفاتيح الكثيرة على TTL متطابق (أضف عشوائية صغيرة)، ولا تُفرّغ الكاش كاملًا في الإنتاج دون داعٍ، لأن Cache::flush() يزيل كل شيء ولا يحترم الـ prefix بحسب التوثيق.
أخطاء شائعة
- القفل بدون إعادة فحص: تحوّل الزحام إلى طابور من الحسابات المتكررة.
- قفل بمدة قصيرة جدًا: ينتهي قبل انتهاء الحساب فيدخل طلب آخر.
- عدم معالجة LockTimeoutException: المستخدم يرى خطأ 500 بدل قيمة بديلة أو رسالة مناسبة.
- استخدام driver الملفات مع عدة خوادم: كل خادم له كاشه وأقفاله، فلا حماية حقيقية. الأقفال تتطلب خادم كاش مشتركًا.
- استخدام flexible لبيانات مالية أو حساسة: القيمة القديمة قد تكون خاطئة من منظور العمل.
الخلاصة
الـ Cache Stampede مشكلة لا تظهر في بيئة التطوير ولا تحت الحمل الخفيف، وتظهر فجأة في أسوأ وقت. ابدأ بتحديد أثقل مفاتيح الكاش وأكثرها قراءة، ثم اختر لكل منها: Cache::flexible إن قبلت بيانات قديمة قليلًا، أو قفلًا ذرّيًا مع إعادة فحص إن لم تقبل، أو تحديثًا مجدولًا إن كان الحساب ثقيلًا جدًا. وجرّب سلوك تطبيقك بإطلاق طلبات متزامنة على مفتاح منتهٍ الصلاحية قبل أن يفعل زوارك ذلك.