امنع Cache Stampede في API باستخدام Redis
مستوى القارئ: متوسط
هتقلل ضغط قاعدة البيانات وقت انتهاء الكاش بدل ما ألف طلب يضربوا نفس query في نفس الثانية.
المشكلة باختصار
الـ cache العادي بيحل مشكلة، لكنه ممكن يفتح مشكلة تانية. لو عندك endpoint بيجيب قائمة منتجات، ومدة الكاش 60 ثانية، أول طلب بعد انتهاء الـ TTL هيبني القيمة من قاعدة البيانات. ده طبيعي.
اللي بيحصل فعلاً في وقت الذروة إن مش طلب واحد هو اللي يوصل بعد انتهاء الـ TTL. ممكن 500 أو 1000 طلب يوصلوا خلال ثانيتين. كلهم يلاقوا الكاش فاضي، وكلهم ينفذوا نفس query. النتيجة: latency عالي، CPU أعلى على قاعدة البيانات، وأحيانًا timeout.
الطريقة دي بتفشل لما تعتمد على TTL فقط. البديل الأفضل هنا: طلب واحد فقط يحدث الكاش، وباقي الطلبات تأخذ نسخة stale لفترة قصيرة أو تنتظر نتيجة مضمونة.
الفكرة الأساسية: lock قصير + stale cache
ركز في التشبيه العملي. عندك باب واحد لمخزن. بدل ما كل الموظفين يدخلوا المخزن في نفس اللحظة عشان يجيبوا نفس الصندوق، شخص واحد يدخل ويحدث الصندوق، والباقي يستخدموا آخر نسخة موجودة لحد ما التحديث يخلص.
تقنيًا، الشخص الواحد ده هو Redis lock. آخر نسخة موجودة هي stale value. Redis يدعم نمط القفل باستخدام SET key value NX PX timeout، حيث NX تعني لا تضع المفتاح إلا لو مش موجود، وPX تعطيه انتهاء تلقائي بالملي ثانية. الفكرة موثقة في نمط distributed locking في Redis.
الافتراض إن الـ endpoint عندك عام أو شبه عام، ومش بيرجع بيانات شخصية لكل مستخدم. لو بيرجع بيانات مرتبطة بالجلسة، اقرأ قسم “متى لا تستخدم هذه الطريقة” قبل التنفيذ.
التنفيذ في Node.js
المثال التالي يستخدم ioredis. الكاش الأساسي مدته 60 ثانية، والنسخة القديمة مسموح استخدامها 30 ثانية إضافية أثناء إعادة البناء. الأرقام دي مش مقدسة. ابدأ بها، ثم قِس P95 latency وعدل.
import Redis from "ioredis";
import crypto from "node:crypto";
const redis = new Redis(process.env.REDIS_URL);
async function getProductsFromDb() {
// ضع هنا query الحقيقي. المثال يفترض أنها تستغرق 600ms وقت الضغط.
return [{ id: 1, name: "Keyboard" }];
}
export async function getProductsCached() {
const cacheKey = "products:v1";
const staleKey = "products:v1:stale";
const lockKey = "lock:products:v1";
const token = crypto.randomUUID();
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const locked = await redis.set(lockKey, token, "NX", "PX", 5000);
if (locked) {
try {
const fresh = await getProductsFromDb();
await redis.set(cacheKey, JSON.stringify(fresh), "EX", 60);
await redis.set(staleKey, JSON.stringify(fresh), "EX", 90);
return fresh;
} finally {
const current = await redis.get(lockKey);
if (current === token) await redis.del(lockKey);
}
}
const stale = await redis.get(staleKey);
if (stale) return JSON.parse(stale);
await new Promise(resolve => setTimeout(resolve, 120));
const retry = await redis.get(cacheKey);
if (retry) return JSON.parse(retry);
return getProductsFromDb();
}القياس قبل وبعد
سيناريو واقعي: API عليه 50K زائر يوميًا، وفي الدقيقة الأولى من حملة إعلانية بيجيله 1000 طلب على نفس endpoint. قبل الحل، انتهاء TTL عمل spike: P95 latency وصل تقريبًا من 130ms إلى 700ms، وعدد queries المتكررة على قاعدة البيانات وصل لمئات في أقل من ثانيتين.
بعد Redis lock، طلب واحد فقط بنى القيمة الجديدة. باقي الطلبات رجعت stale cache أو انتظرت retry قصير. القياس المتوقع في الحالة دي: P95 بين 140ms و170ms بدل 700ms. ده تقدير مبني على query أصلية حوالي 600ms، ولازم تقيسه عندك بـ k6 أو Grafana.
الـ trade-off هنا
بتكسب استقرار وقت الذروة وتقليل ضغط واضح على قاعدة البيانات. بتخسر بساطة الكود، وبتقبل إن بعض الطلبات تشوف بيانات قديمة لمدة 30 ثانية مثلًا. لو البيانات أسعار لحظية أو رصيد حساب، الثمن ده غير مقبول.
فيه تكلفة تشغيل كمان. Redis لازم يكون متاحًا ومرصودًا. لو Redis وقع، الكود لازم يعرف يرجع للـ DB بدون ما ينهار. لذلك خلي lock timeout قصير، وسجل metric باسم cache_lock_acquired وstale_served_total.
متى لا تستخدم هذه الطريقة
لا تستخدمها مع بيانات شخصية أو مالية أو طبية، ولا مع endpoints لازم ترجع أحدث قيمة في كل طلب. كمان لا تستخدمها لو عدد الطلبات قليل جدًا. لو endpoint بيجيله 20 طلب في الساعة، التعقيد مش مستاهل.
استخدمها لما يكون عندك بيانات عامة، expensive query، وtraffic متزامن. أمثلة مناسبة: قوائم المنتجات، صفحات التصنيفات، leaderboards غير لحظية، ونتائج بحث محفوظة لفترة قصيرة.
مصادر وتفاصيل مهمة
- Redis يوضح استخدام
SET key value NX PX timeoutكقفل ذري مع انتهاء تلقائي في نمط distributed locking: Redis distributed locking. - MDN يشرح
stale-while-revalidateكفكرة تسمح باستخدام نسخة stale أثناء إعادة التحقق في الخلفية: Cache-Control. - Cloudflare يوثق فكرة request collapsing عند cache miss عشان origin يستقبل طلبًا واحدًا بدل طلبات مكررة: Cloudflare cache behavior.
الخطوة التالية
اختار endpoint واحد بطيء وعام، وطبّق عليه Redis lock لمدة 5 ثواني مع stale cache لمدة 30 ثانية. بعدين قارن P95 latency وعدد queries قبل وبعد لمدة يوم واحد.