Redis والتخزين المؤقت المتقدم
أنماط واستراتيجيات التخزين المؤقت
أنماط واستراتيجيات التخزين المؤقت
في هذا الدرس، سنستكشف أنماط واستراتيجيات تخزين مؤقت مختلفة تُستخدم في التطبيقات الحديثة. اختيار نمط التخزين المؤقت الصحيح يمكن أن يؤثر بشكل كبير على أداء تطبيقك واتساقه وتعقيده.
Cache-Aside (التحميل الكسول)
نمط التخزين المؤقت الأكثر شيوعاً حيث يكون التطبيق مسؤولاً عن القراءة والكتابة إلى ذاكرة التخزين المؤقت:
async function getUserById(userId) {
// حاول الحصول من ذاكرة التخزين المؤقت أولاً
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;
}
// حاول الحصول من ذاكرة التخزين المؤقت أولاً
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;
}
ملاحظة: يُعرف Cache-Aside أيضاً باسم التحميل الكسول لأن البيانات يتم تحميلها في ذاكرة التخزين المؤقت فقط عند الطلب.
المزايا:
- يتم تخزين البيانات المطلوبة فقط مؤقتاً (استخدام فعال للذاكرة)
- فشل ذاكرة التخزين المؤقت لا يمنع التطبيق من العمل
- بسيط للتنفيذ
العيوب:
- عقوبة فشل ذاكرة التخزين المؤقت (زمن انتقال إضافي في الطلب الأول)
- احتمالية بيانات قديمة إذا تم تحديث قاعدة البيانات مباشرة
- يتطلب إبطال يدوي لذاكرة التخزين المؤقت
التخزين المؤقت للكتابة المباشرة (Write-Through)
يتم كتابة البيانات إلى ذاكرة التخزين المؤقت وقاعدة البيانات في نفس الوقت:
async function updateUser(userId, updates) {
// تحديث قاعدة البيانات أولاً
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;
}
// تحديث قاعدة البيانات أولاً
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)
يتم كتابة البيانات إلى ذاكرة التخزين المؤقت فوراً وبشكل غير متزامن إلى قاعدة البيانات:
async function updateUserFast(userId, updates) {
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);
});
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)
تعمل ذاكرة التخزين المؤقت كوكيل يقوم تلقائياً بتحميل البيانات من قاعدة البيانات عند الحاجة:
class CacheManager {
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
);
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)
تحديث ذاكرة التخزين المؤقت بشكل استباقي قبل انتهاء صلاحيتها للبيانات المُستخدمة بشكل متكرر:
async function getWithRefresh(key, loader, ttl = 3600) {
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;
}
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: أنماط وصول بيانات ساخنة يمكن التنبؤ بها، متطلبات زمن انتقال صفري
مثال على نمط هجين
دمج أنماط متعددة للحصول على أداء أمثل:
class SmartCache {
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);
}
}
}
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);
}
}
}
تمرين: نفذ ذاكرة تخزين مؤقت لكتالوج المنتجات باستخدام cache-aside للقراءات وwrite-through للتحديثات. أضف دالة لإبطال جميع المنتجات المخزنة مؤقتاً عندما يؤثر تحديث السعر على عناصر متعددة. اختبر مع 1000 منتج وقس معدلات إصابة ذاكرة التخزين المؤقت.