📌 المقال الرئيسي: Redis 8: caching، queues، pub/sub، streams
المتطلّبات: تثبيت Redis 8 وضبط RDB + AOF
Caching هو الاستعمال التاريخي والمهيمن دائمًا لـ Redis. مُنَفَّذًا جيّدًا، يُقَلِّل الحِمل على قاعدة علاقية بعشرة أضعاف ويُخَفِّض زمن الاستجابة جذريًّا. مُنَفَّذًا سيّئًا، يُدخل عدم اتّساق صامت، تسرّبات ذاكرة، أو انهيارات سلسلية. يُفَصِّل هذا الدليل الأنماط الكلاسيكية الثلاثة — cache-aside، write-through، write-behind — في Node.js بـ ioredis، تسوياتها، إدارة TTL، وإبطال بـ tags.
المتطلّبات
- خادم Redis 8 يعمل.
- Node.js 22 LTS وnpm.
- قاعدة بيانات PostgreSQL أو MySQL لمحاكاة backend بطيء.
- أساسيات async/await في JavaScript.
- الوقت: 60 دقيقة.
الخطوة 1 — تهيئة المشروع وتثبيت التبعيّات
mkdir cache-redis-demo
cd cache-redis-demo
npm init -y
npm install ioredis pg
ioredis أنضج عميل في المنظومة: يدعم Cluster وSentinel وpipelining أصليًّا. pg عميل PostgreSQL. لـ MySQL، استبدله بـ mysql2.
الخطوة 2 — تأسيس الاتّصال مع إدارة الأخطاء
// redis-client.js
import Redis from 'ioredis';
export const redis = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: 6379,
username: 'appuser',
password: process.env.REDIS_PASSWORD,
db: 0,
retryStrategy(times) {
return Math.min(times * 200, 5000);
},
maxRetriesPerRequest: 3
});
redis.on('connect', () => console.log('Redis connecté'));
redis.on('error', (err) => console.error('Erreur Redis :', err.message));
redis.on('reconnecting', (ms) => console.log('Reconnexion Redis dans ' + ms + 'ms'));
retryStrategy يُملي استراتيجية إعادة الاتّصال: times * 200 مع سقف 5000 ms يُنتج 200 ms، 400 ms… حتى 5 ث. maxRetriesPerRequest: 3 يحدّ بـ 3 محاولات قبل إخفاق الأمر.
الخطوة 3 — نمط cache-aside
Cache-aside (lazy loading) هو الأبسط والأكثر استعمالًا. التطبيق يتحقّق من cache أوّلًا؛ إن كانت القيمة حاضرة، تُقَدَّم؛ وإلّا يستجوب القاعدة، يكتب النتيجة في cache مع TTL، ويُقَدِّمها. cache لا يعرف شيئًا عن القاعدة — التطبيق يُنَسِّق.
// cache-aside.js
import { redis } from './redis-client.js';
import pg from 'pg';
const db = new pg.Client({ /* إعداد PostgreSQL */ });
await db.connect();
async function getProduit(id) {
const cacheKey = `produit:${id}`;
// 1. محاولة قراءة cache
const cached = await redis.get(cacheKey);
if (cached !== null) {
console.log('HIT pour ' + cacheKey);
return JSON.parse(cached);
}
// 2. cache miss: استعلام القاعدة
console.log('MISS pour ' + cacheKey + ', requête base');
const result = await db.query('SELECT * FROM produits WHERE id = $1', [id]);
if (result.rows.length === 0) {
return null;
}
const produit = result.rows[0];
// 3. كتابة في cache مع TTL 300 ثانية
await redis.set(cacheKey, JSON.stringify(produit), 'EX', 300);
return produit;
}
'EX', 300 يُطَبِّق TTL بـ 300 ثانية: بعد هذه المهلة، المفتاح يُحذَف تلقائيًّا. تسلسل JSON ضروري لأنّ Redis يُخَزِّن strings. كلفة hit نمطيًّا 0.3 إلى 1 ms على شبكة محلّية.
الخطوة 4 — مشكلة cache stampede وحلّها
النمط الساذج له عيب رئيسي في الإنتاج: حين ينتهي مفتاح ساخن، عشرات الطلبات المتزامنة تُسَبِّب miss لكلّ منها، تستجوب القاعدة بالتوازي، وتُعيد كتابة نفس القيمة. هذا cache stampede. الحلّ القياسي قفل احتمالي.
async function getProduitProtege(id) {
const cacheKey = `produit:${id}`;
const lockKey = `lock:${cacheKey}`;
const cached = await redis.get(cacheKey);
if (cached !== null) return JSON.parse(cached);
// محاولة اكتساب قفل لإعادة بناء المفتاح
const lockAcquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (!lockAcquired) {
// عملية أخرى تُعيد البناء، انتظر قليلًا
await new Promise(r => setTimeout(r, 50));
const retry = await redis.get(cacheKey);
if (retry !== null) return JSON.parse(retry);
}
try {
const result = await db.query('SELECT * FROM produits WHERE id = $1', [id]);
if (result.rows.length === 0) return null;
const produit = result.rows[0];
await redis.set(cacheKey, JSON.stringify(produit), 'EX', 300);
return produit;
} finally {
await redis.del(lockKey);
}
}
الخيار NX على SET ينشئ المفتاح فقط إن لم يكن موجودًا. أوّل عملية تصل تكتسب القفل وتُعيد البناء؛ التالية تنتظر 50 ms وتُعيد المحاولة. EX 10 على القفل يضمن تحريره حتى لو انهارت العملية.
الخطوة 5 — نمط write-through للاتّساق القوي
async function mettreAJourProduit(id, modifications) {
// 1. كتابة في القاعدة (مصدر الحقيقة)
const result = await db.query(
'UPDATE produits SET prix = $2, stock = $3 WHERE id = $1 RETURNING *',
[id, modifications.prix, modifications.stock]
);
if (result.rows.length === 0) throw new Error('Produit introuvable');
const produit = result.rows[0];
// 2. تحديث متزامن لـ cache
await redis.set(`produit:${id}`, JSON.stringify(produit), 'EX', 3600);
return produit;
}
مع write-through، يمكن أن يكون TTL أطول بكثير (هنا ساعة، أو لا نهائي) لأنّ cache مضمون التزامن مع القاعدة عند كلّ تعديل. نمط مثالي لكائنات مرجعية تتغيّر قليلًا وتُقرأ كثيرًا.
الخطوة 6 — إبطال بـ tags
async function cacheAvecTags(cacheKey, valeur, tags, ttl = 300) {
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(valeur), 'EX', ttl);
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, cacheKey);
pipeline.expire(`tag:${tag}`, ttl + 60);
}
await pipeline.exec();
}
async function invaliderTag(tag) {
const cles = await redis.smembers(`tag:${tag}`);
if (cles.length === 0) return 0;
const pipeline = redis.pipeline();
cles.forEach(k => pipeline.del(k));
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return cles.length;
}
// الاستعمال
await cacheAvecTags('produit:42', produit42, ['categorie:smartphones', 'marque:samsung']);
await cacheAvecTags('produit:43', produit43, ['categorie:smartphones', 'marque:apple']);
// عند تعديل فئة smartphones، نُبطل الكلّ:
const n = await invaliderTag('categorie:smartphones');
console.log(n + ' cles invalidees');
النمط يستعمل pipeline لتجميع الأوامر في رحلة شبكة واحدة. SADD يُضيف المفتاح لـ set الـ tag. لعشرات الآلاف من المفاتيح لكلّ tag، فكّر في SSCAN بدل SMEMBERS.
الخطوة 7 — قياس hit ratio
async function statsCache() {
const info = await redis.info('stats');
const hits = parseInt(info.match(/keyspace_hits:(d+)/)[1]);
const misses = parseInt(info.match(/keyspace_misses:(d+)/)[1]);
const total = hits + misses;
const ratio = total > 0 ? (hits / total * 100).toFixed(2) : 0;
return { hits, misses, ratio: ratio + '%', total };
}
console.log(await statsCache());
// مثال: { hits: 8432, misses: 1156, ratio: '87.94%', total: 9588 }
hit ratio أعلى من 80% جيّد عادة؛ فوق 95% يُشير إلى تقدير ممتاز. إن نزل تحت 60%، إمّا زد TTL، أو زد maxmemory، أو راجع استراتيجية cache.
الخطوة 8 — TTL تكيّفي مع jitter
function ttlAvecJitter(baseSeconds, jitterPercent = 0.1) {
const jitter = baseSeconds * jitterPercent;
return Math.floor(baseSeconds + Math.random() * jitter - jitter / 2);
}
// بدل:
// redis.set(key, value, 'EX', 300);
// استعمل:
redis.set(key, value, 'EX', ttlAvecJitter(300));
// TTL سيكون موزَّعًا بين 285 و315 ثانية
هذا التعديل البسيط يُوَزِّع الانتهاءات في الزمن ويتفادى موجات miss متزامنة.
أخطاء شائعة
| الخطأ | السبب | الحلّ |
|---|---|---|
| Cache غير صالح بعد تحديث DB | Cache-aside دون إبطال عند الكتابة | DEL للمفتاح بعد كلّ UPDATE |
| Hit ratio منخفض جدًّا | TTL قصير أو maxmemory غير كافٍ | زد TTL، تحقّق من evicted_keys |
| OOM kill لعملية Redis | maxmemory غير مُحَدَّد | هَيِّئ maxmemory-policy allkeys-lru |
| Race condition عند miss | Cache stampede دون قفل | نَفِّذ قفلًا احتماليًّا |
| JSON.parse ينهار | بيانات فاسدة أو تغيّر التنسيق | غَلِّف في try/catch، عامله كـ miss |
الأدلّة التالية
FAQ
cache-aside أم write-through؟ cache-aside لمعظم الحالات. write-through للكائنات المقروءة كثيرًا والمكتوبة نادرًا حيث الاتّساق القوي حرج. write-behind خطير: crash Redis قبل التثبيت يعني فقدان البيانات.
أيّ TTL أُطَبِّق؟ يعتمد على المجال. كائنات نادرة التغيير (كتالوج، profiles): 1 إلى 24 ساعة. كائنات متغيّرة (سلال، sessions): 5 إلى 60 دقيقة. كائنات شبه ثابتة: بلا TTL مع إبطال صريح.
كيف نُبطل عدّة مفاتيح في مرّة؟ إمّا tags، أو patterns عبر SCAN + DEL في حلقة (أبدًا KEYS). في Redis 8، UNLINK يحذف غير متزامن.
JSON أم ثنائي؟ JSON يكفي للأغلبية الساحقة — سهل التنقيح. لكائنات كبيرة (> 100 KB) أو حركة ضخمة، MessagePack أو Protocol Buffers يُقَلِّلان بـ 30 إلى 50%.
مراجع
- Patterns Redis — التوثيق الرسمي
- ioredis على GitHub
- أمر SET مع NX، XX، EX، PX