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

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

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

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

الاختبار والتصحيح المناسبين لأنظمة التخزين المؤقت أمر حاسم لضمان الموثوقية والأداء واتساق البيانات. دعنا نستكشف استراتيجيات اختبار شاملة وتقنيات تصحيح لأنظمة التخزين المؤقت القائمة على Redis.

اختبار الوحدة للكود المخزن مؤقتاً

اكتب اختبارات الوحدة لمنطق التخزين المؤقت الخاص بك باستخدام Jest ومحاكاة Redis:

// تثبيت تبعيات الاختبار
// npm install --save-dev jest redis-mock

// __tests__/cacheService.test.js
const RedisMock = require('redis-mock');
const CacheService = require('../src/services/cacheService');

// محاكاة عميل Redis
jest.mock('../src/config/redis', () => {
return RedisMock.createClient();
});

describe('CacheService', () => {
let cacheService;

beforeEach(() => {
cacheService = new CacheService();
cacheService.resetMetrics();
});

afterEach(async () => {
// مسح جميع المفاتيح بعد كل اختبار
const redis = require('../src/config/redis');
const keys = await redis.keys('*');
if (keys.length > 0) {
await redis.del(keys);
}
});

describe('get', () => {
test('يجب إرجاع القيمة المخزنة مؤقتاً عند نجاح الذاكرة المؤقتة', async () => {
const key = 'test:key';
const value = { id: 1, name: 'Test' };

// تعيين القيمة في الذاكرة المؤقتة
await cacheService.set(key, value);

// يجب أن يُرجع الجلب القيمة المخزنة مؤقتاً
const fetcher = jest.fn();
const result = await cacheService.get(key, fetcher);

expect(result).toEqual(value);
expect(fetcher).not.toHaveBeenCalled();
expect(cacheService.getMetrics().hits).toBe(1);
});

test('يجب استدعاء الجالب عند فقدان الذاكرة المؤقتة', async () => {
const key = 'test:miss';
const value = { id: 2, name: 'Fetched' };

const fetcher = jest.fn().mockResolvedValue(value);
const result = await cacheService.get(key, fetcher);

expect(result).toEqual(value);
expect(fetcher).toHaveBeenCalledTimes(1);
expect(cacheService.getMetrics().misses).toBe(1);
});

test('يجب تخزين القيمة المجلوبة مؤقتاً', async () => {
const key = 'test:fetch';
const value = { id: 3, name: 'Cached' };

const fetcher = jest.fn().mockResolvedValue(value);

// الاستدعاء الأول - فقدان الذاكرة المؤقتة
await cacheService.get(key, fetcher);
expect(fetcher).toHaveBeenCalledTimes(1);

// الاستدعاء الثاني - نجاح الذاكرة المؤقتة
await cacheService.get(key, fetcher);
expect(fetcher).toHaveBeenCalledTimes(1); // لم يُستدعى مرة أخرى
});

test('يجب التعامل مع الأخطاء بأناقة', async () => {
const key = 'test:error';
const value = { id: 4, name: 'Fallback' };

// محاكاة خطأ Redis
const redis = require('../src/config/redis');
jest.spyOn(redis, 'get').mockRejectedValueOnce(new Error('خطأ Redis'));

const fetcher = jest.fn().mockResolvedValue(value);
const result = await cacheService.get(key, fetcher);

expect(result).toEqual(value);
expect(fetcher).toHaveBeenCalled();
expect(cacheService.getMetrics().errors).toBe(1);
});
});

describe('delPattern', () => {
test('يجب حذف المفاتيح المطابقة للنمط', async () => {
await cacheService.set('products:1', { id: 1 });
await cacheService.set('products:2', { id: 2 });
await cacheService.set('users:1', { id: 1 });

const count = await cacheService.delPattern('products:*');

expect(count).toBe(2);

// التحقق من الحذف
const redis = require('../src/config/redis');
const products1 = await redis.get('products:1');
const users1 = await redis.get('users:1');

expect(products1).toBeNull();
expect(users1).not.toBeNull();
});
});
});

محاكاة Redis للاختبارات

إنشاء محاكاة Redis مرنة لاختبارات التكامل:

// __tests__/helpers/redisMock.js
class RedisMockClient {
constructor() {
this.store = new Map();
this.isReady = true;
}

async get(key) {
const item = this.store.get(key);
if (!item) return null;

// فحص انتهاء الصلاحية
if (item.expiresAt && Date.now() > item.expiresAt) {
this.store.delete(key);
return null;
}

return item.value;
}

async set(key, value) {
this.store.set(key, { value, expiresAt: null });
return 'OK';
}

async setEx(key, seconds, value) {
this.store.set(key, {
value,
expiresAt: Date.now() + (seconds * 1000)
});
return 'OK';
}

async del(...keys) {
let count = 0;
for (const key of keys) {
if (this.store.delete(key)) count++;
}
return count;
}

async keys(pattern) {
const regex = new RegExp(pattern.replace('*', '.*'));
return Array.from(this.store.keys()).filter(key => regex.test(key));
}

async flushAll() {
this.store.clear();
return 'OK';
}

// طرق Pub/Sub
async publish(channel, message) {
return 1; // عدد المشتركين
}

async subscribe(channel) {
return 'OK';
}

// محاكاة الاتصال
async connect() {
this.isReady = true;
}

async quit() {
this.isReady = false;
}

duplicate() {
return new RedisMockClient();
}
}

module.exports = RedisMockClient;

اختبار التكامل

اختبار التخزين المؤقت مع نقاط نهاية API الفعلية:

// __tests__/integration/productAPI.test.js
const request = require('supertest');
const app = require('../../src/app');
const Product = require('../../src/models/product');
const cacheService = require('../../src/services/cacheService');

describe('Product API Caching', () => {
beforeEach(async () => {
// مسح قاعدة البيانات والذاكرة المؤقتة
await Product.deleteMany({});
await cacheService.delPattern('*');
cacheService.resetMetrics();
});

describe('GET /api/products/:id', () => {
test('يجب تخزين المنتج مؤقتاً في الطلب الأول', async () => {
const product = await Product.create({
name: 'Test Product',
price: 99.99,
category: 'Electronics'
});

// الطلب الأول - فقدان الذاكرة المؤقتة
const res1 = await request(app)
.get(`/api/products/${product._id}`)
.expect(200);

expect(res1.headers['x-cache']).toBe('MISS');
expect(res1.body.data.name).toBe('Test Product');

// الطلب الثاني - نجاح الذاكرة المؤقتة
const res2 = await request(app)
.get(`/api/products/${product._id}`)
.expect(200);

expect(res2.headers['x-cache']).toBe('HIT');
expect(res2.body.data.name).toBe('Test Product');

const metrics = cacheService.getMetrics();
expect(metrics.hits).toBe(1);
expect(metrics.misses).toBe(1);
});

test('يجب إبطال الذاكرة المؤقتة عند التحديث', async () => {
const product = await Product.create({
name: 'Original Name',
price: 50,
category: 'Books'
});

// تخزين المنتج مؤقتاً
await request(app)
.get(`/api/products/${product._id}`)
.expect(200);

// تحديث المنتج
await request(app)
.put(`/api/products/${product._id}`)
.send({ name: 'Updated Name' })
.expect(200);

// الطلب التالي يجب أن يجلب بيانات جديدة
const res = await request(app)
.get(`/api/products/${product._id}`)
.expect(200);

expect(res.headers['x-cache']).toBe('MISS');
expect(res.body.data.name).toBe('Updated Name');
});
});

describe('GET /api/products', () => {
test('يجب تخزين قائمة المنتجات مؤقتاً', async () => {
await Product.create([
{ name: 'Product 1', price: 10, category: 'A' },
{ name: 'Product 2', price: 20, category: 'A' }
]);

const res1 = await request(app)
.get('/api/products')
.expect(200);

expect(res1.headers['x-cache']).toBe('MISS');
expect(res1.body.count).toBe(2);

const res2 = await request(app)
.get('/api/products')
.expect(200);

expect(res2.headers['x-cache']).toBe('HIT');
});
});
});

مراقبة نسبة نجاح الذاكرة المؤقتة

تنفيذ مراقبة وتحليلات شاملة للذاكرة المؤقتة:

// src/services/cacheMonitor.js
const redisClient = require('../config/redis');

class CacheMonitor {
constructor() {
this.metricsKey = 'cache:metrics';
this.startTime = Date.now();
}

/**
* تسجيل نجاح الذاكرة المؤقتة
*/
async recordHit(key, responseTime) {
await redisClient.hIncrBy(this.metricsKey, 'hits', 1);
await this.recordResponseTime(responseTime);
}

/**
* تسجيل فقدان الذاكرة المؤقتة
*/
async recordMiss(key, responseTime) {
await redisClient.hIncrBy(this.metricsKey, 'misses', 1);
await this.recordResponseTime(responseTime);
}

/**
* تسجيل وقت الاستجابة
*/
async recordResponseTime(ms) {
const bucket = Math.floor(ms / 10) * 10; // دلاء 10 مللي ثانية
await redisClient.hIncrBy('cache:response_times', bucket, 1);
}

/**
* الحصول على مقاييس شاملة
*/
async getMetrics() {
const [metrics, responseTimes, info] = await Promise.all([
redisClient.hGetAll(this.metricsKey),
redisClient.hGetAll('cache:response_times'),
redisClient.info('stats')
]);

const hits = parseInt(metrics.hits || 0);
const misses = parseInt(metrics.misses || 0);
const total = hits + misses;

// حساب المئويات من دلاء وقت الاستجابة
const times = Object.entries(responseTimes)
.map(([bucket, count]) => ({
time: parseInt(bucket),
count: parseInt(count)
}))
.sort((a, b) => a.time - b.time);

return {
hitRate: total > 0 ? ((hits / total) * 100).toFixed(2) : 0,
hits,
misses,
total,
uptime: Math.floor((Date.now() - this.startTime) / 1000),
responseTimes: this.calculatePercentiles(times),
redis: this.parseRedisInfo(info)
};
}

calculatePercentiles(times) {
const totalCount = times.reduce((sum, t) => sum + t.count, 0);
if (totalCount === 0) return {};

const percentiles = { p50: 0, p95: 0, p99: 0 };
let cumulative = 0;

for (const { time, count } of times) {
cumulative += count;
const percent = (cumulative / totalCount) * 100;

if (percent >= 50 && !percentiles.p50) percentiles.p50 = time;
if (percent >= 95 && !percentiles.p95) percentiles.p95 = time;
if (percent >= 99 && !percentiles.p99) percentiles.p99 = time;
}

return percentiles;
}

parseRedisInfo(info) {
const lines = info.split('\r\n');
const stats = {};

for (const line of lines) {
if (line.includes(':')) {
const [key, value] = line.split(':');
stats[key] = value;
}
}

return {
totalConnectionsReceived: stats.total_connections_received,
totalCommandsProcessed: stats.total_commands_processed,
keysExpired: stats.expired_keys,
keysEvicted: stats.evicted_keys
};
}

/**
* إعادة تعيين جميع المقاييس
*/
async reset() {
await redisClient.del(this.metricsKey, 'cache:response_times');
this.startTime = Date.now();
}
}

module.exports = new CacheMonitor();

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

مشاكل الذاكرة المؤقتة الشائعة وكيفية تصحيحها:

// أداة تصحيح لفحص الذاكرة المؤقتة
class CacheDebugger {
constructor(redis) {
this.redis = redis;
}

/**
* فحص تفاصيل مفتاح الذاكرة المؤقتة
*/
async inspectKey(key) {
const [value, ttl, type] = await Promise.all([
this.redis.get(key),
this.redis.ttl(key),
this.redis.type(key)
]);

return {
key,
exists: value !== null,
type,
ttl: ttl > 0 ? ttl : 'لا انتهاء صلاحية',
size: value ? Buffer.byteLength(value) : 0,
value: value ? JSON.parse(value) : null
};
}

/**
* البحث عن إدخالات ذاكرة مؤقتة كبيرة
*/
async findLargeKeys(minSize = 1024) {
const keys = await this.redis.keys('*');
const large = [];

for (const key of keys) {
const value = await this.redis.get(key);
if (value) {
const size = Buffer.byteLength(value);
if (size >= minSize) {
large.push({ key, size });
}
}
}

return large.sort((a, b) => b.size - a.size);
}

/**
* البحث عن مفاتيح بدون انتهاء صلاحية
*/
async findKeysWithoutExpiration() {
const keys = await this.redis.keys('*');
const noExpiry = [];

for (const key of keys) {
const ttl = await this.redis.ttl(key);
if (ttl === -1) { // -1 يعني لا انتهاء صلاحية
noExpiry.push(key);
}
}

return noExpiry;
}

/**
* تحليل أنماط مفاتيح الذاكرة المؤقتة
*/
async analyzeKeyPatterns() {
const keys = await this.redis.keys('*');
const patterns = {};

for (const key of keys) {
const prefix = key.split(':')[0];
patterns[prefix] = (patterns[prefix] || 0) + 1;
}

return Object.entries(patterns)
.map(([prefix, count]) => ({ prefix, count }))
.sort((a, b) => b.count - a.count);
}
}

module.exports = CacheDebugger;
مشاكل الذاكرة المؤقتة الشائعة:
  • بيانات قديمة: فحص قيم TTL ومنطق الإبطال
  • معدل نجاح منخفض: مراجعة استراتيجية مفتاح الذاكرة المؤقتة وقيم TTL
  • نمو الذاكرة: البحث عن مفاتيح بدون انتهاء صلاحية أو قيم كبيرة
  • اندفاع الذاكرة المؤقتة: تنفيذ آليات قفل أو stale-while-revalidate
  • بيانات غير متسقة: التحقق من عمل إبطال الذاكرة المؤقتة عبر جميع مسارات التحديث

اختبار الحمل لأداء الذاكرة المؤقتة

اختبر الذاكرة المؤقتة الخاصة بك تحت حمل واقعي:

// تثبيت artillery لاختبار الحمل
// npm install -g artillery

// load-test.yml
config:
target: 'http://localhost:3000'
phases:
- duration: 60
arrivalRate: 10
name: 'الإحماء'
- duration: 300
arrivalRate: 50
name: 'الحمل المستمر'
- duration: 120
arrivalRate: 100
name: 'حمل الذروة'
plugins:
expect: {}

scenarios:
- name: 'الحصول على المنتج (يجب أن يكون مخزناً مؤقتاً)'
weight: 70
flow:
- get:
url: '/api/products/{{ $randomNumber(1, 100) }}'
expect:
- statusCode: 200
- hasHeader: 'X-Cache'

- name: 'قائمة المنتجات (يجب أن تكون مخزنة مؤقتاً)'
weight: 20
flow:
- get:
url: '/api/products?category={{ $randomString() }}'
expect:
- statusCode: 200

- name: 'تحديث المنتج (يبطل الذاكرة المؤقتة)'
weight: 10
flow:
- put:
url: '/api/products/{{ $randomNumber(1, 100) }}'
json:
price: {{ $randomNumber(10, 1000) }}
expect:
- statusCode: 200
// تشغيل اختبار الحمل وجمع المقاييس
// artillery run load-test.yml --output report.json
// artillery report report.json

// سكريبت اختبار حمل مخصص
const autocannon = require('autocannon');

async function runLoadTest() {
const instance = autocannon({
url: 'http://localhost:3000',
connections: 100,
duration: 60,
pipelining: 10,
requests: [
{
method: 'GET',
path: '/api/products/123'
}
]
});

autocannon.track(instance, { renderProgressBar: true });

instance.on('done', (results) => {
console.log('نتائج اختبار الحمل:');
console.log(`الطلبات: ${results.requests.total}`);
console.log(`الإنتاجية: ${results.throughput.mean} طلب/ثانية`);
console.log(`زمن الاستجابة p50: ${results.latency.p50}ms`);
console.log(`زمن الاستجابة p99: ${results.latency.p99}ms`);
console.log(`الأخطاء: ${results.errors}`);
});
}

runLoadTest();
معايير الأداء: يجب أن تحقق ذاكرة Redis المؤقتة المُحسّنة جيداً: معدل نجاح 95%+ للبيانات المتكررة، وقت استجابة أقل من 1 مللي ثانية لنجاحات الذاكرة المؤقتة، 10,000+ طلب/ثانية على عتاد متواضع، ونمو ذاكرة ضئيل مع مرور الوقت.
تمرين: أنشئ مجموعة اختبار شاملة للذاكرة المؤقتة تتضمن:
  • اختبارات الوحدة لجميع طرق خدمة الذاكرة المؤقتة مع الحالات الحدية
  • اختبارات التكامل للتحقق من إبطال الذاكرة المؤقتة عبر جميع نقاط نهاية API
  • اختبارات الأداء لقياس معدلات نجاح الذاكرة المؤقتة تحت الحمل
  • سكريبت تصحيح يحدد مشاكل الذاكرة المؤقتة (البيانات القديمة، المفاتيح الكبيرة، انتهاءات الصلاحية المفقودة)
  • لوحة تحكم مراقبة تعرض مقاييس الذاكرة المؤقتة في الوقت الفعلي والتنبيهات
أفضل ممارسات الاختبار: استخدم دائماً Redis محاكى في اختبارات الوحدة، اختبر إبطال الذاكرة المؤقتة بشكل شامل، راقب معدلات نجاح الذاكرة المؤقتة في الإنتاج، نفذ تنبيهات لمعدلات نجاح منخفضة أو معدلات خطأ عالية، وراجع بانتظام أنماط مفاتيح الذاكرة المؤقتة وقيم TTL.