استراتيجيات التخزين المؤقت في Redis التي تعمل فعلاً في الإنتاج
Cache-aside، وwrite-through، ومنع cache stampede، واستراتيجيات TTL، وأنماط الإبطال. أنماط Redis التي استخدمتها في تطبيقات Node.js الإنتاجية مع أمثلة كود حقيقية.
الجميع يخبرك بأن "تضيف Redis فحسب" عندما يكون الـ API بطيئاً. لا أحد يخبرك بما يحدث بعد ستة أشهر عندما تقدّم ذاكرة التخزين المؤقت بيانات قديمة، ومنطق الإبطال مبعثر عبر 40 ملفاً، ويتسبب النشر في تكالب على ذاكرة التخزين المؤقت يُسقط قاعدة بياناتك بشكل أسوأ مما لو لم تستخدم التخزين المؤقت أصلاً.
أدير Redis في بيئة الإنتاج منذ سنوات. ليس كلعبة، ولا في درس تعليمي — في أنظمة تتعامل مع حركة مرور حقيقية حيث الخطأ في التخزين المؤقت يعني تنبيهات في الساعة الثالثة فجراً. ما يلي هو كل ما تعلمته عن كيفية القيام بذلك بشكل صحيح.
لماذا التخزين المؤقت؟#
لنبدأ بالواضح: قواعد البيانات بطيئة نسبياً مقارنة بالذاكرة. استعلام PostgreSQL الذي يستغرق 15 مللي ثانية يُعتبر سريعاً بمعايير قواعد البيانات. لكن إذا كان هذا الاستعلام يعمل مع كل طلب API، وأنت تتعامل مع 1,000 طلب في الثانية، فهذا يعني 15,000 مللي ثانية من الوقت التراكمي لقاعدة البيانات في الثانية. مجمّع الاتصالات مستنفد. زمن الاستجابة p99 خارج السيطرة. المستخدمون يحدّقون في مؤشرات التحميل.
Redis تخدم معظم عمليات القراءة في أقل من مللي ثانية واحدة. نفس البيانات، مخزّنة مؤقتاً، تحوّل عملية تستغرق 15 مللي ثانية إلى عملية تستغرق 0.3 مللي ثانية. هذا ليس تحسيناً طفيفاً. هذا هو الفرق بين الحاجة إلى 4 نسخ من قاعدة البيانات وعدم الحاجة لأي نسخة.
لكن التخزين المؤقت ليس مجانياً. فهو يضيف تعقيداً، ويُدخل مشاكل اتساق، ويخلق فئة جديدة تماماً من أنماط الأعطال. قبل أن تخزّن أي شيء مؤقتاً، اسأل نفسك:
متى يساعد التخزين المؤقت:
- البيانات تُقرأ أكثر بكثير مما تُكتب (نسبة 10:1 أو أعلى)
- الاستعلام الأساسي مكلف (عمليات ربط، تجميعات، استدعاءات API خارجية)
- القليل من البيانات القديمة مقبول (كتالوج المنتجات، ملفات المستخدمين، الإعدادات)
- لديك أنماط وصول متوقعة (نفس المفاتيح تُطلب بشكل متكرر)
متى يضرّ التخزين المؤقت:
- البيانات تتغير باستمرار ويجب أن تكون محدّثة (أسعار الأسهم الآنية، النتائج المباشرة)
- كل طلب فريد (استعلامات بحث بمعاملات كثيرة)
- مجموعة بياناتك صغيرة (إذا كانت كلها تتسع في ذاكرة تطبيقك، تخطّ Redis)
- ليس لديك النضج التشغيلي لمراقبة وتصحيح مشاكل ذاكرة التخزين المؤقت
قال Phil Karlton عبارته الشهيرة أن هناك شيئين صعبين فقط في علوم الحاسوب: إبطال ذاكرة التخزين المؤقت وتسمية الأشياء. كان محقاً في كليهما، لكن إبطال ذاكرة التخزين المؤقت هو ما يوقظك ليلاً.
إعداد ioredis#
قبل أن نغوص في الأنماط، لنؤسس الاتصال. أستخدم ioredis في كل مكان — وهو أنضج عميل Redis لـ Node.js، مع دعم TypeScript مناسب، ووضع المجموعات، ودعم Sentinel، وبرمجة Lua.
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: Number(process.env.REDIS_DB) || 0,
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 200, 5000);
return delay;
},
lazyConnect: true,
enableReadyCheck: true,
connectTimeout: 10000,
});
redis.on("error", (err) => {
console.error("[Redis] Connection error:", err.message);
});
redis.on("connect", () => {
console.log("[Redis] Connected");
});
export default redis;بعض الأشياء الجديرة بالملاحظة. lazyConnect: true يعني أن الاتصال لا يُنشأ حتى تنفّذ أمراً فعلياً، وهو مفيد أثناء الاختبار والتهيئة. retryStrategy ينفّذ تراجعاً أسّياً مع حد أقصى 5 ثوانٍ — بدون هذا، يتسبب انقطاع Redis في إرسال تطبيقك لمحاولات إعادة اتصال مكثفة. وmaxRetriesPerRequest: 3 يضمن فشل الأوامر الفردية بسرعة بدلاً من التعليق إلى الأبد.
نمط Cache-Aside#
هذا هو النمط الذي ستستخدمه 80% من الوقت. يُسمى أيضاً "التحميل الكسول" أو "look-aside". التدفق بسيط:
- يستقبل التطبيق طلباً
- يتحقق من Redis بحثاً عن القيمة المخزّنة مؤقتاً
- إذا وُجدت (إصابة ذاكرة التخزين المؤقت)، يُرجعها
- إذا لم تُوجد (فقدان ذاكرة التخزين المؤقت)، يستعلم قاعدة البيانات
- يخزّن النتيجة في Redis
- يُرجع النتيجة
إليك تنفيذاً مُنمّطاً:
import redis from "./redis";
interface CacheOptions {
ttl?: number; // seconds
prefix?: string;
}
async function cacheAside<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions = {}
): Promise<T> {
const { ttl = 3600, prefix = "cache" } = options;
const cacheKey = `${prefix}:${key}`;
// Step 1: Try to read from cache
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
return JSON.parse(cached) as T;
} catch {
// Corrupted cache entry, delete it and fall through
await redis.del(cacheKey);
}
}
// Step 2: Cache miss — fetch from source
const result = await fetcher();
// Step 3: Store in cache (don't await — fire and forget)
redis
.set(cacheKey, JSON.stringify(result), "EX", ttl)
.catch((err) => {
console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
});
return result;
}الاستخدام يبدو هكذا:
interface User {
id: string;
name: string;
email: string;
plan: "free" | "pro" | "enterprise";
}
async function getUser(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{ ttl: 1800 } // 30 minutes
);
}لاحظ أنني أطلق استدعاء redis.set بدون انتظار (fire-and-forget). هذا مقصود. إذا كانت Redis معطّلة أو بطيئة، يكتمل الطلب على أي حال. ذاكرة التخزين المؤقت هي تحسين، وليست متطلباً. إذا فشلت الكتابة إلى ذاكرة التخزين المؤقت، سيستعلم الطلب التالي قاعدة البيانات مرة أخرى. لا مشكلة.
هناك خلل خفي في كثير من تنفيذات cache-aside يفوّته الناس: تخزين القيم الفارغة مؤقتاً. إذا كان المستخدم غير موجود ولم تخزّن هذه الحقيقة مؤقتاً، فكل طلب لهذا المستخدم يستعلم قاعدة البيانات. يمكن للمهاجم استغلال هذا بطلب معرّفات مستخدمين عشوائية، محوّلاً ذاكرة التخزين المؤقت إلى لا شيء. خزّن النتيجة السلبية أيضاً دائماً — فقط مع TTL أقصر.
async function getUserSafe(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{
// Shorter TTL for null results to limit memory usage
// but long enough to absorb repeated misses
ttl: row ? 1800 : 300,
}
);
}في الواقع، دعني أعيد هيكلة ذلك ليعمل TTL الديناميكي بشكل صحيح:
async function getUserWithDynamicTTL(userId: string): Promise<User | null> {
const cacheKey = `cache:user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as User | null;
}
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
const user: User | null = row[0] ?? null;
// Cache exists results for 30 min, null results for 5 min
const ttl = user ? 1800 : 300;
await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
return user;
}Write-Through و Write-Behind#
نمط cache-aside يعمل بشكل رائع لأحمال العمل كثيفة القراءة، لكن لديه مشكلة اتساق: إذا حدّثت خدمة أو عملية أخرى قاعدة البيانات مباشرة، تكون ذاكرة التخزين المؤقت قديمة حتى تنتهي صلاحية TTL. هنا يأتي نمطا write-through و write-behind.
Write-Through#
في write-through، تمر كل عملية كتابة عبر طبقة ذاكرة التخزين المؤقت. تُحدَّث ذاكرة التخزين المؤقت أولاً، ثم قاعدة البيانات. هذا يضمن أن ذاكرة التخزين المؤقت متسقة دائماً مع قاعدة البيانات (بافتراض أن عمليات الكتابة تمر دائماً عبر تطبيقك).
async function updateUser(
userId: string,
updates: Partial<User>
): Promise<User> {
// Step 1: Update the database
const updated = await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *",
[userId, updates.name, updates.email]
);
const user: User = updated[0];
// Step 2: Update the cache immediately
const cacheKey = `cache:user:${userId}`;
await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
return user;
}الفرق الرئيسي عن cache-aside: نكتب إلى ذاكرة التخزين المؤقت مع كل عملية كتابة، وليس فقط عند القراءة. هذا يعني أن ذاكرة التخزين المؤقت دائماً محدّثة للبيانات المحدّثة مؤخراً.
المقايضة: يزداد زمن استجابة الكتابة لأن كل عملية كتابة الآن تلمس كلاً من قاعدة البيانات و Redis. إذا كانت Redis بطيئة، تكون عمليات الكتابة بطيئة. في معظم التطبيقات، تفوق عمليات القراءة عمليات الكتابة بكثير، لذا هذه المقايضة تستحق.
Write-Behind (Write-Back)#
نمط write-behind يقلب النص: تذهب عمليات الكتابة إلى Redis أولاً، وتُحدَّث قاعدة البيانات بشكل غير متزامن. هذا يمنحك عمليات كتابة سريعة للغاية مقابل احتمال فقدان البيانات إذا تعطّلت Redis قبل حفظ البيانات.
async function updateUserWriteBehind(
userId: string,
updates: Partial<User>
): Promise<User> {
const cacheKey = `cache:user:${userId}`;
// Read current state
const current = await redis.get(cacheKey);
const user = current ? JSON.parse(current) as User : null;
if (!user) throw new Error("User not in cache");
// Update cache immediately
const updated = { ...user, ...updates };
await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
// Queue database write for async processing
await redis.rpush(
"write_behind:users",
JSON.stringify({ userId, updates, timestamp: Date.now() })
);
return updated;
}ستحتاج بعد ذلك إلى عامل منفصل يستنزف تلك القائمة:
async function processWriteBehindQueue(): Promise<void> {
while (true) {
const item = await redis.blpop("write_behind:users", 5);
if (item) {
const { userId, updates } = JSON.parse(item[1]);
try {
await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1",
[userId, updates.name, updates.email]
);
} catch (err) {
// Re-queue on failure with retry count
console.error("[WriteBehind] Failed:", err);
await redis.rpush("write_behind:users:dlq", item[1]);
}
}
}
}نادراً ما أستخدم write-behind عملياً. خطر فقدان البيانات حقيقي — إذا تعطّلت Redis قبل أن يعالج العامل القائمة، تضيع تلك عمليات الكتابة. استخدم هذا فقط للبيانات حيث الاتساق النهائي مقبول فعلاً، مثل عدّادات المشاهدات، أو أحداث التحليلات، أو تفضيلات المستخدم غير الحرجة.
استراتيجية TTL#
الحصول على TTL الصحيح أكثر دقة مما يبدو. TTL ثابت لمدة ساعة واحدة على كل شيء سهل التنفيذ وخاطئ دائماً تقريباً.
مستويات تقلّب البيانات#
أصنّف البيانات إلى ثلاث مستويات وأعيّن TTL وفقاً لذلك:
const TTL = {
// Tier 1: Rarely changes, expensive to compute
// Examples: product catalog, site config, feature flags
STATIC: 86400, // 24 hours
// Tier 2: Changes occasionally, moderate cost
// Examples: user profiles, team settings, permissions
MODERATE: 1800, // 30 minutes
// Tier 3: Changes frequently, cheap to compute but called often
// Examples: feed data, notification counts, session info
VOLATILE: 300, // 5 minutes
// Tier 4: Ephemeral, used for rate limiting and locks
EPHEMERAL: 60, // 1 minute
// Null results: always short-lived
NOT_FOUND: 120, // 2 minutes
} as const;تذبذب TTL: منع القطيع الهائج#
إليك سيناريو عضّني: تنشر تطبيقك، ذاكرة التخزين المؤقت فارغة، و10,000 طلب يخزّنون جميعاً نفس البيانات مع TTL لمدة ساعة. بعد ساعة واحدة، تنتهي صلاحية جميع المفاتيح الـ 10,000 في نفس الوقت. جميع الطلبات الـ 10,000 تستعلم قاعدة البيانات دفعة واحدة. قاعدة البيانات تختنق. لقد رأيت هذا يُسقط مثيل Postgres إنتاجي.
الحل هو التذبذب — إضافة عشوائية لقيم TTL:
function ttlWithJitter(baseTtl: number, jitterPercent = 0.1): number {
const jitter = baseTtl * jitterPercent;
const offset = Math.random() * jitter * 2 - jitter;
return Math.max(1, Math.round(baseTtl + offset));
}
// Instead of: redis.set(key, value, "EX", 3600)
// Use: redis.set(key, value, "EX", ttlWithJitter(3600))
// 3600 ± 10% = random value between 3240 and 3960هذا يوزّع انتهاء الصلاحية عبر نافذة زمنية، فبدلاً من 10,000 مفتاح تنتهي صلاحيتها في نفس الثانية، تنتهي صلاحيتها خلال نافذة 12 دقيقة. قاعدة البيانات ترى زيادة تدريجية في الحركة، وليس جرفاً.
للمسارات الحرجة، أذهب أبعد وأستخدم تذبذباً بنسبة 20%:
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 secondsانتهاء الصلاحية المنزلق#
لبيانات شبيهة بالجلسات حيث يجب إعادة تعيين TTL مع كل وصول، استخدم GETEX (Redis 6.2+):
async function getWithSlidingExpiry<T>(
key: string,
ttl: number
): Promise<T | null> {
// GETEX atomically gets the value AND resets the TTL
const value = await redis.getex(key, "EX", ttl);
if (value === null) return null;
return JSON.parse(value) as T;
}إذا كنت على إصدار أقدم من Redis، استخدم خط أنابيب:
async function getWithSlidingExpiryCompat<T>(
key: string,
ttl: number
): Promise<T | null> {
const pipeline = redis.pipeline();
pipeline.get(key);
pipeline.expire(key, ttl);
const results = await pipeline.exec();
if (!results || !results[0] || results[0][1] === null) return null;
return JSON.parse(results[0][1] as string) as T;
}تكالب ذاكرة التخزين المؤقت (القطيع الهائج)#
تذبذب TTL يساعد مع انتهاء الصلاحية الجماعي، لكنه لا يحل تكالب المفتاح الواحد: عندما تنتهي صلاحية مفتاح شائع ومئات الطلبات المتزامنة تحاول جميعها إعادة توليده في نفس الوقت.
تخيّل أنك تخزّن موجز صفحتك الرئيسية مؤقتاً مع TTL لمدة 5 دقائق. تنتهي صلاحيته. خمسون طلباً متزامناً يرون فقدان ذاكرة التخزين المؤقت. جميع الخمسين يستعلمون قاعدة البيانات بنفس الاستعلام المكلف. لقد هاجمت نفسك بهجوم حرمان خدمة.
الحل 1: قفل Mutex#
طلب واحد فقط يعيد توليد ذاكرة التخزين المؤقت. الجميع ينتظرون.
async function cacheAsideWithMutex<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const lockKey = `lock:${key}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as T;
}
// Try to acquire lock (NX = only if not exists, EX = auto-expire)
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (acquired) {
try {
// We got the lock — fetch and cache
const result = await fetcher();
await redis.set(
cacheKey,
JSON.stringify(result),
"EX",
ttlWithJitter(ttl)
);
return result;
} finally {
// Release lock
await redis.del(lockKey);
}
}
// Another request holds the lock — wait and retry
await sleep(100);
const retried = await redis.get(cacheKey);
if (retried !== null) {
return JSON.parse(retried) as T;
}
// Still no cache — fall through to database
// (this handles the case where the lock holder failed)
return fetcher();
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}هناك حالة سباق خفية في تحرير القفل أعلاه. إذا استغرق حامل القفل أكثر من 10 ثوانٍ (TTL القفل)، يحصل طلب آخر على القفل، ثم يحذف الطلب الأول قفل الطلب الثاني. الحل الصحيح هو استخدام رمز فريد:
import { randomUUID } from "crypto";
async function acquireLock(
lockKey: string,
ttl: number
): Promise<string | null> {
const token = randomUUID();
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
return acquired ? token : null;
}
async function releaseLock(lockKey: string, token: string): Promise<boolean> {
// Lua script ensures atomic check-and-delete
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await redis.eval(script, 1, lockKey, token);
return result === 1;
}هذا في الأساس Redlock مبسّط. لمثيل Redis واحد، هذا كافٍ. لإعدادات Redis Cluster أو Sentinel، ابحث عن خوارزمية Redlock الكاملة — لكن بصراحة، لمنع تكالب ذاكرة التخزين المؤقت، هذا الإصدار البسيط يعمل بشكل جيد.
الحل 2: انتهاء الصلاحية المبكر الاحتمالي#
هذا هو نهجي المفضل. بدلاً من انتظار انتهاء صلاحية المفتاح، أعد توليده عشوائياً قبل انتهاء الصلاحية بقليل. الفكرة تأتي من ورقة بحثية لـ Vattani و Chierichetti و Lowenstein.
interface CachedValue<T> {
data: T;
cachedAt: number;
ttl: number;
}
async function cacheWithEarlyExpiration<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T> {
const cacheKey = `cache:${key}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
const entry = JSON.parse(cached) as CachedValue<T>;
const age = (Date.now() - entry.cachedAt) / 1000;
const remaining = entry.ttl - age;
// XFetch algorithm: probabilistically regenerate as expiry approaches
// beta * Math.log(Math.random()) produces a negative number
// that grows larger (more negative) as expiry approaches
const beta = 1; // tuning parameter, 1 works well
const shouldRegenerate =
remaining - beta * Math.log(Math.random()) * -1 <= 0;
if (!shouldRegenerate) {
return entry.data;
}
// Fall through to regenerate
console.log(`[Cache] Early regeneration triggered for ${key}`);
}
const data = await fetcher();
const entry: CachedValue<T> = {
data,
cachedAt: Date.now(),
ttl,
};
// Set with extra buffer so Redis doesn't expire before we can regenerate
await redis.set(
cacheKey,
JSON.stringify(entry),
"EX",
Math.round(ttl * 1.1)
);
return data;
}جمال هذا النهج: كلما انخفض TTL المتبقي للمفتاح، تزداد احتمالية إعادة التوليد. مع 1,000 طلب متزامن، ربما واحد أو اثنان يُطلقان إعادة التوليد بينما يستمر الباقي في تقديم البيانات المخزّنة مؤقتاً. لا أقفال، لا تنسيق، لا انتظار.
الحل 3: Stale-While-Revalidate#
قدّم القيمة القديمة أثناء إعادة التوليد في الخلفية. هذا يعطي أفضل زمن استجابة لأنه لا يوجد طلب ينتظر أبداً لجلب البيانات.
async function staleWhileRevalidate<T>(
key: string,
fetcher: () => Promise<T>,
options: {
freshTtl: number; // how long the data is "fresh"
staleTtl: number; // how long stale data can be served
}
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const metaKey = `meta:${key}`;
const [cached, meta] = await redis.mget(cacheKey, metaKey);
if (cached !== null) {
const parsedMeta = meta ? JSON.parse(meta) : null;
const isFresh =
parsedMeta && Date.now() - parsedMeta.cachedAt < options.freshTtl * 1000;
if (!isFresh) {
// Data is stale — serve it but trigger background refresh
revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
}
return JSON.parse(cached) as T;
}
// Complete cache miss — must fetch synchronously
return fetchAndCache(key, cacheKey, metaKey, fetcher, options);
}
async function fetchAndCache<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): Promise<T> {
const data = await fetcher();
const totalTtl = options.freshTtl + options.staleTtl;
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(data), "EX", totalTtl);
pipeline.set(
metaKey,
JSON.stringify({ cachedAt: Date.now() }),
"EX",
totalTtl
);
await pipeline.exec();
return data;
}
function revalidateInBackground<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): void {
// Use a lock to prevent multiple background refreshes
const lockKey = `revalidate_lock:${key}`;
redis
.set(lockKey, "1", "EX", 30, "NX")
.then((acquired) => {
if (!acquired) return;
return fetchAndCache(key, cacheKey, metaKey, fetcher, options)
.finally(() => redis.del(lockKey));
})
.catch((err) => {
console.error(`[SWR] Background revalidation failed for ${key}:`, err);
});
}الاستخدام:
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
freshTtl: 300, // 5 minutes fresh
staleTtl: 3600, // serve stale for up to 1 hour while revalidating
});أستخدم هذا النمط لأي شيء يواجه المستخدم حيث زمن الاستجابة أهم من الحداثة المطلقة. بيانات لوحات التحكم، صفحات الملفات الشخصية، قوائم المنتجات — كلها مرشحات مثالية.
إبطال ذاكرة التخزين المؤقت#
Phil Karlton لم يكن يمزح. الإبطال هو حيث ينتقل التخزين المؤقت من "تحسين سهل" إلى "مشكلة أنظمة موزّعة".
إبطال بسيط قائم على المفاتيح#
الحالة الأسهل: عندما تحدّث مستخدماً، احذف مفتاح ذاكرة التخزين المؤقت الخاص به.
async function updateUserAndInvalidate(
userId: string,
updates: Partial<User>
): Promise<User> {
const user = await db.query(
"UPDATE users SET name = $2 WHERE id = $1 RETURNING *",
[userId, updates.name]
);
// Invalidate the cache
await redis.del(`cache:user:${userId}`);
return user[0];
}هذا يعمل حتى تظهر بيانات المستخدم في نتائج مخزّنة مؤقتاً أخرى. ربما تكون مضمّنة في قائمة أعضاء الفريق. ربما في نتيجة بحث. ربما في 14 استجابة API مخزّنة مؤقتاً مختلفة. الآن تحتاج لتتبع أي مفاتيح ذاكرة تخزين مؤقت تحتوي على أي كيانات.
إبطال قائم على الوسوم#
ضع وسوماً على مدخلات ذاكرة التخزين المؤقت بالكيانات التي تحتويها، ثم أبطل بالوسم.
async function setWithTags<T>(
key: string,
value: T,
ttl: number,
tags: string[]
): Promise<void> {
const pipeline = redis.pipeline();
// Store the value
pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
// Add the key to each tag's set
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, `cache:${key}`);
pipeline.expire(`tag:${tag}`, ttl + 3600); // Tag sets live longer than values
}
await pipeline.exec();
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}الاستخدام:
// When caching team data, tag it with all member IDs
const team = await fetchTeam(teamId);
await setWithTags(
`team:${teamId}`,
team,
1800,
[
`entity:team:${teamId}`,
...team.members.map((m) => `entity:user:${m.id}`),
]
);
// When user 42 updates their profile, invalidate everything that contains them
await invalidateByTag("entity:user:42");إبطال مدفوع بالأحداث#
للأنظمة الأكبر، استخدم Redis Pub/Sub لبث أحداث الإبطال:
// Publisher (in your API service)
async function publishInvalidation(
entityType: string,
entityId: string
): Promise<void> {
await redis.publish(
"cache:invalidate",
JSON.stringify({ entityType, entityId, timestamp: Date.now() })
);
}
// Subscriber (in each app instance)
const subscriber = new Redis(/* same config */);
subscriber.subscribe("cache:invalidate", (err) => {
if (err) console.error("[PubSub] Subscribe error:", err);
});
subscriber.on("message", async (_channel, message) => {
const { entityType, entityId } = JSON.parse(message);
await invalidateByTag(`entity:${entityType}:${entityId}`);
console.log(`[Cache] Invalidated ${entityType}:${entityId}`);
});هذا حاسم في نشرات متعددة المثيلات. إذا كان لديك 4 خوادم تطبيقات خلف موزّع أحمال، يحتاج الإبطال على الخادم 1 للانتشار إلى جميع الخوادم. Pub/Sub يتعامل مع هذا تلقائياً.
إبطال قائم على الأنماط (بحذر)#
أحياناً تحتاج لإبطال جميع المفاتيح التي تطابق نمطاً. لا تستخدم KEYS أبداً في الإنتاج. فهو يحجب خادم Redis أثناء مسح فضاء المفاتيح بالكامل. مع ملايين المفاتيح، يمكن أن يحجب لثوانٍ — وهي دهر بمعايير Redis.
استخدم SCAN بدلاً من ذلك:
async function invalidateByPattern(pattern: string): Promise<number> {
let cursor = "0";
let deletedCount = 0;
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
deletedCount += keys.length;
}
} while (cursor !== "0");
return deletedCount;
}
// Invalidate all cached data for a specific team
await invalidateByPattern("cache:team:42:*");SCAN يتكرر بشكل تدريجي — لا يحجب الخادم أبداً. تلميح COUNT يقترح عدد المفاتيح التي يُعيدها في كل تكرار (إنه تلميح وليس ضماناً). لفضاءات المفاتيح الكبيرة، هذا هو النهج الآمن الوحيد.
مع ذلك، الإبطال القائم على الأنماط هو علامة تحذيرية في الكود. إذا وجدت نفسك تمسح بشكل متكرر، أعد تصميم هيكل مفاتيحك أو استخدم الوسوم. SCAN هو O(N) عبر فضاء المفاتيح ومصمّم لعمليات الصيانة، وليس المسارات الحرجة.
هياكل البيانات ما وراء النصوص#
معظم المطورين يتعاملون مع Redis كمخزن مفتاح-قيمة لسلاسل JSON. هذا مثل شراء سكين سويسري واستخدام فتّاحة الزجاجات فقط. Redis لديها هياكل بيانات غنية، واختيار الصحيح يمكن أن يزيل فئات كاملة من التعقيد.
التجزئات للكائنات#
بدلاً من تسلسل كائن كامل كـ JSON، خزّنه كتجزئة Redis. هذا يتيح لك قراءة وتحديث حقول فردية دون إلغاء تسلسل الكل.
// Store user as a hash
async function setUserHash(user: User): Promise<void> {
const key = `user:${user.id}`;
await redis.hset(key, {
name: user.name,
email: user.email,
plan: user.plan,
updatedAt: Date.now().toString(),
});
await redis.expire(key, 1800);
}
// Read specific fields
async function getUserPlan(userId: string): Promise<string | null> {
return redis.hget(`user:${userId}`, "plan");
}
// Update a single field
async function upgradeUserPlan(
userId: string,
plan: string
): Promise<void> {
await redis.hset(`user:${userId}`, "plan", plan);
}
// Read entire hash as object
async function getUserHash(userId: string): Promise<User | null> {
const data = await redis.hgetall(`user:${userId}`);
if (!data || Object.keys(data).length === 0) return null;
return {
id: userId,
name: data.name,
email: data.email,
plan: data.plan as User["plan"],
};
}التجزئات موفّرة للذاكرة للكائنات الصغيرة (Redis تستخدم ترميز ziplist مضغوط داخلياً) وتتجنب عبء التسلسل/إلغاء التسلسل. المقايضة: تفقد القدرة على تخزين كائنات متداخلة دون تسطيحها أولاً.
المجموعات المرتّبة للوحات المتصدرين وتحديد المعدل#
المجموعات المرتّبة هي أكثر هياكل البيانات التي لا تحظى بالتقدير في Redis. كل عضو له نتيجة، والمجموعة مرتّبة دائماً حسب النتيجة. هذا يجعلها مثالية للوحات المتصدرين، والترتيب، وتحديد المعدل بالنافذة المنزلقة.
// Leaderboard
async function addScore(
leaderboard: string,
userId: string,
score: number
): Promise<void> {
await redis.zadd(leaderboard, score, userId);
}
async function getTopPlayers(
leaderboard: string,
count: number = 10
): Promise<Array<{ userId: string; score: number }>> {
const results = await redis.zrevrange(
leaderboard,
0,
count - 1,
"WITHSCORES"
);
const players: Array<{ userId: string; score: number }> = [];
for (let i = 0; i < results.length; i += 2) {
players.push({
userId: results[i],
score: parseFloat(results[i + 1]),
});
}
return players;
}
async function getUserRank(
leaderboard: string,
userId: string
): Promise<number | null> {
const rank = await redis.zrevrank(leaderboard, userId);
return rank !== null ? rank + 1 : null; // 0-indexed to 1-indexed
}لتحديد المعدل بالنافذة المنزلقة:
async function slidingWindowRateLimit(
identifier: string,
windowMs: number,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number }> {
const key = `ratelimit:${identifier}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = redis.pipeline();
// Remove entries outside the window
pipeline.zremrangebyscore(key, 0, windowStart);
// Add current request
pipeline.zadd(key, now, `${now}:${Math.random()}`);
// Count requests in window
pipeline.zcard(key);
// Set expiry on the whole key
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results?.[2]?.[1] as number;
return {
allowed: count <= maxRequests,
remaining: Math.max(0, maxRequests - count),
};
}هذا أكثر دقة من نهج العدّاد بالنافذة الثابتة ولا يعاني من مشكلة الحدود حيث يُضاعف الاندفاع في نهاية نافذة وبداية التالية حدّ المعدل فعلياً.
القوائم للطوابير#
قوائم Redis مع LPUSH/BRPOP تصنع طوابير مهام خفيفة ممتازة:
interface Job {
id: string;
type: string;
payload: Record<string, unknown>;
createdAt: number;
}
// Producer
async function enqueueJob(
queue: string,
type: string,
payload: Record<string, unknown>
): Promise<string> {
const job: Job = {
id: randomUUID(),
type,
payload,
createdAt: Date.now(),
};
await redis.lpush(`queue:${queue}`, JSON.stringify(job));
return job.id;
}
// Consumer (blocks until a job is available)
async function dequeueJob(
queue: string,
timeout: number = 5
): Promise<Job | null> {
const result = await redis.brpop(`queue:${queue}`, timeout);
if (!result) return null;
return JSON.parse(result[1]) as Job;
}لأي شيء أكثر تعقيداً من الطوابير الأساسية (إعادة المحاولات، طوابير الرسائل الميتة، الأولوية، المهام المؤجّلة)، استخدم BullMQ الذي يُبنى على Redis لكنه يتعامل مع جميع الحالات الحدية.
المجموعات للتتبع الفريد#
تحتاج لتتبع الزوار الفريدين، أو إلغاء تكرار الأحداث، أو التحقق من العضوية؟ المجموعات هي O(1) للإضافة، والإزالة، والتحقق من العضوية.
// Track unique visitors per day
async function trackVisitor(
page: string,
visitorId: string
): Promise<boolean> {
const key = `visitors:${page}:${new Date().toISOString().split("T")[0]}`;
const isNew = await redis.sadd(key, visitorId);
// Auto-expire after 48 hours
await redis.expire(key, 172800);
return isNew === 1; // 1 = new member, 0 = already existed
}
// Get unique visitor count
async function getUniqueVisitors(page: string, date: string): Promise<number> {
return redis.scard(`visitors:${page}:${date}`);
}
// Check if user has already performed an action
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}للمجموعات الكبيرة جداً (ملايين الأعضاء)، فكّر في HyperLogLog بدلاً من ذلك. يستخدم فقط 12 كيلوبايت من الذاكرة بغض النظر عن العدد الأصلي، مقابل خطأ معياري ~0.81%:
// HyperLogLog for approximate unique counts
async function trackVisitorApprox(
page: string,
visitorId: string
): Promise<void> {
const key = `hll:visitors:${page}:${new Date().toISOString().split("T")[0]}`;
await redis.pfadd(key, visitorId);
await redis.expire(key, 172800);
}
async function getApproxUniqueVisitors(
page: string,
date: string
): Promise<number> {
return redis.pfcount(`hll:visitors:${page}:${date}`);
}التسلسل: JSON مقابل MessagePack#
JSON هو الخيار الافتراضي لتسلسل Redis. قابل للقراءة، وعالمي، وكافٍ لمعظم الحالات. لكن للأنظمة عالية الإنتاجية، يتراكم عبء التسلسل/إلغاء التسلسل.
مشكلة JSON#
const user = {
id: "usr_abc123",
name: "Ahmet Kousa",
email: "ahmet@example.com",
plan: "pro",
preferences: {
theme: "dark",
language: "tr",
notifications: true,
},
};
// JSON: 189 bytes
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
// JSON.parse on a hot path: ~0.02ms per call
// At 10,000 requests/sec: 200ms total CPU time per secondبديل MessagePack#
MessagePack هو تنسيق تسلسل ثنائي أصغر وأسرع من JSON:
npm install msgpackrimport { pack, unpack } from "msgpackr";
// MessagePack: ~140 bytes (25% smaller)
const packed = pack(user);
console.log(packed.length); // ~140
// Store as Buffer
await redis.set("user:123", packed);
// Read as Buffer
const raw = await redis.getBuffer("user:123");
if (raw) {
const data = unpack(raw);
}لاحظ استخدام getBuffer بدلاً من get — هذا حرج. get يُعيد نصاً ويُفسد البيانات الثنائية.
الضغط للقيم الكبيرة#
للقيم المخزّنة مؤقتاً الكبيرة (استجابات API بمئات العناصر، HTML مُصيّر)، أضف ضغطاً:
import { promisify } from "util";
import { gzip, gunzip } from "zlib";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
async function setCompressed<T>(
key: string,
value: T,
ttl: number
): Promise<void> {
const json = JSON.stringify(value);
// Only compress if larger than 1KB (compression overhead isn't worth it for small values)
if (Buffer.byteLength(json) > 1024) {
const compressed = await gzipAsync(json);
await redis.set(key, compressed, "EX", ttl);
} else {
await redis.set(key, json, "EX", ttl);
}
}
async function getCompressed<T>(key: string): Promise<T | null> {
const raw = await redis.getBuffer(key);
if (!raw) return null;
try {
// Try to decompress first
const decompressed = await gunzipAsync(raw);
return JSON.parse(decompressed.toString()) as T;
} catch {
// Not compressed, parse as regular JSON
return JSON.parse(raw.toString()) as T;
}
}في اختباراتي، ضغط gzip عادة يقلّل حجم حمولة JSON بنسبة 70-85%. استجابة API بحجم 50 كيلوبايت تصبح 8 كيلوبايت. هذا مهم عندما تدفع مقابل ذاكرة Redis — ذاكرة أقل لكل مفتاح يعني مفاتيح أكثر في نفس المثيل.
المقايضة: الضغط يضيف 1-3 مللي ثانية من وقت المعالج لكل عملية. لمعظم التطبيقات، هذا لا يُذكر. للمسارات فائقة السرعة، تخطّاه.
توصيتي#
استخدم JSON ما لم يُظهر التحليل أنه عنق زجاجة. قابلية قراءة JSON وقابلية تصحيحه في Redis (يمكنك تنفيذ redis-cli GET key وقراءة القيمة فعلاً) تفوق مكسب الأداء لـ MessagePack لـ 95% من التطبيقات. أضف الضغط فقط للقيم الأكبر من 1 كيلوبايت.
Redis في Next.js#
Next.js لديه قصته الخاصة مع التخزين المؤقت (ذاكرة التخزين المؤقت للبيانات، ذاكرة التخزين المؤقت الكاملة للمسارات، إلخ)، لكن Redis تملأ الفجوات التي لا يستطيع التخزين المؤقت المدمج التعامل معها — خاصة عندما تحتاج لمشاركة ذاكرة التخزين المؤقت عبر مثيلات متعددة أو الحفاظ على ذاكرة التخزين المؤقت عبر عمليات النشر.
تخزين استجابات مسارات API مؤقتاً#
// app/api/products/route.ts
import { NextResponse } from "next/server";
import redis from "@/lib/redis";
export async function GET(request: Request) {
const url = new URL(request.url);
const category = url.searchParams.get("category") || "all";
const cacheKey = `api:products:${category}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return NextResponse.json(JSON.parse(cached), {
headers: {
"X-Cache": "HIT",
"Cache-Control": "public, s-maxage=60",
},
});
}
// Fetch from database
const products = await db.products.findMany({
where: category !== "all" ? { category } : undefined,
orderBy: { createdAt: "desc" },
take: 50,
});
// Cache for 5 minutes with jitter
await redis.set(
cacheKey,
JSON.stringify(products),
"EX",
ttlWithJitter(300)
);
return NextResponse.json(products, {
headers: {
"X-Cache": "MISS",
"Cache-Control": "public, s-maxage=60",
},
});
}ترويسة X-Cache لا تُقدّر بثمن لتصحيح الأخطاء. عندما يرتفع زمن الاستجابة، استدعاء curl -I سريع يخبرك ما إذا كانت ذاكرة التخزين المؤقت تعمل.
تخزين الجلسات#
Next.js مع Redis للجلسات يتفوق على JWT للتطبيقات التي تحتفظ بالحالة:
// lib/session.ts
import { randomUUID } from "crypto";
import redis from "./redis";
interface Session {
userId: string;
role: string;
createdAt: number;
data: Record<string, unknown>;
}
const SESSION_TTL = 86400; // 24 hours
const SESSION_PREFIX = "session:";
export async function createSession(
userId: string,
role: string
): Promise<string> {
const sessionId = randomUUID();
const session: Session = {
userId,
role,
createdAt: Date.now(),
data: {},
};
await redis.set(
`${SESSION_PREFIX}${sessionId}`,
JSON.stringify(session),
"EX",
SESSION_TTL
);
return sessionId;
}
export async function getSession(
sessionId: string
): Promise<Session | null> {
const key = `${SESSION_PREFIX}${sessionId}`;
// Use GETEX to refresh TTL on every access (sliding expiry)
const raw = await redis.getex(key, "EX", SESSION_TTL);
if (!raw) return null;
return JSON.parse(raw) as Session;
}
export async function destroySession(sessionId: string): Promise<void> {
await redis.del(`${SESSION_PREFIX}${sessionId}`);
}
// Destroy all sessions for a user (useful for "logout everywhere")
export async function destroyAllUserSessions(
userId: string
): Promise<void> {
// This requires maintaining a user->sessions index
const sessionIds = await redis.smembers(`user_sessions:${userId}`);
if (sessionIds.length > 0) {
const pipeline = redis.pipeline();
for (const sid of sessionIds) {
pipeline.del(`${SESSION_PREFIX}${sid}`);
}
pipeline.del(`user_sessions:${userId}`);
await pipeline.exec();
}
}وسيط تحديد المعدل#
// middleware.ts (or a helper used by middleware)
import redis from "@/lib/redis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
export async function rateLimit(
identifier: string,
limit: number = 60,
windowSeconds: number = 60
): Promise<RateLimitResult> {
const key = `rate:${identifier}`;
const now = Math.floor(Date.now() / 1000);
const windowStart = now - windowSeconds;
// Lua script for atomic rate limiting
const script = `
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
local count = redis.call('ZCARD', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return count
`;
const count = (await redis.eval(
script,
1,
key,
windowStart,
now,
`${now}:${Math.random()}`,
windowSeconds
)) as number;
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
resetAt: now + windowSeconds,
};
}نص Lua مهم هنا. بدونه، تسلسل ZREMRANGEBYSCORE + ZADD + ZCARD ليس ذرياً، وتحت التزامن العالي، قد يكون العدّ غير دقيق. نصوص Lua تُنفَّذ بشكل ذري في Redis — لا يمكن تداخلها مع أوامر أخرى.
الأقفال الموزّعة لـ Next.js#
عندما يكون لديك مثيلات Next.js متعددة وتحتاج لضمان أن واحدة فقط تعالج مهمة (مثل إرسال بريد إلكتروني مجدول أو تشغيل مهمة تنظيف):
// lib/distributed-lock.ts
import { randomUUID } from "crypto";
import redis from "./redis";
export async function withLock<T>(
lockName: string,
fn: () => Promise<T>,
options: { ttl?: number; retryDelay?: number; maxRetries?: number } = {}
): Promise<T | null> {
const { ttl = 30, retryDelay = 200, maxRetries = 10 } = options;
const token = randomUUID();
const lockKey = `dlock:${lockName}`;
// Try to acquire lock
for (let attempt = 0; attempt < maxRetries; attempt++) {
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
if (acquired) {
try {
// Extend lock automatically for long-running tasks
const extender = setInterval(async () => {
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
else
return 0
end
`;
await redis.eval(script, 1, lockKey, token, ttl);
}, (ttl * 1000) / 3);
const result = await fn();
clearInterval(extender);
return result;
} finally {
// Release lock only if we still own it
const releaseScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(releaseScript, 1, lockKey, token);
}
}
// Wait before retrying
await new Promise((r) => setTimeout(r, retryDelay));
}
// Could not acquire lock after all retries
return null;
}الاستخدام:
// In a cron-triggered API route
export async function POST() {
const result = await withLock("daily-report", async () => {
// Only one instance runs this
const report = await generateDailyReport();
await sendReportEmail(report);
return report;
});
if (result === null) {
return NextResponse.json(
{ message: "Another instance is already processing" },
{ status: 409 }
);
}
return NextResponse.json({ success: true });
}فترة تمديد القفل عند ttl/3 مهمة. بدونها، إذا استغرقت مهمتك أكثر من TTL القفل، ينتهي القفل ويستحوذ مثيل آخر عليه. المُمدِّد يبقي القفل حياً طالما المهمة قيد التشغيل.
المراقبة وتصحيح الأخطاء#
Redis سريعة حتى تتوقف عن ذلك. عندما تضرب المشاكل، تحتاج للرؤية.
نسبة إصابة ذاكرة التخزين المؤقت#
المقياس الأكثر أهمية. تتبّعه في تطبيقك:
// lib/cache-metrics.ts
import redis from "./redis";
const METRICS_KEY = "metrics:cache";
export async function recordCacheHit(): Promise<void> {
await redis.hincrby(METRICS_KEY, "hits", 1);
}
export async function recordCacheMiss(): Promise<void> {
await redis.hincrby(METRICS_KEY, "misses", 1);
}
export async function getCacheStats(): Promise<{
hits: number;
misses: number;
hitRate: number;
}> {
const stats = await redis.hgetall(METRICS_KEY);
const hits = parseInt(stats.hits || "0", 10);
const misses = parseInt(stats.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? hits / total : 0,
};
}
// Reset metrics daily
export async function resetCacheStats(): Promise<void> {
await redis.del(METRICS_KEY);
}نسبة إصابة ذاكرة تخزين مؤقت صحية فوق 90%. إذا كنت تحت 80%، إما TTL قصير جداً، أو مفاتيح ذاكرة التخزين المؤقت محددة جداً، أو أنماط الوصول أكثر عشوائية مما ظننت.
أمر INFO#
أمر INFO هو لوحة تحكم صحية مدمجة في Redis:
redis-cli INFO memory# Memory
used_memory:1234567
used_memory_human:1.18M
used_memory_peak:2345678
used_memory_peak_human:2.24M
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.23
مقاييس رئيسية يجب مراقبتها:
- used_memory مقابل maxmemory: هل تقترب من الحد؟
- mem_fragmentation_ratio: فوق 1.5 يعني أن Redis تستخدم ذاكرة RSS أكثر بكثير من الذاكرة المنطقية. فكّر في إعادة التشغيل.
- evicted_keys: إذا كان غير صفري ولم تقصد الطرد، فأنت نفدت الذاكرة.
redis-cli INFO statsراقب:
- keyspace_hits / keyspace_misses: نسبة الإصابة على مستوى الخادم
- total_commands_processed: الإنتاجية
- instantaneous_ops_per_sec: الإنتاجية الحالية
MONITOR (استخدم بحذر شديد)#
MONITOR يبث كل أمر يُنفَّذ على خادم Redis في الوقت الحقيقي. مفيد بشكل لا يُصدَّق لتصحيح الأخطاء وخطير بشكل لا يُصدَّق في الإنتاج.
# NEVER leave this running in production
# It adds significant overhead and can log sensitive data
redis-cli MONITOR1614556800.123456 [0 127.0.0.1:52340] "SET" "cache:user:123" "{\"name\":\"Ahmet\"}" "EX" "1800"
1614556800.234567 [0 127.0.0.1:52340] "GET" "cache:user:456"
أستخدم MONITOR لشيئين فقط: تصحيح مشاكل تسمية المفاتيح أثناء التطوير، والتحقق من أن مسار كود معين يستعلم Redis كما هو متوقع. أبداً لأكثر من 30 ثانية. أبداً في الإنتاج إلا إذا استنفدت خيارات التصحيح الأخرى.
إشعارات فضاء المفاتيح#
تريد معرفة متى تنتهي صلاحية المفاتيح أو تُحذف؟ Redis يمكنها نشر الأحداث:
# Enable keyspace notifications for expired and evicted events
redis-cli CONFIG SET notify-keyspace-events Exconst subscriber = new Redis(/* config */);
// Listen for key expiration events
subscriber.subscribe("__keyevent@0__:expired", (err) => {
if (err) console.error("Subscribe error:", err);
});
subscriber.on("message", (_channel, expiredKey) => {
console.log(`Key expired: ${expiredKey}`);
// Proactively regenerate important keys
if (expiredKey.startsWith("cache:homepage")) {
regenerateHomepageCache().catch(console.error);
}
});هذا مفيد لتدفئة ذاكرة التخزين المؤقت الاستباقية — بدلاً من انتظار مستخدم لتشغيل فقدان ذاكرة التخزين المؤقت، تعيد توليد المدخلات الحرجة لحظة انتهاء صلاحيتها.
تحليل الذاكرة#
عندما تنمو ذاكرة Redis بشكل غير متوقع، تحتاج لإيجاد أي مفاتيح تستهلك الأكثر:
# Sample 10 largest keys
redis-cli --bigkeys# Scanning the entire keyspace to find biggest keys
[00.00%] Biggest string found so far '"cache:search:electronics"' with 524288 bytes
[25.00%] Biggest zset found so far '"leaderboard:global"' with 150000 members
[50.00%] Biggest hash found so far '"session:abc123"' with 45 fields
لتحليل أكثر تفصيلاً:
# Memory usage of a specific key (in bytes)
redis-cli MEMORY USAGE "cache:search:electronics"// Programmatic memory analysis
async function analyzeMemory(pattern: string): Promise<void> {
let cursor = "0";
const stats: Array<{ key: string; bytes: number }> = [];
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
for (const key of keys) {
const bytes = await redis.memory("USAGE", key);
if (bytes) {
stats.push({ key, bytes: bytes as number });
}
}
} while (cursor !== "0");
// Sort by size descending
stats.sort((a, b) => b.bytes - a.bytes);
console.log("Top 20 keys by memory usage:");
for (const { key, bytes } of stats.slice(0, 20)) {
const mb = (bytes / 1024 / 1024).toFixed(2);
console.log(` ${key}: ${mb} MB`);
}
}سياسات الطرد#
إذا كان لمثيل Redis حدّ maxmemory (ويجب أن يكون)، هيّئ سياسة طرد:
# In redis.conf or via CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lruالسياسات المتاحة:
- noeviction: يُعيد خطأ عند امتلاء الذاكرة (افتراضي، الأسوأ للتخزين المؤقت)
- allkeys-lru: طرد المفتاح الأقل استخداماً مؤخراً (أفضل خيار عام للتخزين المؤقت)
- allkeys-lfu: طرد المفتاح الأقل استخداماً تكراراً (أفضل إذا كانت بعض المفاتيح تُوصل على دفعات)
- volatile-lru: طرد المفاتيح ذات TTL المُعيَّن فقط (مفيد إذا كنت تمزج بيانات مؤقتة ودائمة)
- allkeys-random: طرد عشوائي (جيد بشكل مفاجئ، بدون عبء)
لأحمال عمل التخزين المؤقت الخالصة، allkeys-lfu هو عادة الخيار الأفضل. يحتفظ بالمفاتيح التي يُوصل إليها بشكل متكرر في الذاكرة حتى لو لم يُوصل إليها مؤخراً.
تجميع كل شيء: وحدة ذاكرة تخزين مؤقت إنتاجية#
إليك وحدة ذاكرة التخزين المؤقت الكاملة التي أستخدمها في الإنتاج، تجمع كل ما ناقشناه:
// lib/cache.ts
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 200, 5000);
},
});
// TTL tiers
const TTL = {
STATIC: 86400,
MODERATE: 1800,
VOLATILE: 300,
EPHEMERAL: 60,
NOT_FOUND: 120,
} as const;
type TTLTier = keyof typeof TTL;
function ttlWithJitter(base: number, jitter = 0.1): number {
const offset = base * jitter * (Math.random() * 2 - 1);
return Math.max(1, Math.round(base + offset));
}
// Core cache-aside with stampede protection
async function get<T>(
key: string,
fetcher: () => Promise<T>,
options: {
tier?: TTLTier;
ttl?: number;
tags?: string[];
swr?: { freshTtl: number; staleTtl: number };
} = {}
): Promise<T> {
const { tier = "MODERATE", tags } = options;
const baseTtl = options.ttl ?? TTL[tier];
const cacheKey = `c:${key}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
const parsed = JSON.parse(cached);
recordHit();
return parsed as T;
} catch {
await redis.del(cacheKey);
}
}
recordMiss();
// Acquire lock to prevent stampede
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (!acquired) {
// Another process is fetching — wait briefly and retry cache
await new Promise((r) => setTimeout(r, 150));
const retried = await redis.get(cacheKey);
if (retried) return JSON.parse(retried) as T;
}
try {
const result = await fetcher();
const ttl = ttlWithJitter(baseTtl);
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(result), "EX", ttl);
// Store tag associations
if (tags) {
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, cacheKey);
pipeline.expire(`tag:${tag}`, ttl + 3600);
}
}
await pipeline.exec();
return result;
} finally {
await redis.del(lockKey);
}
}
// Invalidation
async function invalidate(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
await redis.del(...keys.map((k) => `c:${k}`));
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}
// Metrics
function recordHit(): void {
redis.hincrby("metrics:cache", "hits", 1).catch(() => {});
}
function recordMiss(): void {
redis.hincrby("metrics:cache", "misses", 1).catch(() => {});
}
async function stats(): Promise<{
hits: number;
misses: number;
hitRate: string;
}> {
const raw = await redis.hgetall("metrics:cache");
const hits = parseInt(raw.hits || "0", 10);
const misses = parseInt(raw.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "N/A",
};
}
export const cache = {
get,
invalidate,
invalidateByTag,
stats,
redis,
TTL,
};الاستخدام عبر التطبيق:
import { cache } from "@/lib/cache";
// Simple cache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
tier: "VOLATILE",
tags: ["entity:products"],
});
// With custom TTL
const config = await cache.get("app:config", fetchAppConfig, {
ttl: 43200, // 12 hours
});
// After updating a product
await cache.invalidateByTag("entity:products");
// Check health
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);أخطاء شائعة ارتكبتها (حتى لا ترتكبها أنت)#
1. عدم تعيين maxmemory. Redis ستستخدم بسعادة كل الذاكرة المتاحة حتى يقتلها نظام التشغيل. عيّن حداً دائماً.
2. استخدام KEYS في الإنتاج. يحجب الخادم. استخدم SCAN. تعلّمت هذا عندما تسبب استدعاء KEYS * من نص مراقبة في 3 ثوانٍ من التوقف.
3. التخزين المؤقت بإفراط. ليس كل شيء يحتاج للتخزين المؤقت. إذا كان استعلام قاعدة بياناتك يستغرق 2 مللي ثانية ويُستدعى 10 مرات في الدقيقة، فالتخزين المؤقت يضيف تعقيداً مقابل فائدة ضئيلة.
4. تجاهل تكاليف التسلسل. ذات مرة خزّنت كتلة JSON بحجم 2 ميغابايت مؤقتاً وتساءلت لماذا عمليات قراءة ذاكرة التخزين المؤقت بطيئة. عبء التسلسل كان أكبر من استعلام قاعدة البيانات الذي كان من المفترض أن يوفّره.
5. عدم وجود تدهور رشيق. عندما تتعطّل Redis، يجب أن يعمل تطبيقك — فقط أبطأ. غلّف كل استدعاء لذاكرة التخزين المؤقت في try/catch يرجع إلى قاعدة البيانات. لا تدع فشل ذاكرة التخزين المؤقت يصبح خطأ يواجه المستخدم.
async function resilientGet<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
try {
return await cache.get(key, fetcher);
} catch (err) {
console.error(`[Cache] Degraded mode for ${key}:`, err);
return fetcher(); // Bypass cache entirely
}
}6. عدم مراقبة عمليات الطرد. إذا كانت Redis تطرد مفاتيح، فأنت إما تحت التزويد أو تخزّن أكثر من اللازم. في كلتا الحالتين، تحتاج لمعرفة ذلك.
7. مشاركة مثيل Redis بين التخزين المؤقت والبيانات الدائمة. استخدم مثيلات منفصلة (أو على الأقل قواعد بيانات منفصلة). سياسة طرد ذاكرة التخزين المؤقت التي تحذف مدخلات طابور المهام هي يوم سيء للجميع.
خلاصة#
التخزين المؤقت في Redis ليس صعباً، لكن من السهل الخطأ فيه. ابدأ بـ cache-aside، أضف تذبذب TTL من اليوم الأول، راقب نسبة الإصابة، وقاوم الرغبة في تخزين كل شيء مؤقتاً.
أفضل استراتيجية تخزين مؤقت هي تلك التي يمكنك التفكير فيها في الساعة الثالثة فجراً عندما ينكسر شيء ما. اجعلها بسيطة، اجعلها قابلة للمراقبة، وتذكّر أن كل قيمة مخزّنة مؤقتاً هي كذبة أخبرتها لمستخدميك عن حالة بياناتك — مهمتك هي إبقاء تلك الكذبة صغيرة وقصيرة العمر قدر الإمكان.