Redis والتخزين المؤقت المتقدم

التخزين المؤقت في البنية الموزعة

18 دقيقة الدرس 26 من 30

التخزين المؤقت في البنية الموزعة

تقدم بنية الخدمات الموزعة (Microservices) تحديات فريدة في التخزين المؤقت بسبب البيانات الموزعة وحدود الخدمات ومتطلبات الاتساق. دعنا نستكشف استراتيجيات التخزين المؤقت الفعالة للخدمات الموزعة.

تحديات التخزين المؤقت الموزع

تواجه الخدمات الموزعة تعقيدات محددة في التخزين المؤقت:

// التحدي 1: ملكية البيانات والحدود
// الخدمة A تمتلك بيانات المستخدم
class UserService {
async getUser(userId) {
const cacheKey = `user:${userId}`;
let user = await redis.get(cacheKey);

if (!user) {
user = await db.users.findById(userId);
await redis.setex(cacheKey, 3600, JSON.stringify(user));
} else {
user = JSON.parse(user);
}
return user;
}
}

// التحدي 2: الخدمة B تحتاج بيانات المستخدم
class OrderService {
async createOrder(userId, items) {
// هل يجب تخزين بيانات المستخدم مؤقتاً هنا؟
// الخيار 1: استدعاء UserService (تكلفة شبكية)
const user = await userServiceClient.getUser(userId);

// الخيار 2: التخزين المحلي (تكرار البيانات)
// الخيار 3: استخدام تخزين مشترك (اقتران)
}
}
التحديات الرئيسية:
  • ملكية البيانات وحدود الخدمات
  • اتساق الذاكرة المؤقتة عبر الخدمات
  • زمن انتقال الشبكة واستدعاءات الخدمات
  • تنسيق إبطال الذاكرة المؤقتة
  • تكرار البيانات مقابل الاقتران

أنماط الذاكرة المؤقتة المشتركة

يمكن لعدة خدمات مشاركة نسخة Redis مع تسمية المفاتيح المناسبة:

// النمط 1: مفاتيح بادئة الخدمة
class CacheManager {
constructor(serviceName) {
this.serviceName = serviceName;
this.redis = redis.createClient();
}

getKey(key) {
return `${this.serviceName}:${key}`;
}

async get(key) {
return await this.redis.get(this.getKey(key));
}

async set(key, value, ttl = 3600) {
return await this.redis.setex(this.getKey(key), ttl, value);
}
}

// خدمة المستخدم
const userCache = new CacheManager('user-service');
await userCache.set('user:123', userData);
// المفتاح في Redis: "user-service:user:123"

// خدمة الطلبات
const orderCache = new CacheManager('order-service');
await orderCache.set('order:456', orderData);
// المفتاح في Redis: "order-service:order:456"
// النمط 2: ذاكرة مؤقتة مرجعية مشتركة
// ذاكرة مؤقتة مركزية للبيانات المرجعية الشائعة
class ReferenceCache {
async getCountry(code) {
const key = `ref:country:${code}`;
let country = await redis.get(key);

if (!country) {
country = await referenceDataAPI.getCountry(code);
// TTL طويل للبيانات المرجعية (24 ساعة)
await redis.setex(key, 86400, JSON.stringify(country));
}
return JSON.parse(country);
}

async getProductCategory(id) {
const key = `ref:category:${id}`;
return await this.getCachedReference(key,
() => referenceDataAPI.getCategory(id)
);
}
}

استراتيجيات اتساق الذاكرة المؤقتة

الحفاظ على الاتساق عبر الخدمات الموزعة:

// الاستراتيجية 1: إبطال الذاكرة المؤقتة المدفوع بالأحداث
const EventEmitter = require('events');
const eventBus = new EventEmitter();

class UserService {
async updateUser(userId, updates) {
// تحديث قاعدة البيانات
await db.users.update(userId, updates);

// إبطال الذاكرة المؤقتة
await redis.del(`user:${userId}`);

// نشر حدث الإبطال
eventBus.emit('user.updated', { userId, updates });

// أو استخدام Redis Pub/Sub للأحداث الموزعة
await redis.publish('cache:invalidate', JSON.stringify({
service: 'user-service',
key: `user:${userId}`,
timestamp: Date.now()
}));
}
}

// الخدمات الأخرى تستمع لأحداث الإبطال
class OrderService {
constructor() {
this.subscriber = redis.duplicate();
this.subscriber.subscribe('cache:invalidate');

this.subscriber.on('message', async (channel, message) => {
const event = JSON.parse(message);
if (event.service === 'user-service') {
// إبطال الذاكرة المؤقتة المحلية إذا وجدت
await this.localCache.del(event.key);
}
});
}
}
// الاستراتيجية 2: الاتساق المعتمد على الوقت (الاتساق النهائي)
class CacheWithSoftTTL {
async get(key, fetcher, ttl = 3600, softTTL = 300) {
const data = await redis.get(key);

if (!data) {
// فقدان الذاكرة المؤقتة - جلب وتخزين
const fresh = await fetcher();
await redis.setex(key, ttl, JSON.stringify({
value: fresh,
cachedAt: Date.now()
}));
return fresh;
}

const cached = JSON.parse(data);
const age = Date.now() - cached.cachedAt;

if (age > softTTL * 1000) {
// تجاوز TTL الناعم - إرجاع البيانات القديمة وتحديث غير متزامن
setImmediate(async () => {
try {
const fresh = await fetcher();
await redis.setex(key, ttl, JSON.stringify({
value: fresh,
cachedAt: Date.now()
}));
} catch (err) {
console.error('فشل التحديث في الخلفية:', err);
}
});
}

return cached.value;
}
}

مزامنة الذاكرة المؤقتة

تقنيات مزامنة متقدمة للذاكرة المؤقتة الموزعة:

// النمط 1: التخزين المؤقت ذو المستويين (L1 + L2)
class TwoLevelCache {
constructor() {
this.l1Cache = new Map(); // في الذاكرة (سريع)
this.l2Cache = redis; // Redis (مشترك)
this.l1TTL = 60; // دقيقة واحدة
this.l2TTL = 3600; // ساعة واحدة
}

async get(key, fetcher) {
// فحص L1 (الذاكرة المحلية)
const l1Data = this.l1Cache.get(key);
if (l1Data && Date.now() < l1Data.expiresAt) {
return l1Data.value;
}

// فحص L2 (Redis)
const l2Data = await this.l2Cache.get(key);
if (l2Data) {
const value = JSON.parse(l2Data);
// ملء L1
this.l1Cache.set(key, {
value,
expiresAt: Date.now() + (this.l1TTL * 1000)
});
return value;
}

// فقدان الذاكرة المؤقتة - جلب من المصدر
const value = await fetcher();

// تخزين في كلا المستويين
this.l1Cache.set(key, {
value,
expiresAt: Date.now() + (this.l1TTL * 1000)
});
await this.l2Cache.setex(key, this.l2TTL, JSON.stringify(value));

return value;
}

async invalidate(key) {
this.l1Cache.delete(key);
await this.l2Cache.del(key);
// بث إلى النسخ الأخرى
await this.l2Cache.publish('cache:invalidate:l1', key);
}
}
// النمط 2: ذاكرة مؤقتة مباشرة عبر الخدمات
class WriteThroughCache {
async set(key, value, ttl = 3600) {
const pipeline = redis.pipeline();

// الكتابة إلى الذاكرة المؤقتة
pipeline.setex(key, ttl, JSON.stringify(value));

// نشر حدث التغيير
pipeline.publish('cache:change', JSON.stringify({
key,
value,
timestamp: Date.now()
}));

await pipeline.exec();
}
}

التخزين المؤقت على مستوى الخدمة

تنفيذ التخزين المؤقت على مستوى بوابة API والخدمات:

// وسيط ذاكرة مؤقتة لبوابة API
const express = require('express');
const app = express();

const gatewayCacheMiddleware = (ttl = 60) => {
return async (req, res, next) => {
// تخزين مؤقت لطلبات GET فقط
if (req.method !== 'GET') {
return next();
}

const cacheKey = `gateway:${req.originalUrl}`;
const cached = await redis.get(cacheKey);

if (cached) {
const data = JSON.parse(cached);
return res.set('X-Cache', 'HIT').json(data);
}

// التقاط الاستجابة
const originalJson = res.json.bind(res);
res.json = (data) => {
// تخزين مؤقت للاستجابات الناجحة فقط
if (res.statusCode === 200) {
redis.setex(cacheKey, ttl, JSON.stringify(data));
}
res.set('X-Cache', 'MISS');
return originalJson(data);
};

next();
};
};

// تطبيق على المسارات
app.get('/api/products', gatewayCacheMiddleware(300), productController.list);
app.get('/api/products/:id', gatewayCacheMiddleware(600), productController.get);
// استراتيجية ذاكرة مؤقتة خاصة بالخدمة
class ProductService {
constructor() {
this.cache = new CacheManager('product-service');
}

async getProduct(id) {
return await this.cache.get(`product:${id}`, async () => {
// جلب من قاعدة البيانات
const product = await db.products.findById(id);

// جلب البيانات ذات الصلة من خدمات أخرى (تخزين مؤقت لهذه أيضاً)
const [category, inventory] = await Promise.all([
this.getCategoryFromCache(product.categoryId),
this.getInventoryFromCache(id)
]);

return { ...product, category, inventory };
}, 3600);
}

async getCategoryFromCache(categoryId) {
return await this.cache.get(
`category:${categoryId}`,
() => categoryServiceClient.get(categoryId),
7200 // TTL أطول للبيانات المرجعية
);
}
}
أنماط مضادة للتخزين المؤقت في الخدمات الموزعة:
  • تخزين البيانات التي لا تمتلكها دون استراتيجية إبطال
  • الاقتران المفرط للخدمات من خلال تبعيات الذاكرة المؤقتة المشتركة
  • تجاهل حدود الخدمة في مفاتيح الذاكرة المؤقتة
  • عدم التعامل مع تقسيمات الشبكة وعدم توفر الذاكرة المؤقتة
  • التخزين المؤقت دون مراقبة فعالية الذاكرة المؤقتة لكل خدمة

مراقبة صحة الذاكرة المؤقتة

المراقبة الأساسية للتخزين المؤقت الموزع:

// مقاييس الذاكرة المؤقتة على مستوى الخدمة
class CacheMetrics {
constructor(serviceName) {
this.serviceName = serviceName;
this.hits = 0;
this.misses = 0;
this.errors = 0;
}

recordHit() {
this.hits++;
redis.hincrby(`metrics:${this.serviceName}`, 'cache_hits', 1);
}

recordMiss() {
this.misses++;
redis.hincrby(`metrics:${this.serviceName}`, 'cache_misses', 1);
}

getHitRate() {
const total = this.hits + this.misses;
return total > 0 ? (this.hits / total * 100).toFixed(2) : 0;
}

async getGlobalMetrics() {
const metrics = await redis.hgetall(`metrics:${this.serviceName}`);
return {
service: this.serviceName,
hits: parseInt(metrics.cache_hits || 0),
misses: parseInt(metrics.cache_misses || 0),
hitRate: this.calculateHitRate(metrics)
};
}
}
تمرين: صمم استراتيجية تخزين مؤقت لبنية تجارة إلكترونية موزعة تحتوي على الخدمات التالية: خدمة المستخدم، خدمة المنتجات، خدمة المخزون، خدمة الطلبات، وخدمة الدفع. ضع في اعتبارك:
  • ما البيانات التي يجب تخزينها مؤقتاً في كل خدمة؟
  • أي ذاكرة مؤقتة يجب أن تكون مشتركة مقابل خاصة بالخدمة؟
  • كيف ستتعامل مع إبطال الذاكرة المؤقتة عند تحديث منتج؟
  • ما قيم TTL المناسبة لكل نوع بيانات؟
  • كيف ستضمن الاتساق بين الخدمات؟
أفضل الممارسات: استخدم مفاتيح ذاكرة مؤقتة مسبوقة باسم الخدمة، نفذ إبطالاً مدفوعاً بالأحداث للبيانات الحرجة، اقبل الاتساق النهائي للبيانات غير الحرجة، راقب معدلات نجاح الذاكرة المؤقتة لكل خدمة، ونفذ قواطع الدوائر لفشل الذاكرة المؤقتة.