فهم أداء API
يؤثر أداء API بشكل مباشر على تجربة المستخدم وقابلية التوسع وتكاليف التشغيل. تؤدي واجهات برمجة التطبيقات البطيئة إلى مستخدمين محبطين وتكاليف بنية تحتية أعلى وقدرة نظام مخفضة. يغطي هذا الدرس التقنيات الأساسية لتحديد اختناقات الأداء وتحسين أوقات استجابة API.
مشكلة استعلام N+1
مشكلة استعلام N+1 هي واحدة من أكثر قاتلي الأداء شيوعًا في واجهات برمجة التطبيقات. تحدث عندما تجلب قائمة من N عنصر، ثم تقوم باستعلام قاعدة بيانات إضافي لكل عنصر لجلب البيانات ذات الصلة، مما يؤدي إلى N+1 استعلام إجمالي.
مثال على مشكلة N+1
// سيء: مشكلة استعلام N+1
app.get('/api/posts', async (req, res) => {
// استعلام واحد لجلب جميع المنشورات
const posts = await db.query('SELECT * FROM posts LIMIT 10');
// لكل منشور، جلب المؤلف (10 استعلامات إضافية)
for (let post of posts) {
// N استعلامات (واحد لكل منشور)
post.author = await db.query(
'SELECT * FROM users WHERE id = ?',
[post.user_id]
);
}
res.json(posts);
});
// النتيجة: 1 + 10 = 11 استعلام قاعدة بيانات
// إذا جلبت 100 منشور، ستقوم بـ 101 استعلام!
تأثير الأداء: مع 100 منشور ومتوسط وقت استعلام 50ms، تؤدي مشكلة N+1 إلى 101 استعلام × 50ms = 5,050ms (5 ثوانٍ) إجمالي وقت الاستعلام. هذا غير مقبول لأداء API.
الحل 1: التحميل المسبق (JOIN)
// جيد: استخدام SQL JOIN لجلب البيانات ذات الصلة في استعلام واحد
app.get('/api/posts', async (req, res) => {
const query = `
SELECT
posts.*,
users.id as author_id,
users.name as author_name,
users.email as author_email,
users.avatar as author_avatar
FROM posts
JOIN users ON posts.user_id = users.id
LIMIT 10
`;
const rows = await db.query(query);
// تحويل الصفوف المسطحة إلى كائنات متداخلة
const posts = rows.map(row => ({
id: row.id,
title: row.title,
content: row.content,
created_at: row.created_at,
author: {
id: row.author_id,
name: row.author_name,
email: row.author_email,
avatar: row.author_avatar
}
}));
res.json(posts);
});
// النتيجة: استعلام قاعدة بيانات واحد فقط، بغض النظر عن عدد المنشورات
الحل 2: نمط محمل البيانات (لسيناريوهات GraphQL/المعقدة)
// استخدام DataLoader لدفع واستعلامات قاعدة البيانات المخبأة
const DataLoader = require('dataloader');
// إنشاء DataLoader لدفع استعلامات المستخدم
const userLoader = new DataLoader(async (userIds) => {
// تتلقى هذه الدالة مصفوفة من معرفات المستخدمين
// وتعيد المستخدمين بنفس الترتيب
const users = await db.query(
'SELECT * FROM users WHERE id IN (?)',
[userIds]
);
// إنشاء خريطة للبحث O(1)
const userMap = {};
users.forEach(user => userMap[user.id] = user);
// إعادة المستخدمين بنفس ترتيب المعرفات المطلوبة
return userIds.map(id => userMap[id] || null);
});
app.get('/api/posts', async (req, res) => {
// جلب المنشورات
const posts = await db.query('SELECT * FROM posts LIMIT 10');
// تحميل جميع المؤلفين مرة واحدة (مجمعة في استعلام واحد)
const postsWithAuthors = await Promise.all(
posts.map(async (post) => ({
...post,
author: await userLoader.load(post.user_id)
}))
);
res.json(postsWithAuthors);
});
// النتيجة: استعلامان إجمالاً (1 للمنشورات، 1 لجميع المؤلفين الفريدين)
أفضل ممارسة: استخدم دائمًا التحميل المسبق (JOINs) أو الدفع عند جلب البيانات ذات الصلة. قم بملف تعريف نقاط نهاية API الخاصة بك لتحديد مشاكل N+1 باستخدام سجلات استعلام قاعدة البيانات أو أدوات APM.
فهرسة قاعدة البيانات لواجهات برمجة التطبيقات
الفهرسة المناسبة لقاعدة البيانات أمر بالغ الأهمية لأداء API. تسرع الفهارس استرجاع البيانات ولكنها تبطئ الكتابة، لذا فإن الفهرسة الاستراتيجية ضرورية.
متى تضيف الفهارس
- أعمدة المفتاح الخارجي (user_id، post_id، إلخ.)
- الأعمدة المستخدمة بشكل متكرر في جمل WHERE
- الأعمدة المستخدمة في شروط JOIN
- الأعمدة المستخدمة في جمل ORDER BY
- الأعمدة المستخدمة للتصفية/البحث
أمثلة على الفهارس
-- فهرس عمود واحد لعمليات البحث عن المستخدم
CREATE INDEX idx_posts_user_id ON posts(user_id);
-- فهرس مركب للتصفية حسب المستخدم والحالة
CREATE INDEX idx_posts_user_status ON posts(user_id, status);
-- فهرس لوظيفة البحث
CREATE INDEX idx_posts_title ON posts(title);
-- فهرس النص الكامل لبحث المحتوى
CREATE FULLTEXT INDEX idx_posts_content ON posts(content);
-- فهرس للاستعلامات المستندة إلى التاريخ
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
-- فهرس فريد لعمليات البحث عن البريد الإلكتروني
CREATE UNIQUE INDEX idx_users_email ON users(email);
تحليل أداء الاستعلام
-- تحقق من استخدام الاستعلام للفهارس (MySQL)
EXPLAIN SELECT * FROM posts WHERE user_id = 123;
-- مثال على الإخراج:
-- +----+-------------+-------+------+------------------+---------+---------+-------+------+-------+
-- | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
-- +----+-------------+-------+------+------------------+---------+---------+-------+------+-------+
-- | 1 | SIMPLE | posts | ref | idx_posts_user_id| idx_... | 4 | const | 10 | NULL |
-- +----+-------------+-------+------+------------------+---------+---------+-------+------+-------+
-- type = 'ref' جيد (استخدام الفهرس)
-- type = 'ALL' سيء (مسح الجدول الكامل)
-- نسخة PostgreSQL
EXPLAIN ANALYZE SELECT * FROM posts WHERE user_id = 123;
// مراقبة الاستعلامات البطيئة في API الخاص بك
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
database: 'myapp',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// غلاف لتسجيل الاستعلامات البطيئة
async function executeQuery(sql, params) {
const startTime = Date.now();
try {
const [rows] = await pool.execute(sql, params);
const duration = Date.now() - startTime;
// تسجيل الاستعلامات التي تستغرق أكثر من 100ms
if (duration > 100) {
console.warn(`استعلام بطيء (${duration}ms): ${sql}`, params);
}
return rows;
} catch (error) {
console.error('خطأ في الاستعلام:', sql, params, error);
throw error;
}
}
استراتيجيات الفهرس:
- الفهارس المركبة: الترتيب مهم! ضع العمود الأكثر انتقائية أولاً
- فهارس التغطية: قم بتضمين جميع الأعمدة المطلوبة في الاستعلام لتجنب عمليات البحث في الجدول
- قوة الفهرس: قوة عالية (العديد من القيم الفريدة) = جيد للفهرسة
- صيانة الفهرس: قم بتحليل وتحسين الفهارس بانتظام؛ أزل الفهارس غير المستخدمة
ضغط الاستجابة
يقلل ضغط استجابات API من استخدام النطاق الترددي ويحسن أوقات الاستجابة، خاصة للعملاء على الشبكات البطيئة.
تنفيذ ضغط Gzip في Express.js
const express = require('express');
const compression = require('compression');
const app = express();
// تمكين ضغط gzip لجميع الاستجابات
app.use(compression({
// مستوى الضغط (0-9، أعلى = ضغط أفضل لكن أبطأ)
level: 6,
// الحد الأدنى لحجم الاستجابة للضغط (بايت)
threshold: 1024, // ضغط الاستجابات فقط > 1KB
// تصفية الاستجابات التي يجب ضغطها
filter: (req, res) => {
// لا تضغط الاستجابات بهذا رأس الطلب
if (req.headers['x-no-compression']) {
return false;
}
// استخدام دالة تصفية الضغط
return compression.filter(req, res);
}
}));
app.get('/api/posts', async (req, res) => {
const posts = await fetchPosts();
res.json(posts);
// يتم ضغط الاستجابة تلقائيًا إذا > 1KB
});
أفضل ممارسات الضغط
// تكوين ضغط مخصص
const compression = require('compression');
app.use(compression({
level: 6,
threshold: 1024,
filter: (req, res) => {
const contentType = res.getHeader('Content-Type');
// ضغط استجابات JSON والنص
if (contentType && (
contentType.includes('application/json') ||
contentType.includes('text/')
)) {
return true;
}
// لا تضغط الصور (مضغوطة بالفعل)
if (contentType && contentType.includes('image/')) {
return false;
}
return compression.filter(req, res);
}
}));
// قياس التوفير من الضغط
app.use((req, res, next) => {
const oldWrite = res.write;
const oldEnd = res.end;
const chunks = [];
res.write = function(chunk) {
chunks.push(Buffer.from(chunk));
return oldWrite.apply(res, arguments);
};
res.end = function(chunk) {
if (chunk) {
chunks.push(Buffer.from(chunk));
}
const uncompressedSize = Buffer.concat(chunks).length;
const compressedSize = parseInt(res.getHeader('Content-Length') || 0);
if (compressedSize > 0) {
const savings = ((1 - compressedSize / uncompressedSize) * 100).toFixed(1);
console.log(`الضغط: ${uncompressedSize}B → ${compressedSize}B (${savings}% موفر)`);
}
return oldEnd.apply(res, arguments);
};
next();
});
نتائج الضغط: نسب الضغط النموذجية لـ JSON:
- JSON مع بيانات متكررة: انخفاض 70-90%
- استجابات JSON متوسطة: انخفاض 60-70%
- استجابات صغيرة (<1KB): التحميل الزائد للضغط لا يستحق ذلك
استراتيجيات التخزين المؤقت للاستجابة
التخزين المؤقت هو واحد من أكثر الطرق فعالية لتحسين أداء API عن طريق تجنب الحسابات الزائدة واستعلامات قاعدة البيانات.
رؤوس ذاكرة التخزين المؤقت HTTP
// تعيين رؤوس ذاكرة التخزين المؤقت للبيانات الثابتة
app.get('/api/countries', (req, res) => {
// التخزين المؤقت لمدة ساعة واحدة (3600 ثانية)
res.set({
'Cache-Control': 'public, max-age=3600',
'Expires': new Date(Date.now() + 3600000).toUTCString()
});
res.json(countries);
});
// ETags للطلبات المشروطة
app.get('/api/posts/:id', async (req, res) => {
const post = await fetchPost(req.params.id);
// توليد ETag من بيانات المنشور
const etag = generateETag(post);
res.set('ETag', etag);
// تحقق من أن العميل لديه نسخة مخبأة
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // لم يتم التعديل
}
res.json(post);
});
function generateETag(data) {
const crypto = require('crypto');
return crypto
.createHash('md5')
.update(JSON.stringify(data))
.digest('hex');
}
طبقة التخزين المؤقت Redis
const Redis = require('ioredis');
const redis = new Redis({
host: 'localhost',
port: 6379,
maxRetriesPerRequest: 3
});
// middleware ذاكرة التخزين المؤقت
function cacheMiddleware(duration) {
return async (req, res, next) => {
// توليد مفتاح ذاكرة التخزين المؤقت من URL ومعلمات الاستعلام
const cacheKey = `cache:${req.originalUrl}`;
try {
// فحص ذاكرة التخزين المؤقت
const cachedData = await redis.get(cacheKey);
if (cachedData) {
console.log('إصابة ذاكرة التخزين المؤقت:', cacheKey);
res.set('X-Cache', 'HIT');
return res.json(JSON.parse(cachedData));
}
console.log('فقدان ذاكرة التخزين المؤقت:', cacheKey);
res.set('X-Cache', 'MISS');
// تجاوز res.json لتخزين الاستجابة مؤقتًا
const originalJson = res.json.bind(res);
res.json = function(data) {
// تخزين الاستجابة مؤقتًا
redis.setex(cacheKey, duration, JSON.stringify(data))
.catch(err => console.error('خطأ في تعيين ذاكرة التخزين المؤقت:', err));
return originalJson(data);
};
next();
} catch (error) {
console.error('خطأ في ذاكرة التخزين المؤقت:', error);
next(); // المتابعة بدون ذاكرة تخزين مؤقت عند حدوث خطأ
}
};
}
// الاستخدام: تخزين المنشورات مؤقتًا لمدة 5 دقائق (300 ثانية)
app.get('/api/posts', cacheMiddleware(300), async (req, res) => {
const posts = await fetchPosts();
res.json(posts);
});
// إبطال ذاكرة التخزين المؤقت عند تغيير البيانات
app.post('/api/posts', async (req, res) => {
const post = await createPost(req.body);
// مسح ذاكرات التخزين المؤقت ذات الصلة
await redis.del('cache:/api/posts');
await redis.del(`cache:/api/posts/${post.id}`);
res.status(201).json(post);
});
إبطال ذاكرة التخزين المؤقت المتقدم
// إبطال ذاكرة التخزين المؤقت القائم على العلامات
class CacheManager {
constructor(redis) {
this.redis = redis;
}
// تخزين البيانات مع العلامات
async set(key, data, ttl, tags = []) {
const cacheKey = `cache:${key}`;
// تخزين البيانات
await this.redis.setex(cacheKey, ttl, JSON.stringify(data));
// ربط العلامات بهذا المفتاح
for (const tag of tags) {
await this.redis.sadd(`tag:${tag}`, cacheKey);
}
}
// الحصول على البيانات المخزنة مؤقتًا
async get(key) {
const cacheKey = `cache:${key}`;
const data = await this.redis.get(cacheKey);
return data ? JSON.parse(data) : null;
}
// إبطال حسب العلامة
async invalidateTag(tag) {
const tagKey = `tag:${tag}`;
// الحصول على جميع المفاتيح بهذه العلامة
const keys = await this.redis.smembers(tagKey);
if (keys.length > 0) {
// حذف جميع المفاتيح
await this.redis.del(...keys);
// حذف مجموعة العلامات
await this.redis.del(tagKey);
}
console.log(`تم إبطال ${keys.length} إدخالات ذاكرة تخزين مؤقت للعلامة: ${tag}`);
}
}
const cacheManager = new CacheManager(redis);
// التخزين المؤقت مع العلامات
app.get('/api/posts', async (req, res) => {
const cacheKey = 'posts:list';
const cached = await cacheManager.get(cacheKey);
if (cached) {
return res.json(cached);
}
const posts = await fetchPosts();
// التخزين المؤقت مع العلامات
await cacheManager.set(cacheKey, posts, 300, ['posts', 'listings']);
res.json(posts);
});
// إبطال جميع ذاكرات التخزين المؤقت المتعلقة بالمنشورات
app.post('/api/posts', async (req, res) => {
const post = await createPost(req.body);
// إبطال جميع ذاكرات التخزين المؤقت الموسومة بـ 'posts'
await cacheManager.invalidateTag('posts');
res.status(201).json(post);
});
تحديات إبطال ذاكرة التخزين المؤقت: إبطال ذاكرة التخزين المؤقت صعب بشكل سيء السمعة. كن متحفظًا مع TTLs لذاكرة التخزين المؤقت للبيانات التي تتغير بشكل متكرر. فكر في استخدام TTLs أقصر مع التحديث في الخلفية لتجربة مستخدم أفضل.
تجميع اتصال قاعدة البيانات
يعيد تجميع الاتصال استخدام اتصالات قاعدة البيانات بدلاً من إنشاء اتصالات جديدة لكل طلب، مما يحسن الأداء بشكل كبير.
// تجميع اتصال MySQL
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
database: 'myapp',
// تكوين المجموعة
waitForConnections: true, // انتظر إذا لم تكن هناك اتصالات متاحة
connectionLimit: 10, // حد أقصى 10 اتصالات متزامنة
maxIdle: 10, // الحد الأقصى للاتصالات الخاملة
idleTimeout: 60000, // إغلاق الاتصالات الخاملة بعد دقيقة واحدة
queueLimit: 0, // لا يوجد حد على الطلبات في قائمة الانتظار
enableKeepAlive: true, // الحفاظ على الاتصالات حية
keepAliveInitialDelay: 0
});
// Middleware لإضافة المجموعة إلى كائن req
app.use((req, res, next) => {
req.db = pool;
next();
});
// استخدام الاتصالات المجمعة
app.get('/api/posts', async (req, res) => {
try {
const [rows] = await req.db.query('SELECT * FROM posts LIMIT 10');
res.json(rows);
} catch (error) {
console.error('خطأ في قاعدة البيانات:', error);
res.status(500).json({ error: 'خطأ في قاعدة البيانات' });
}
});
// مراقبة إحصائيات المجموعة
setInterval(() => {
console.log('إحصائيات مجموعة الاتصال:', {
total: pool.pool._allConnections.length,
active: pool.pool._allConnections.length - pool.pool._freeConnections.length,
idle: pool.pool._freeConnections.length
});
}, 30000); // كل 30 ثانية
الترقيم والحد من النتائج
قم دائمًا بترقيم مجموعات النتائج الكبيرة لتجنب مشاكل الذاكرة وتحسين أوقات الاستجابة.
// ترقيم فعال مع limit/offset
app.get('/api/posts', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 10, 100); // حد أقصى 100 لكل صفحة
const offset = (page - 1) * limit;
try {
// الحصول على العدد الإجمالي
const [countResult] = await db.query('SELECT COUNT(*) as total FROM posts');
const total = countResult[0].total;
// الحصول على البيانات المرقمة
const [posts] = await db.query(
'SELECT * FROM posts ORDER BY created_at DESC LIMIT ? OFFSET ?',
[limit, offset]
);
res.json({
data: posts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1
}
});
} catch (error) {
res.status(500).json({ error: 'خطأ في قاعدة البيانات' });
}
});
الترقيم القائم على المؤشر (لمجموعات البيانات الكبيرة)
// أكثر كفاءة لمجموعات البيانات الكبيرة
app.get('/api/posts', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 10, 100);
const cursor = req.query.cursor; // معرف آخر عنصر من الصفحة السابقة
let query;
let params;
if (cursor) {
// جلب العناصر بعد المؤشر
query = 'SELECT * FROM posts WHERE id < ? ORDER BY id DESC LIMIT ?';
params = [cursor, limit + 1]; // جلب واحد إضافي للتحقق من وجود صفحة تالية
} else {
// الصفحة الأولى
query = 'SELECT * FROM posts ORDER BY id DESC LIMIT ?';
params = [limit + 1];
}
const [posts] = await db.query(query, params);
const hasNext = posts.length > limit;
const data = hasNext ? posts.slice(0, -1) : posts;
res.json({
data,
pagination: {
limit,
nextCursor: hasNext ? data[data.length - 1].id : null,
hasNext
}
});
});
أداء الترقيم: يعمل الترقيم القائم على المؤشر بشكل أفضل من الترقيم القائم على الإزاحة لمجموعات البيانات الكبيرة لأنه لا يتطلب حساب جميع الصفوف ويستخدم الأعمدة المفهرسة (عادةً المفتاح الأساسي) للاستعلامات الفعالة.
المعالجة غير المتزامنة للعمليات الثقيلة
انقل المهام التي تستغرق وقتًا طويلاً إلى وظائف الخلفية للحفاظ على سرعة استجابات API.
// استخدام قائمة انتظار Bull لوظائف الخلفية
const Queue = require('bull');
const emailQueue = new Queue('email', 'redis://localhost:6379');
// نقطة نهاية API تعود على الفور
app.post('/api/users', async (req, res) => {
const user = await createUser(req.body);
// قائمة انتظار بريد إلكتروني ترحيبي (معالج في الخلفية)
await emailQueue.add({
type: 'welcome',
userId: user.id,
email: user.email
});
res.status(201).json(user);
});
// عامل الخلفية يعالج الوظائف
emailQueue.process(async (job) => {
const { type, userId, email } = job.data;
if (type === 'welcome') {
await sendWelcomeEmail(email);
console.log(`تم إرسال بريد إلكتروني ترحيبي إلى ${email}`);
}
});
تمرين: قم بتحسين نقطة نهاية API بطيئة بالخصائص التالية:
- نقطة النهاية:
GET /api/users/:id/dashboard
- وقت الاستجابة الحالي: 3.5 ثوانٍ
- تجلب: ملف تعريف المستخدم، الطلبات الأخيرة (آخر 10)، إحصائيات الطلبات، توصيات المنتج
- لديها مشكلة استعلام N+1 عند تحميل عناصر الطلب
- لم يتم تنفيذ أي تخزين مؤقت
- لا توجد فهارس قاعدة بيانات على المفاتيح الخارجية
- تعيد 500KB JSON غير مضغوط
المهام:
- إصلاح مشكلة استعلام N+1 باستخدام JOINs أو التحميل المسبق
- إضافة فهارس قاعدة بيانات مناسبة (user_id، order_id، created_at)
- تنفيذ التخزين المؤقت Redis مع TTL 5 دقائق
- تمكين ضغط gzip
- نقل توليد التوصيات إلى وظيفة خلفية
- إضافة تسجيل وقت الاستجابة
الهدف: تقليل وقت الاستجابة إلى أقل من 200ms للطلبات المخزنة مؤقتًا وأقل من 800ms للطلبات غير المخزنة مؤقتًا.