طَبِّقْهاتعلّم المبدأ، وطبّقه بيدكمقرر 242 · أطر الويب
الدرس 24 · الوحدة 10: تحسين الأداء

تحسين أداء الخادم: التخزين المؤقت والفهارس

خادم يرد أسرع ويتحمل زوّارًا أكثر ⏱ نحو 45 دقيقة.

بعد هذا الدرس ستستطيع أن:
  • تشرح حلقة الأحداث وما يحجب خادم Node.
  • تضيف تخزينًا مؤقتًا بمدة وإبطال.
  • تستخدم ETag و Cache-Control و 304.
  • تضيف فهارس وتقرأ خطة الاستعلام، وتقلل البيانات المرسلة.

١خيط واحد يخدم الجميع

Node.js يشغّل كودك على خيط واحد وحلقة أحداث Event Loop: حين ينتظر طلبٌ قاعدةَ البيانات أو ملفًّا، ينتقل Node لخدمة طلب آخر. لهذا يخدم آلاف الاتصالات بذاكرة قليلة. لكن هذا يعني أيضًا: حساب ثقيل متزامن يجمّد الخادم كله، فلا يرد على أحد حتى ينتهي.

const express = require('express');const app = express(); app.get('/heavy', (req, res) => {  const t0 = Date.now();  let x = 0;  for (let i = 0; i < 3e7; i++) x += i % 7;     // حساب متزامن يحجز الخيط  console.log('انتهى الحساب الثقيل بعد', Date.now() - t0, 'ms');  res.json({ x });}); app.get('/ping', (req, res) => res.send('pong')); app.listen(3000);

في خادم حقيقي، لو وصل طلب /ping أثناء حساب /heavy لانتظر حتى ينتهي. الحل: تجنّب الحسابات الثقيلة في المعالج، أو انقلها إلى Worker Threads أو خدمة منفصلة أو مهمة في الخلفية.

يحجب الخادم ✗لا يحجبه ✓
fs.readFileSync داخل معالجawait fs.promises.readFile
حلقة ضخمة أو JSON.parse لملف ضخمتقسيم العمل، أو Worker Threads
bcrypt.hashSync(p, 14)await bcrypt.hash(p, 10)

٢التخزين المؤقت في الذاكرة

أسرع استعلام هو الذي لا يُنفَّذ. إن كانت البيانات تُطلب كثيرًا وتتغير قليلًا (قائمة المقررات، إحصاءات الصفحة الرئيسية)، نحفظ النتيجة في الذاكرة مدة محددة TTL، ونردها مباشرة:

const express = require('express');const app = express(); // محاكاة استعلام بطيء (300 ملّي ثانية)const slowDb = () => new Promise(r => setTimeout(() => r(['CS241', 'CS242', 'CS243']), 300)); const cache = new Map();                     // المفتاح ← { value, expires }async function cached(key, ttlMs, load) {  const hit = cache.get(key);  if (hit && hit.expires > Date.now()) return { value: hit.value, hit: true };  const value = await load();  cache.set(key, { value, expires: Date.now() + ttlMs });  return { value, hit: false };} app.get('/api/courses', async (req, res) => {  const t0 = Date.now();  const { value, hit } = await cached('courses', 60_000, slowDb);  res.set('X-Cache', hit ? 'HIT' : 'MISS');  console.log(hit ? 'من الذاكرة' : 'من قاعدة البيانات', Date.now() - t0, 'ms');  res.json(value);}); app.post('/api/courses', (req, res) => {  cache.delete('courses');                   // أي تعديل يُبطل المخزّن  res.status(201).json({ ok: true });}); app.listen(3000);

الطلب الأول MISS (300 ms)، والثاني HIT فوري. وبعد الإضافة أُبطل المخزّن فعاد الطلب التالي إلى قاعدة البيانات. الترويسة X-Cache تساعدك على المراقبة.

٣تخزين المتصفح: ETag و 304

المتصفح يستطيع أن يسأل: «عندي نسخة بصمتها كذا، هل تغيّرت؟» بالترويسة If-None-Match. Express يضيف بصمة ETag لكل رد تلقائيًّا، فإن طابقت يرد 304 Not Modified بلا جسم: توفير في الشبكة. ومع Cache-Control: max-age لا يسأل المتصفح أصلًا خلال المدة:

const express = require('express');const app = express(); app.get('/api/menu', (req, res) => {  res.set('Cache-Control', 'public, max-age=300');   // 5 دقائق بلا سؤال  res.json({ items: ['كبسة', 'سليق', 'جريش'], day: 'الأحد' });}); app.listen(3000);

الطلب الثاني أرسل ETag الذي عاد في الأول، فرد الخادم 304 بلا محتوى، واستخدم المتصفح نسخته المحفوظة.

٤الفهارس: من مسح الجدول إلى البحث المباشر

بلا فهرس Index، البحث عن طالب برقم هويته يعني أن قاعدة البيانات تقرأ كل الصفوف (Full scan). الفهرس بنية مرتبة (كفهرس الكتاب) توصل للصف مباشرة. EXPLAIN QUERY PLAN يكشف ما تفعله قاعدة البيانات:

const express = require('express');const { Sequelize, DataTypes } = require('sequelize');const db = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }); const Student = db.define('Student', {  nationalId: DataTypes.STRING,  name: DataTypes.STRING,  major: DataTypes.STRING,}, {  indexes: [{ unique: true, fields: ['nationalId'] }],   // ← فهرس}); const app = express();const plan = async (sql) => (await db.query('EXPLAIN QUERY PLAN ' + sql, { type: 'SELECT' })).map(r => r.detail); app.get('/plan', async (req, res) => {  res.json({    byNationalId: await plan("SELECT * FROM Students WHERE nationalId = '1100'"),    byMajor: await plan("SELECT * FROM Students WHERE major = 'CS'"),  });}); db.sync().then(() => app.listen(3000));

البحث بـ nationalId يستخدم الفهرس (SEARCH … USING INDEX)، أما البحث بـ major فيمسح الجدول كله (SCAN). مع مليون طالب يكون الفرق بين ملّي ثانية وثوانٍ.

ضع فهرسًا علىولا تبالغ لأن
الأعمدة المستخدمة في WHERE كثيرًاكل فهرس يبطّئ الإضافة والتعديل قليلًا
المفاتيح الأجنبية (AuthorId…) المستخدمة في JOINويأخذ مساحة تخزين
أعمدة الفرز ORDER BY في القوائم الكبيرةالجداول الصغيرة لا تحتاجه

٥أرسل أقل

التقنيةفي Sequelize/Express
الأعمدة المطلوبة فقطattributes: ['id', 'title']
الترقيم دائمًاlimit و offset مع حد أعلى
JOIN بدل N+1include
ضغط الردودوسيط compression() (gzip) أو من الخادم الأمامي
عدّ بلا جلبModel.count() بدل findAll().length

توقّع وتحقّق

لماذا يجمّد حساب متزامن ثقيل خادم Node كله؟

Event loop.

ما معنى الرد 304؟

Not Modified.

ماذا يعني SCAN في خطة الاستعلام؟

Full scan.

متى يجب إبطال المخزّن المؤقت؟

Invalidation.

الخلاصة

تحدٍّ برمجي: ذاكرة مؤقتة لإحصاءات الصفحة الرئيسية

متقدم

المسار /api/stats يحسب إحصاءات من قاعدة بيانات بطيئة في كل طلب. أضف تخزينًا مؤقتًا.

المطلوب:
  • أول طلب يحسب من slowStats() ويرد بالترويسة X-Cache: MISS.
  • الطلبات التالية خلال 30 ثانية ترد من الذاكرة بـ X-Cache: HIT دون استدعاء slowStats (يطبع «حساب»).
  • POST /api/orders يضيف طلبًا ويُبطل المخزّن، فيعود الطلب التالي MISS بالأرقام الجديدة.
👀 اعرض الاستجابات المطلوبة
✓ التحقق الآلي: يشغّل المختبر خادمك من جديد ويرسل إليه هذه الطلبات بالترتيب ثم يفحص الردود
  1. GET /api/stats
  2. GET /api/stats
  3. GET /api/stats
  4. POST /api/orders
  5. GET /api/stats
  • الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
  • الأول MISS
  • الثاني HIT
  • والقيم نفسها
  • الثالث HIT
  • بعد الإضافة MISS
  • … بالأرقام الجديدة (121)
  • الحساب البطيء نُفّذ مرتين فقط (الطرفية)

💡 تلميح 1
متغير cache = { value, expires } خارج المعالج.
💡 تلميح 2
في POST: cache = null.

حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.

const express = require('express');const app = express();let orders = 120;let cache = null; const slowStats = () => new Promise(r => setTimeout(() => {  console.log('حساب');  r({ orders, revenue: orders * 50 });}, 200)); app.get('/api/stats', async (req, res) => {  if (cache && cache.expires > Date.now()) {    res.set('X-Cache', 'HIT');    return res.json(cache.value);  }  const value = await slowStats();  cache = { value, expires: Date.now() + 30_000 };  res.set('X-Cache', 'MISS');  res.json(value);}); app.post('/api/orders', (req, res) => {  orders++;  cache = null;  res.status(201).json({ orders });}); app.listen(3000);
انتقل إلى تقييم الدرساختيار من متعدد، وصح وخطأ، وأكمل الفراغ، وتتبّع الناتج.ابدأ التقييم ←

📝 معلم؟ أرسل ملاحظة للمؤلف عن هذا الدرس