LRU Cache في JavaScript: سرّع القراءات المتكررة بـ Map
هتطلع من المقال ده بكلاس JavaScript صغير يعمل LRU Cache فعلي، ويقلل قراءات قاعدة البيانات المتكررة من عشرات المللي ثانية إلى 2 مللي ثانية تقريبًا في الحالات المناسبة.
مستوى القارئ: متوسط
المشكلة باختصار
لو صفحة المنتج عندك بتطلب نفس بيانات المنتج كل شوية، قراءة قاعدة البيانات في كل مرة مش أفضل حل. الافتراض إن عندك API بيدخل له 50 ألف زائر يوميًا، وأشهر 1000 منتج بياخدوا 70% من الطلبات. اللي بيحصل فعلاً إنك بتعيد نفس الاستعلام آلاف المرات، رغم إن النتيجة غالبًا لم تتغير خلال دقيقة أو دقيقتين.
الحل هنا مش إنك ترمي Redis في أي مكان. أحيانًا كاش صغير داخل نفس عملية Node.js يكفي. التكلفة: الكاش يضيع عند restart، ومش مشترك بين أكثر من instance. المكسب: تنفيذ سريع جدًا، وتعقيد أقل، ومناسب للبيانات غير الحساسة التي تتحمل stale بسيط.
الفكرة بمثال واضح
ركز في المثال ده. عندك رف صغير على مكتبك يسع 3 ملفات فقط. كل مرة تحتاج ملف، لو موجود على المكتب تاخده فورًا. لو مش موجود، تقوم تجيبه من الأرشيف البعيد. لو المكتب امتلأ، تشيل أقل ملف استخدمته مؤخرًا وتحط الملف الجديد مكانه.
ده بالظبط معنى LRU: Least Recently Used. أنت لا تحذف أقدم عنصر بالزمن المطلق فقط، بل تحذف العنصر الذي لم يُستخدم منذ أطول فترة. في JavaScript، Map مناسب للفكرة لأنه يحفظ ترتيب إدخال المفاتيح، ويدعم get وset وdelete. عند قراءة عنصر موجود، نحذفه ونضيفه مرة أخرى في آخر الـ Map. كده يبقى الأحدث استخدامًا في آخر الترتيب.
كود LRU Cache قابل للنسخ
الكود التالي مناسب كبداية داخل خدمة Node.js واحدة. الرقم 1000 هنا مش مقدس. اختاره بناءً على حجم العنصر في الذاكرة وعدد العناصر الساخنة عندك.
class LRUCache {
constructor(limit = 1000) {
this.limit = limit;
this.store = new Map();
}
get(key) {
if (!this.store.has(key)) return undefined;
const value = this.store.get(key);
this.store.delete(key);
this.store.set(key, value);
return value;
}
set(key, value) {
if (this.store.has(key)) {
this.store.delete(key);
}
this.store.set(key, value);
if (this.store.size > this.limit) {
const oldestKey = this.store.keys().next().value;
this.store.delete(oldestKey);
}
}
size() {
return this.store.size;
}
}
const productCache = new LRUCache(1000);
async function getProduct(productId) {
const cacheKey = `product:${productId}`;
const cached = productCache.get(cacheKey);
if (cached) return { source: "cache", data: cached };
const product = await db.products.findById(productId);
productCache.set(cacheKey, product);
return { source: "db", data: product };
}الطريقة الشائعة الغلط إنك تعمل const cache = {} وتفضل تضيف مفاتيح بدون حد أقصى. الطريقة دي بتفشل لما traffic يزيد، لأن الذاكرة تكبر مع الوقت ومفيش eviction واضح. Map مع limit بيخليك عارف أقصى عدد عناصر من البداية.
قياس قبل وبعد
في سيناريو واقعي، قراءة منتج من PostgreSQL عبر ORM ممكن تاخد 40 إلى 120ms حسب الشبكة والاستعلام والفهارس. قراءة نفس العنصر من Map داخل نفس العملية غالبًا أقل من 2ms. لو عندك 10 آلاف طلب في الدقيقة، و60% منها hit في الكاش، فأنت بتقلل حوالي 6000 round trip لقاعدة البيانات كل دقيقة.
قِس قبل ما تعتمد. استخدم process.memoryUsage() في Node.js لمراقبة heap، وسجل hit rate في اللوج. لو hit rate أقل من 20%، غالبًا الكاش مش مفيد. لو heap زاد 150MB بعد تشغيل الكاش، قلل limit أو خزّن نسخة أصغر من العنصر.
setInterval(() => {
const mb = process.memoryUsage().heapUsed / 1024 / 1024;
console.log({ cacheSize: productCache.size(), heapUsedMB: Math.round(mb) });
}, 30000);الـ trade-off هنا
بتكسب latency أقل وضغط أقل على قاعدة البيانات. بتخسر consistency كاملة بين كل الـ instances. لو عندك 4 نسخ من نفس الخدمة خلف load balancer، كل نسخة عندها كاش مختلف. ده مقبول لبيانات منتجات عامة تتحدث كل دقيقة أو خمس دقائق. مش مقبول لرصيد محفظة، صلاحيات مستخدم، أو أسعار لازم تتغير فورًا.
فيه trade-off كمان في الذاكرة. لو كل منتج بعد serialization حجمه 20KB، وتخزن 1000 منتج، فأنت بتتكلم عن 20MB تقريبًا قبل overhead. الرقم قابل للزيادة. لذلك لا تخزن response كامل فيه صور ووصف طويل لو أنت محتاج السعر والاسم فقط.
متى لا تستخدم هذه الطريقة
لا تستخدم LRU داخل العملية لو التطبيق موزع على instances كثيرة وتحتاج كاش مشترك. استخدم Redis أو Memcached. لا تستخدمه لو البيانات حساسة أو لازم تتحدث لحظيًا. لا تستخدمه لو كل request بمفتاح مختلف؛ هنا hit rate هيبقى ضعيف والذاكرة هتتحول لمخزن مؤقت بلا قيمة.
كذلك لا تستخدمه كبديل لفهارس قاعدة البيانات. لو الاستعلام بطيء لأنه بدون index، ابدأ بالـ index. الكاش يعالج التكرار، مش سوء تصميم الاستعلام.
المصادر
الخطوة التالية
افتح endpoint واحد عندك بيتكرر كثيرًا، وضيف LRU Cache بسعة 100 عنصر لمدة يوم مراقبة. لو hit rate عدى 50% والذاكرة فضلت مستقرة، كبّر السعة تدريجيًا بدل ما تبدأ بحل موزع من أول يوم.