أنماط واستراتيجيات التخزين المؤقت
أنماط واستراتيجيات التخزين المؤقت
في هذا الدرس، سنستكشف أنماط واستراتيجيات تخزين مؤقت مختلفة تُستخدم في التطبيقات الحديثة. اختيار نمط التخزين المؤقت الصحيح يمكن أن يؤثر بشكل كبير على أداء تطبيقك واتساقه وتعقيده.
Cache-Aside (التحميل الكسول)
نمط التخزين المؤقت الأكثر شيوعاً حيث يكون التطبيق مسؤولاً عن القراءة والكتابة إلى ذاكرة التخزين المؤقت:
// حاول الحصول من ذاكرة التخزين المؤقت أولاً
const cacheKey = `user:${userId}`;
let user = await redis.get(cacheKey);
if (user) {
// إصابة ذاكرة التخزين المؤقت
console.log('إصابة ذاكرة التخزين المؤقت');
return JSON.parse(user);
}
// فشل ذاكرة التخزين المؤقت - جلب من قاعدة البيانات
console.log('فشل ذاكرة التخزين المؤقت');
user = await db.users.findById(userId);
if (user) {
// تخزين في ذاكرة التخزين المؤقت مع انتهاء صلاحية ساعة واحدة
await redis.setex(
cacheKey,
3600,
JSON.stringify(user)
);
}
return user;
}
المزايا:
- يتم تخزين البيانات المطلوبة فقط مؤقتاً (استخدام فعال للذاكرة)
- فشل ذاكرة التخزين المؤقت لا يمنع التطبيق من العمل
- بسيط للتنفيذ
العيوب:
- عقوبة فشل ذاكرة التخزين المؤقت (زمن انتقال إضافي في الطلب الأول)
- احتمالية بيانات قديمة إذا تم تحديث قاعدة البيانات مباشرة
- يتطلب إبطال يدوي لذاكرة التخزين المؤقت
التخزين المؤقت للكتابة المباشرة (Write-Through)
يتم كتابة البيانات إلى ذاكرة التخزين المؤقت وقاعدة البيانات في نفس الوقت:
// تحديث قاعدة البيانات أولاً
const user = await db.users.update(userId, updates);
// ثم تحديث ذاكرة التخزين المؤقت
const cacheKey = `user:${userId}`;
await redis.setex(
cacheKey,
3600,
JSON.stringify(user)
);
return user;
}
async function createUser(userData) {
// إنشاء في قاعدة البيانات
const user = await db.users.create(userData);
// تخزينه مؤقتاً فوراً
const cacheKey = `user:${user.id}`;
await redis.setex(
cacheKey,
3600,
JSON.stringify(user)
);
return user;
}
المزايا:
- ذاكرة التخزين المؤقت دائماً متسقة مع قاعدة البيانات
- لا توجد عقوبة فشل ذاكرة التخزين المؤقت للبيانات المكتوبة مؤخراً
- التطبيقات ذات القراءة الكثيفة تستفيد بشكل كبير
العيوب:
- زمن انتقال الكتابة (البيانات مكتوبة في مكانين)
- تخزين مؤقت غير ضروري للبيانات التي قد لا يتم قراءتها أبداً
- معالجة أخطاء أكثر تعقيداً
التخزين المؤقت للكتابة الخلفية (Write-Behind)
يتم كتابة البيانات إلى ذاكرة التخزين المؤقت فوراً وبشكل غير متزامن إلى قاعدة البيانات:
const cacheKey = `user:${userId}`;
// الحصول على بيانات المستخدم الحالية
let user = await redis.get(cacheKey);
user = user ? JSON.parse(user) : await db.users.findById(userId);
// تحديث كائن المستخدم
user = { ...user, ...updates };
// الكتابة إلى ذاكرة التخزين المؤقت فوراً
await redis.setex(cacheKey, 3600, JSON.stringify(user));
// وضع كتابة قاعدة البيانات في قائمة الانتظار لاحقاً (غير متزامن)
await writeQueue.add({
operation: 'update',
table: 'users',
id: userId,
data: updates
});
return user;
}
// عامل خلفية يعالج قائمة انتظار الكتابة
writeQueue.process(async (job) => {
const { operation, table, id, data } = job.data;
await db[table][operation](id, data);
});
المزايا:
- كتابات سريعة جداً (سرعة ذاكرة التخزين المؤقت)
- يقلل حمل قاعدة البيانات بشكل كبير
- يمكن تجميع عمليات كتابة متعددة معاً
العيوب:
- خطر فقدان البيانات إذا فشلت ذاكرة التخزين المؤقت قبل كتابة قاعدة البيانات
- معقد للتنفيذ بشكل صحيح
- مشكلات الاتساق النهائي
التخزين المؤقت للقراءة المباشرة (Read-Through)
تعمل ذاكرة التخزين المؤقت كوكيل يقوم تلقائياً بتحميل البيانات من قاعدة البيانات عند الحاجة:
constructor(redis, db) {
this.redis = redis;
this.db = db;
}
async get(key, loader, ttl = 3600) {
// جرب ذاكرة التخزين المؤقت أولاً
let data = await this.redis.get(key);
if (data) {
return JSON.parse(data);
}
// فشل ذاكرة التخزين المؤقت - استخدم دالة المحمل
data = await loader();
if (data) {
// تخزين في ذاكرة التخزين المؤقت
await this.redis.setex(key, ttl, JSON.stringify(data));
}
return data;
}
}
// الاستخدام
const cache = new CacheManager(redis, db);
const user = await cache.get(
`user:${userId}`,
() => db.users.findById(userId),
3600
);
المزايا:
- يجرد منطق التخزين المؤقت من كود التطبيق
- واجهة برمجة تطبيقات متسقة للبيانات المخزنة مؤقتاً وغير المخزنة
- أسهل للصيانة والاختبار
التخزين المؤقت بالتحديث المسبق (Refresh-Ahead)
تحديث ذاكرة التخزين المؤقت بشكل استباقي قبل انتهاء صلاحيتها للبيانات المُستخدمة بشكل متكرر:
const data = await redis.get(key);
if (data) {
// فحص TTL - إذا تبقى أقل من 25%، قم بالتحديث
const remainingTTL = await redis.ttl(key);
const refreshThreshold = ttl * 0.25;
if (remainingTTL < refreshThreshold) {
// تحديث غير متزامن (لا تنتظر)
loader().then(newData => {
redis.setex(key, ttl, JSON.stringify(newData));
}).catch(err => {
console.error('فشل التحديث:', err);
});
}
return JSON.parse(data);
}
// فشل ذاكرة التخزين المؤقت - تحميل متزامن
const newData = await loader();
await redis.setex(key, ttl, JSON.stringify(newData));
return newData;
}
المزايا:
- يقلل عقوبة فشل ذاكرة التخزين المؤقت للبيانات الساخنة
- يحصل المستخدمون دائماً على استجابات سريعة
- يحافظ على البيانات المُستخدمة بشكل متكرر محدثة
العيوب:
- تعقيد متزايد
- قد يحدث تحديث للبيانات دون داعٍ
- يتطلب تنبؤاً دقيقاً لنمط الوصول
اختيار النمط المناسب
- Cache-Aside: الأغراض العامة، أحمال العمل ذات القراءة الكثيفة، عندما تتغير البيانات بشكل نادر
- Write-Through: عندما يكون اتساق البيانات حرجاً، سيناريوهات القراءة بعد الكتابة
- Write-Behind: متطلبات إنتاجية كتابة عالية، يمكن تحمل الاتساق النهائي
- Read-Through: عندما تريد تجريد منطق التخزين المؤقت، واجهة برمجة تطبيقات متسقة
- Refresh-Ahead: أنماط وصول بيانات ساخنة يمكن التنبؤ بها، متطلبات زمن انتقال صفري
مثال على نمط هجين
دمج أنماط متعددة للحصول على أداء أمثل:
constructor(redis, db) {
this.redis = redis;
this.db = db;
}
// Cache-aside للقراءات
async read(key, loader, ttl = 3600) {
let data = await this.redis.get(key);
if (data) return JSON.parse(data);
data = await loader();
if (data) {
await this.redis.setex(key, ttl, JSON.stringify(data));
}
return data;
}
// Write-through للتحديثات
async write(key, saver, ttl = 3600) {
const data = await saver();
await this.redis.setex(key, ttl, JSON.stringify(data));
return data;
}
// إبطال يدوي
async invalidate(key) {
await this.redis.del(key);
}
// إبطال النمط (أحرف البدل)
async invalidatePattern(pattern) {
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
}