جزء من سلسلة 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. استكشف خدماتنا الهندسية الخلفية للتعرف على كيفية تصميم واجهات برمجة التطبيقات عالية الأداء والقابلة للتطوير.
بقلم
ECOSIRE Research and Development Team
بناء منتجات رقمية بمستوى المؤسسات في ECOSIRE. مشاركة رؤى حول تكاملات Odoo وأتمتة التجارة الإلكترونية وحلول الأعمال المدعومة بالذكاء الاصطناعي.
مقالات ذات صلة
API Rate Limiting: Patterns and Best Practices
Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.
k6 Load Testing: Stress-Test Your APIs Before Launch
Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.
NestJS 11 Enterprise API Patterns
Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.
المزيد من Performance & Scalability
k6 Load Testing: Stress-Test Your APIs Before Launch
Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.
Nginx Production Configuration: SSL, Caching, and Security
Nginx production configuration guide: SSL termination, HTTP/2, caching headers, security headers, rate limiting, reverse proxy setup, and Cloudflare integration patterns.
Odoo Performance Tuning: PostgreSQL and Server Optimization
Expert guide to Odoo 19 performance tuning. Covers PostgreSQL configuration, indexing, query optimization, Nginx caching, and server sizing for enterprise deployments.
Odoo vs Acumatica: Cloud ERP for Growing Businesses
Odoo vs Acumatica compared for 2026: unique pricing models, scalability, manufacturing depth, and which cloud ERP fits your growth trajectory.
Testing and Monitoring AI Agents in Production
A complete guide to testing and monitoring AI agents in production environments. Covers evaluation frameworks, observability, drift detection, and incident response for OpenClaw deployments.
Compliance Monitoring Agents with OpenClaw
Deploy OpenClaw AI agents for continuous compliance monitoring. Automate regulatory checks, policy enforcement, audit trail generation, and compliance reporting.