Redis Caching Patterns for Web Applications

Master Redis caching patterns for Node.js and NestJS: cache-aside, write-through, cache stampede prevention, TTL strategies, invalidation, and production monitoring.

E
ECOSIRE Research and Development Team
|19 مارس 202610 دقائق قراءة2.3k كلمات|

جزء من سلسلة Performance & Scalability

اقرأ الدليل الكامل

Redis أنماط التخزين المؤقت لتطبيقات الويب

تعد استعلامات قاعدة البيانات أكثر اختناقات الأداء شيوعًا في تطبيقات الويب. استعلام مفهرسة بشكل سيئ يستغرق 200 مللي ثانية، ويتم استدعاؤه عند كل تحميل صفحة لـ 1000 مستخدم متزامن، ويولد 200 ثانية من وحدة المعالجة المركزية لقاعدة البيانات في الثانية - وهي دوامة الموت. Redis هو الترياق: مخزن في الذاكرة يبلغ حجمه أقل من مللي ثانية يمتص حمل القراءة الذي قد يصل إلى قاعدة بياناتك بشكل متكرر للحصول على بيانات متطابقة.

لكن التخزين المؤقت لا يقتصر على مجرد "وضع الأشياء في Redis". يتسبب النمط الخاطئ في حدوث أخطاء في البيانات التي لا معنى لها، أو كوارث القطيع المدوية، أو نمو الذاكرة غير المحدود. يغطي هذا الدليل أنماط التخزين المؤقت الأساسية الأربعة، واستراتيجيات إبطال ذاكرة التخزين المؤقت، ومنع التدافع، وتنفيذ NestJS للإنتاج حتى تقوم بالتخزين المؤقت بشكل صحيح من البداية.

الوجبات الرئيسية

  • يعد وضع ذاكرة التخزين المؤقت جانبًا (التحميل البطيء) هو الوضع الافتراضي الأكثر أمانًا - فقط تخزين ما هو مطلوب بالفعل
  • تحافظ عملية الكتابة على مزامنة ذاكرة التخزين المؤقت وقاعدة البيانات ولكنها تضيف زمن استجابة للكتابة - تستخدم للبيانات التي تتم قراءتها بشكل متكرر والتي تتم كتابتها بشكل غير متكرر
  • يؤدي تدافع ذاكرة التخزين المؤقت (القطيع المدوي) إلى تدمير قواعد البيانات عند انتهاء صلاحية المفتاح الشائع - استخدم انتهاء الصلاحية المبكر الاحتمالي أو أقفال كائن المزامنة (mutex lock)
  • يجب ضبط TTL على كل مفتاح — نمو Redis غير المحدود هو حدث ينتظر حدوثه
  • يعد إبطال ذاكرة التخزين المؤقت أمرًا صعبًا: تفضل TTLs القصيرة والإبطال المستند إلى الحدث بدلاً من محاولة الإبطال بشكل مثالي
  • يجب أن تتضمن مفاتيح التخزين المؤقت جميع أبعاد الاستعلام: contacts:{orgId}:{page}:{limit}:{search}:{sortField}:{sortDir}
  • استخدم مساحات أسماء Redis وأنماط المفاتيح لتمكين الإبطال المجمع
  • مراقبة معدل دخول ذاكرة التخزين المؤقت؛ أقل من 80% يعني أن مدة البقاء (TTL) قصيرة جدًا أو أن بنية المفتاح الخاصة بك خاطئة

إعداد Redis في NestJS

pnpm add ioredis @nestjs-modules/ioredis
// src/modules/cache/cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';

@Injectable()
export class CacheService {
  private readonly logger = new Logger(CacheService.name);

  constructor(@InjectRedis() private readonly redis: Redis) {}

  async get<T>(key: string): Promise<T | null> {
    const value = await this.redis.get(key);
    if (!value) return null;
    return JSON.parse(value) as T;
  }

  async set<T>(key: string, value: T, ttlSeconds = 300): Promise<void> {
    await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
  }

  async del(key: string): Promise<void> {
    await this.redis.del(key);
  }

  // Use SCAN instead of KEYS to avoid blocking Redis on large key spaces
  async invalidatePattern(pattern: string): Promise<void> {
    let cursor = '0';
    do {
      const [nextCursor, keys] = await this.redis.scan(
        cursor,
        'MATCH', pattern,
        'COUNT', 100
      );
      cursor = nextCursor;
      if (keys.length > 0) {
        await this.redis.del(...keys);
      }
    } while (cursor !== '0');
  }

  async remember<T>(
    key: string,
    ttlSeconds: number,
    factory: () => Promise<T>
  ): Promise<T> {
    const cached = await this.get<T>(key);
    if (cached !== null) return cached;

    const value = await factory();
    await this.set(key, value, ttlSeconds);
    return value;
  }
}
// src/modules/cache/cache.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-modules/ioredis';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheService } from './cache.service';

@Global()
@Module({
  imports: [
    RedisModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'single',
        options: {
          host: config.get('REDIS_HOST', 'localhost'),
          port: config.get<number>('REDIS_PORT', 6379),
          password: config.get('REDIS_PASSWORD'),
          db: config.get<number>('REDIS_DB', 0),
          keyPrefix: 'ecosire:',
        },
      }),
    }),
  ],
  providers: [CacheService],
  exports: [CacheService],
})
export class AppCacheModule {}

النمط 1: وضع ذاكرة التخزين المؤقت جانبًا (التحميل البطيء)

النمط الأكثر شيوعا. القراءة من ذاكرة التخزين المؤقت؛ في حالة عدم القدرة على القراءة من قاعدة البيانات وملء ذاكرة التخزين المؤقت.

// contacts.service.ts
async findAll(orgId: string, page: number, limit: number, search?: string) {
  const cacheKey = `contacts:${orgId}:${page}:${limit}:${search ?? 'all'}`;

  return this.cache.remember(cacheKey, 300, async () => {
    const results = await this.db.query.contacts.findMany({
      where: and(
        eq(contacts.organizationId, orgId),
        search
          ? or(
              ilike(contacts.name, `%${search}%`),
              ilike(contacts.email, `%${search}%`)
            )
          : undefined
      ),
      limit,
      offset: (page - 1) * limit,
      orderBy: desc(contacts.createdAt),
    });
    return results;
  });
}

الإيجابيات: بسيط؛ تحتوي ذاكرة التخزين المؤقت فقط على ما هو مطلوب بالفعل. السلبيات: الطلب الأول دائمًا ما يكون بطيئًا (ذاكرة التخزين المؤقت مفقودة)؛ يمكن أن تكون البيانات قديمة حتى تنتهي صلاحية TTL.

متى يتم الإلغاء: بعد الإنشاء/التحديث/الحذف، احذف جميع المفاتيح المطابقة لـ contacts:{orgId}:*.


النموذج 2: الكتابة

قم بتحديث ذاكرة التخزين المؤقت في وقت واحد مع قاعدة البيانات عند كل عملية كتابة. الصفقات تكتب الكمون من أجل اتساق القراءة.

async update(orgId: string, contactId: string, dto: UpdateContactDto) {
  // 1. Update the database
  const [updated] = await this.db
    .update(contacts)
    .set({ ...dto, updatedAt: new Date() })
    .where(
      and(
        eq(contacts.id, contactId),
        eq(contacts.organizationId, orgId)
      )
    )
    .returning();

  // 2. Update the individual contact cache immediately (write-through)
  const singleKey = `contact:${orgId}:${contactId}`;
  await this.cache.set(singleKey, updated, 3600);

  // 3. Invalidate list caches (they now contain stale order/counts)
  await this.cache.invalidatePattern(`contacts:${orgId}:*`);

  return updated;
}

متى يتم الاستخدام: السجلات التي تتم قراءتها بشكل متكرر، والتي يتم كتابتها بشكل متكرر (ملفات تعريف المستخدمين، والإعدادات، وكتالوج المنتجات).


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

عندما تنتهي صلاحية مفتاح ذاكرة التخزين المؤقت الشائع، تفقد مئات الطلبات المتزامنة ذاكرة التخزين المؤقت في وقت واحد، وتتصل جميعها بقاعدة البيانات - "القطيع الرعد". تنهار قاعدة البيانات تحت الحمل المفاجئ.

نمط قفل Mutex

طلب واحد فقط يعيد بناء ذاكرة التخزين المؤقت؛ الآخرون ينتظرون ويعيدون المحاولة:

// cache.service.ts — mutex-protected cache fetch
async getWithLock<T>(
  key: string,
  ttlSeconds: number,
  factory: () => Promise<T>
): Promise<T> {
  // Try cache first
  const cached = await this.get<T>(key);
  if (cached !== null) return cached;

  const lockKey = `lock:${key}`;
  const lockOwner = `${process.pid}-${Date.now()}`;

  // Try to acquire lock: NX = only set if not exists, EX = expire in 30s
  const acquired = await this.redis.set(lockKey, lockOwner, 'NX', 'EX', 30);

  if (acquired === 'OK') {
    try {
      const value = await factory();
      await this.set(key, value, ttlSeconds);
      return value;
    } finally {
      // Atomic check-and-delete: only release if we still own the lock
      // Uses Lua defined with defineCommand for atomicity
      const currentOwner = await this.redis.get(lockKey);
      if (currentOwner === lockOwner) {
        await this.redis.del(lockKey);
      }
    }
  } else {
    // Another process is rebuilding — wait 100ms and retry
    await new Promise<void>((resolve) => {
      const timer = setTimeout(resolve, 100);
      // Store timer reference so Node.js does not block on it
      timer.unref?.();
    });
    return this.getWithLock(key, ttlSeconds, factory);
  }
}

انتهاء الصلاحية المبكر الاحتمالي (XFetch)

ابدأ بإعادة بناء ذاكرة التخزين المؤقت بشكل احتمالي قبل انتهاء صلاحيتها، مع توزيع حمل إعادة الحساب:

async getWithEarlyExpiration<T>(
  key: string,
  ttlSeconds: number,
  factory: () => Promise<T>,
  beta = 1 // Higher = more aggressive early recomputation
): Promise<T> {
  const metaKey = `${key}:meta`;
  const meta = await this.get<{ createdAt: number; ttl: number }>(metaKey);
  const value = await this.get<T>(key);

  if (value !== null && meta) {
    const elapsed = Date.now() / 1000 - meta.createdAt;
    const remaining = meta.ttl - elapsed;

    // XFetch: trigger early recomputation probabilistically
    const shouldRecompute = -Math.log(Math.random()) * beta > remaining;
    if (!shouldRecompute) return value;
  }

  const newValue = await factory();
  const now = Date.now() / 1000;
  await this.set(key, newValue, ttlSeconds);
  await this.set(metaKey, { createdAt: now, ttl: ttlSeconds }, ttlSeconds + 60);

  return newValue;
}

النموذج 4: إبطال ذاكرة التخزين المؤقت المستندة إلى العلامات

قم بتجميع مفاتيح ذاكرة التخزين المؤقت حسب العلامات المنطقية وإبطال العلامة بأكملها مرة واحدة:

// Tag-based invalidation using Redis sets
async setWithTags(
  key: string,
  value: unknown,
  ttlSeconds: number,
  tags: string[]
): Promise<void> {
  await this.set(key, value, ttlSeconds);

  // Add key to each tag's member set using a pipeline
  const pipeline = this.redis.pipeline();
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, key);
    pipeline.expire(`tag:${tag}`, ttlSeconds + 60);
  }
  await pipeline.exec();
}

async invalidateByTag(tag: string): Promise<void> {
  const keys = await this.redis.smembers(`tag:${tag}`);
  if (keys.length > 0) {
    const pipeline = this.redis.pipeline();
    for (const cacheKey of keys) {
      pipeline.del(cacheKey);
    }
    pipeline.del(`tag:${tag}`);
    await pipeline.exec();
  }
}

// Usage
await this.setWithTags(
  `contacts:${orgId}:page:1`,
  contacts,
  300,
  [`org:${orgId}`, 'contacts']
);

// After any contact mutation in org_123:
await this.invalidateByTag(`org:${orgId}`);

التخزين المؤقت للجلسة والمصادقة

يعد Redis مثاليًا لتخزين جلسة المصادقة — تخلص من عمليات البحث في قاعدة البيانات عند كل طلب:

// Cache user session data (role, permissions, orgId) for JWT validation
async cacheUserSession(userId: string, session: UserSession): Promise<void> {
  const key = `session:${userId}`;
  await this.redis.hset(key,
    'id',             session.id,
    'email',          session.email,
    'role',           session.role,
    'organizationId', session.organizationId,
    'tokenVersion',   String(session.tokenVersion)
  );
  await this.redis.expire(key, 900); // 15 minutes — matches access token TTL
}

async getUserSession(userId: string): Promise<UserSession | null> {
  const data = await this.redis.hgetall(`session:${userId}`);
  if (!data || Object.keys(data).length === 0) return null;
  return {
    ...data,
    tokenVersion: parseInt(data.tokenVersion, 10),
  } as UserSession;
}

async invalidateUserSession(userId: string): Promise<void> {
  await this.redis.del(`session:${userId}`);
}

استراتيجية TTL حسب نوع البيانات

نوع البياناتأوصى TTLاستراتيجية الإبطال
ملف تعريف المستخدم15 دقيقةعند تحديث الملف الشخصي
إعدادات المنظمة1 ساعةعلى تغيير الإعدادات
كتالوج المنتجات24 ساعةعند إنشاء/تحديث المنتج
قائمة منشورات المدونة10 دقائقعلى آخر نشر
عداد حد معدل APIلكل نافذة (60 ثانية)انتهاء الصلاحية التلقائي
جلسة المصادقة15 دقيقةعند تسجيل الخروج أو إلغاء الرمز المميز
نتائج البحث5 دقائقTTL فقط
المقاييس المجمعة1 دقيقةTTL فقط
رموز لمرة واحدة (مصادقة)60 ثانيةبعد الاستخدام أو انتهاء الصلاحية
استعلام القائمة المرقّمة5 دقائقعلى أي طفرة في المنظمة

مراقبة أداء ذاكرة التخزين المؤقت

// Add hit/miss tracking to CacheService
async get<T>(key: string): Promise<T | null> {
  const value = await this.redis.get(key);
  if (value) {
    await this.redis.incr('metrics:cache_hits');
    return JSON.parse(value) as T;
  }
  await this.redis.incr('metrics:cache_misses');
  return null;
}

// Report hit rate every 5 minutes
@Cron('*/5 * * * *')
async reportCacheMetrics(): Promise<void> {
  const [hits, misses] = await this.redis.mget(
    'metrics:cache_hits',
    'metrics:cache_misses'
  );
  const h = parseInt(hits ?? '0', 10);
  const m = parseInt(misses ?? '0', 10);
  const total = h + m;
  const hitRate = total === 0 ? 1 : h / total;

  if (hitRate < 0.8) {
    this.logger.warn(`Low cache hit rate: ${(hitRate * 100).toFixed(1)}%`);
  }

  // Reset counters
  await this.redis.set('metrics:cache_hits', '0');
  await this.redis.set('metrics:cache_misses', '0');
}

مقاييس Redis الرئيسية للمراقبة عبر redis-cli INFO stats:

  • keyspace_hits / keyspace_misses — معدل النجاح العالمي (الهدف أكثر من 80%)
  • used_memory — انتبه للنمو غير المحدود
  • evicted_keys — المفاتيح التي تم إخلاؤها بواسطة سياسة الذاكرة القصوى (يجب أن تكون قريبة من 0 لوضع ذاكرة التخزين المؤقت جانبًا)
  • connected_clients — استخدام تجمع الاتصال
  • instantaneous_ops_per_sec — الإنتاجية الحالية

الأسئلة المتداولة

ما هي سياسة الذاكرة القصوى التي يجب أن أستخدمها لذاكرة التخزين المؤقت؟

استخدم allkeys-lru (اطرد المفاتيح الأقل استخدامًا مؤخرًا من جميع المفاتيح) أو volatile-lru (اطرد مفاتيح LRU التي تحتوي على مجموعة TTL). بالنسبة لأعباء عمل ذاكرة التخزين المؤقت الخالصة، يعد allkeys-lru قياسيًا - يقوم Redis تلقائيًا بإخراج المفاتيح الباردة عندما تكون الذاكرة ممتلئة. لا تستخدم أبدًا noeviction لذاكرة التخزين المؤقت - فهي تُرجع الأخطاء عندما تكون الذاكرة ممتلئة بدلاً من مسح البيانات القديمة.

كيف أتجنب تخزين البيانات الحساسة في Redis؟

لا تقم أبدًا بتخزين كلمات المرور الأولية أو المفاتيح الخاصة أو بيانات بطاقة الدفع أو أرقام الضمان الاجتماعي. بالنسبة لجلسات المصادقة، قم بتخزين الحد الأدنى من البيانات في ذاكرة التخزين المؤقت: معرف المستخدم، الدور، معرف المنظمة، tokenVersion - وليس حمولات JWT الكاملة أو بيانات اعتماد واجهة برمجة التطبيقات. تمكين Redis AUTH وTLS في الإنتاج. إذا تم اختراق مثيل Redis الخاص بك، فسيتم عرض البيانات التعريفية فقط عند اتباع هذا النظام.

ما الفرق بين SCAN وKEYS لحذف النمط؟

KEYS pattern هي عملية حظر O(n) تعمل على إيقاف جميع أوامر Redis مؤقتًا أثناء المسح - يمكن أن تسبب ثوانٍ من التوقف في مساحات المفاتيح الكبيرة. SCAN غير محظور، ويتكرر في أجزاء صغيرة باستخدام المؤشر. استخدم دائمًا SCAN لحذف نمط الإنتاج. تتمثل المقايضة في أن SCAN قد لا يُرجع جميع المفاتيح إذا تمت إضافتها أو حذفها أثناء الفحص - وهو أمر مقبول لإبطال ذاكرة التخزين المؤقت.

هل يجب علي استخدام نسخة Redis منفصلة للتخزين المؤقت مقابل تحديد المعدل مقابل الجلسات؟

بالنسبة لمعظم التطبيقات، يعد مثيل Redis ببادئات مفاتيح مختلفة أمرًا جيدًا. تكون المثيلات المنفصلة منطقية عندما: تحتاج ذاكرة التخزين المؤقت إلى maxmemory-policy مختلف عن مخزن حد المعدل (ذاكرة التخزين المؤقت: allkeys-lru، حدود المعدل: noeviction)، أو عندما تحتاج إلى تحجيم مستقل وتجاوز الفشل. على نطاق واسع، استخدم Redis Cluster مع مجموعات منفصلة لكل نوع حمل عمل.

كيف أتعامل مع إبطال ذاكرة التخزين المؤقت لاستعلامات القائمة المقسمة إلى صفحات؟

تعتبر ذاكرات التخزين المؤقت للقائمة المقسمة إلى صفحات صعبة - حيث تؤدي إضافة جهة اتصال في الصفحة 1 إلى تغيير كافة جهات الاتصال في الصفحات 2+. الحل العملي: استخدام TTLs قصيرة (2-5 دقائق) وإبطال جميع الصفحات الخاصة بالمؤسسة عند أي كتابة باستخدام إبطال النمط (contacts:{orgId}:*). بالنسبة للمؤسسات الكبيرة ذات أحجام الكتابة الثقيلة، تخطي التخزين المؤقت للصفحات تمامًا واعتمد على تحسين مستوى قاعدة البيانات (الفهارس المناسبة، وتغطية الفهارس) بدلاً من ذلك.

كيف يمكنني اختبار سلوك ذاكرة التخزين المؤقت في Vitest؟

قم بالسخرية من CacheService في اختبارات الوحدة باستخدام vi.fn() لإرجاع القيم المناسبة لسيناريوهات النجاح/الفشل. بالنسبة لاختبارات التكامل، استخدم نسخة Redis حقيقية (Docker) واستخدم redis.flushdb() في beforeEach للبدء بالتنظيف. اختبر كلاً من مسار ذاكرة التخزين المؤقت (لا يوجد استدعاء لقاعدة البيانات) ومسار ذاكرة التخزين المؤقت المفقودة (قاعدة البيانات تسمى + ذاكرة التخزين المؤقت المملوءة) بشكل صريح.


الخطوات التالية

يعد التخزين المؤقت لـ Redis أحد أعلى الاستثمارات في أداء عائد الاستثمار في تطبيق الويب. الفرق بين استعلام قاعدة بيانات 200 مللي ثانية ونتائج Redis 2 مللي ثانية هو 100x — على نطاق واسع، وهو ما يترجم مباشرة إلى توفير في تكاليف البنية التحتية وتحسين تجربة المستخدم.

تنفذ ECOSIRE التخزين المؤقت لـ Redis مع ذاكرة التخزين المؤقت جانبًا، وإبطال الكتابة، ومنع التدافع، ومراقبة معدل ضربات ذاكرة التخزين المؤقت في كل مشروع NestJS. استكشف خدماتنا الهندسية الخلفية للتعرف على كيفية تصميم واجهات برمجة التطبيقات عالية الأداء والقابلة للتطوير.

مشاركة:
E

بقلم

ECOSIRE Research and Development Team

بناء منتجات رقمية بمستوى المؤسسات في ECOSIRE. مشاركة رؤى حول تكاملات Odoo وأتمتة التجارة الإلكترونية وحلول الأعمال المدعومة بالذكاء الاصطناعي.

الدردشة على الواتساب