فهارس قاعدة البيانات: متى تسرّع الاستعلام ومتى لا تفيد

١٠ دقائق للقراءة

الفهرس (Index) يجعل البحث عن صفوف محددة في قاعدة البيانات أسرع كثيرًا، لكنه يستهلك مساحة ويبطئ الكتابة. في هذا الدليل: متى تحتاجه، وكيف تتأكد من أنه يعمل.

في أغلب الأنظمة التي تبدأ صغيرة، يكون كل شيء سريعًا في البداية. ثم يكبر جدول الطلبات إلى مئات الآلاف من الصفوف، فتصبح صفحة كانت تفتح فورًا تحتاج ثواني. في حالات كثيرة لا يكون السبب ضعف الخادم ولا اللغة المستخدمة، بل استعلام يجبر قاعدة البيانات على قراءة الجدول كله ليجد صفوفًا قليلة. هذا الدليل يشرح الفهارس من زاوية عملية: ماذا تفعل، وكيف تقرأ خطة التنفيذ، ولماذا تتجاهل قاعدة البيانات الفهرس أحيانًا، ومتى يكون إضافة فهرس قرارًا خاطئًا. الأمثلة مكتوبة لـ PostgreSQL، والأفكار نفسها تنطبق على MySQL وغيرها مع اختلافات بسيطة في الأوامر.

ما هو الفهرس ببساطة؟

تخيّل كتابًا من ألف صفحة بلا فهرس في آخره. إذا أردت كل الصفحات التي تذكر كلمة معينة، فستقلّب الكتاب كله. الفهرس في آخر الكتاب يحل المشكلة: ترتيب أبجدي للكلمات، وأمام كل كلمة أرقام الصفحات.

قاعدة البيانات تعمل بالطريقة نفسها. عندما تكتب:

sql
SELECT * FROM orders WHERE customer_id = 42;

ولا يوجد فهرس على العمود customer_id، قد تضطر قاعدة البيانات إلى قراءة كل صفوف الجدول والتحقق من كل صف. هذا ما يسمى المسح الكامل للجدول (Full Table Scan، ويظهر في PostgreSQL باسم Seq Scan). على جدول من مئة صف لا يهم ذلك. على جدول من مليون صف يصبح مكلفًا، ويتكرر مع كل زائر يفتح الصفحة.

أما مع الفهرس، فتذهب قاعدة البيانات مباشرة إلى المواضع التي فيها customer_id = 42، ثم تقرأ تلك الصفوف فقط.

كيف يعمل الفهرس من الداخل؟

النوع الافتراضي من الفهارس في PostgreSQL وفي محرك InnoDB في MySQL هو شجرة B-tree. هي بنية مرتبة ومتوازنة: كل مستوى فيها يضيّق نطاق البحث، فتصل قاعدة البيانات إلى القيمة المطلوبة بعدد قليل من الخطوات حتى لو كان الجدول ضخمًا، لأن عدد الخطوات ينمو ببطء شديد مع نمو عدد الصفوف.

ولأن الشجرة مرتبة، تفيد B-tree في أكثر من نوع من الاستعلامات:

  • المساواة: WHERE customer_id = 42
  • النطاقات: WHERE created_at >= '2026-11-01'
  • الترتيب: ORDER BY created_at عندما يطابق ترتيب الفهرس
  • البحث ببداية النص في بعض الحالات، مثل LIKE 'abc%' (في PostgreSQL يتوقف ذلك على إعدادات الترتيب Collation أو على نوع فهرس خاص)

توجد أنواع أخرى من الفهارس لاستخدامات محددة، مثل فهارس النص الكامل (Full-text) وفهارس GIN للمصفوفات وبيانات JSON، لكن B-tree هو ما ستحتاجه في أغلب أنظمة الشركات.

مثال عملي: جدول طلبات بمليون صف

لتجرّب بنفسك، أنشئ جدولًا تجريبيًا على قاعدة PostgreSQL محلية (وليس على قاعدة إنتاج):

sql
CREATE TABLE orders (
  id          bigserial PRIMARY KEY,
  customer_id int NOT NULL,
  created_at  timestamptz NOT NULL,
  total       numeric(12, 2) NOT NULL
);

INSERT INTO orders (customer_id, created_at, total)
SELECT (random() * 20000)::int,
       now() - random() * interval '365 days',
       round((random() * 5000)::numeric, 2)
FROM generate_series(1, 1000000);

ANALYZE orders;

هذا يُنشئ مليون طلب موزعة على نحو عشرين ألف عميل، أي نحو خمسين طلبًا لكل عميل.

اقرأ خطة التنفيذ بـ EXPLAIN ANALYZE

بدل أن تخمّن سبب البطء، اطلب من قاعدة البيانات أن تخبرك كيف نفذت الاستعلام:

sql
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;

ستجد في أول سطر من النتيجة Seq Scan on orders، وقد يظهر Parallel Seq Scan إذا استخدمت قاعدة البيانات أكثر من نواة للمعالج. وستجد سطرًا بعنوان Rows Removed by Filter برقم كبير: هذا عدد الصفوف التي قرأتها قاعدة البيانات ثم استبعدتها لأنها لا تطابق الشرط.

الآن أضف الفهرس وأعد التشغيل:

sql
CREATE INDEX idx_orders_customer ON orders (customer_id);

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;

ستتغير الخطة إلى Index Scan أو إلى Bitmap Index Scan يليه Bitmap Heap Scan. كلاهما يعني أن الفهرس استُخدم. قارن قيمة Execution Time في آخر النتيجة قبل الفهرس وبعده على جهازك.

ما يظهر في الخطةالمعنى
Seq Scan / Parallel Seq Scanقراءة الجدول كاملًا
Index Scanالبحث في الفهرس ثم قراءة كل صف مطابق
Bitmap Index Scan + Bitmap Heap Scanجمع مواضع الصفوف من الفهرس أولًا ثم قراءتها مرتبة؛ شائع عندما تكون الصفوف المطابقة كثيرة نسبيًا
Index Only Scanالإجابة من الفهرس وحده دون قراءة الجدول، عندما يحتوي الفهرس كل الأعمدة المطلوبة

ملاحظتان مهمتان قبل أن تستخدم هذا الأمر في عملك.

الأولى: EXPLAIN وحده يعرض الخطة المتوقعة، أما EXPLAIN ANALYZE فينفذ الاستعلام فعلًا. إذا أردت فحص UPDATE أو DELETE، فضعه داخل معاملة (Transaction) وتراجع عنها:

sql
BEGIN;
EXPLAIN ANALYZE UPDATE orders SET total = total WHERE customer_id = 42;
ROLLBACK;

الثانية: في MySQL يوجد EXPLAIN أيضًا، وEXPLAIN ANALYZE متاح منذ الإصدار 8.0.18. شكل النتيجة مختلف، لكن الفكرة واحدة.

لماذا تتجاهل قاعدة البيانات الفهرس أحيانًا؟

قد تضيف فهرسًا ثم تجد الخطة ما زالت Seq Scan. هذا ليس دائمًا خطأ. مخطِّط الاستعلامات (Query Planner) يقدّر تكلفة كل طريقة ويختار الأرخص. أشهر الأسباب:

  • الجدول صغير. قراءة بضع صفحات من الجدول مباشرة أرخص من المرور على الفهرس ثم الجدول.
  • الشرط يطابق نسبة كبيرة من الصفوف. إذا كان الاستعلام سيعيد نصف الجدول، فالمسح الكامل غالبًا أسرع. لذلك يقل نفع الفهرس على عمود مثل status إذا كان معظم الصفوف بالقيمة نفسها.
  • دالة على العمود. الشرط WHERE lower(email) = 'a@example.com' لا يستفيد من فهرس عادي على email، لأن الفهرس مرتب بالقيمة الأصلية. الحل فهرس على التعبير نفسه:
sql
CREATE INDEX idx_users_email_lower ON users (lower(email));
  • علامة البدل في أول النص. الشرط LIKE '%ahmed' لا يستطيع استخدام فهرس B-tree عادي، لأن الفهرس مرتب ببداية النص لا بنهايته.
  • إحصاءات قديمة. إذا تغيرت البيانات كثيرًا ولم تُحدَّث الإحصاءات، فقد يخطئ المخطِّط في التقدير. الأمر ANALYZE orders; يحدّثها، ويقوم بذلك أيضًا الـ autovacuum تلقائيًا في الظروف العادية.

الفهارس المركبة: ترتيب الأعمدة مهم

الفهرس المركب (Composite Index) هو فهرس على أكثر من عمود، مثل:

sql
CREATE INDEX idx_orders_customer_date ON orders (customer_id, created_at);

هذا الفهرس مرتب حسب customer_id أولًا، ثم داخل كل عميل حسب created_at. فكّر في دليل هاتف مرتب بالاسم الأخير ثم بالاسم الأول.

الاستعلامهل يستفيد من الفهرس بكفاءة؟
WHERE customer_id = 42 AND created_at >= '2026-11-01'نعم، يستخدم العمودين
WHERE customer_id = 42نعم، العمود الأول وحده يكفي
WHERE customer_id = 42 ORDER BY created_at DESCنعم، ويمكن أن يستغني عن خطوة ترتيب منفصلة (لكن مع صفوف قليلة قد يفضّل المخطِّط الترتيب بنفسه)
WHERE created_at >= '2026-11-01'غالبًا لا، لأنه يتخطى العمود الأول

القاعدة العملية: ضع أولًا الأعمدة التي تقارنها بالمساواة، ثم العمود الذي تبحث فيه بنطاق أو ترتّب به.

بعض المحركات الحديثة تستطيع أحيانًا استخدام الفهرس رغم تخطي العمود الأول، بأسلوب يسمى Skip Scan: أضيف إلى PostgreSQL في الإصدار 18، ويوجد في MySQL منذ 8.0.13. لكنه يفيد في حالات محددة، غالبًا عندما يكون عدد القيم المختلفة في العمود الأول صغيرًا، فلا تبنِ تصميمك عليه.

تكلفة الفهرس: متى لا تضيفه؟

كل فهرس له ثمن:

  • مساحة تخزين إضافية، وقد تقترب أحيانًا من حجم الجدول نفسه.
  • كتابة أبطأ. كل INSERT يضيف مدخلًا في كل فهرس على الجدول، وكل UPDATE لعمود مفهرس يعدّل الفهرس، وكل DELETE يترك عملًا على الفهارس.
  • عمل إضافي على الصيانة والنسخ الاحتياطي.

لذلك لا تضع فهرسًا على كل عمود "احتياطًا". ابدأ من الاستعلامات البطيئة الفعلية، وأضف الفهرس الذي يخدمها، ثم تأكد بـ EXPLAIN ANALYZE.

ومن وقت لآخر، ابحث عن فهارس لا تُستخدم. في PostgreSQL:

sql
SELECT relname AS table_name,
       indexrelname AS index_name,
       idx_scan
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;

العمود idx_scan يعرض عدد مرات استخدام كل فهرس منذ آخر تصفير للإحصاءات. الفهرس الذي قيمته صفر بعد فترة طويلة من التشغيل الفعلي مرشح للمراجعة، بشرط ألا يكون فهرسًا يضمن قيدًا مثل UNIQUE أو المفتاح الأساسي.

ليس كل بطء سببه غياب الفهرس: مشكلة N+1

أحيانًا يكون كل استعلام سريعًا وحده، لكن الصفحة بطيئة لأنها تنفذ عددًا كبيرًا من الاستعلامات. المثال الشائع: صفحة تعرض 50 طلبًا، فتجلب الطلبات في استعلام واحد، ثم تجلب اسم العميل لكل طلب في استعلام منفصل. النتيجة 51 استعلامًا، وكل استعلام رحلة ذهاب وعودة إلى قاعدة البيانات. تسمى هذه المشكلة N+1، وكثيرًا ما تسببها أدوات الـ ORM دون أن ينتبه المطوّر.

js
// Pseudocode: db.query stands for your database client
// N+1: one query for the list, then one per row
const orders = await db.query(
  'SELECT id, customer_id, total FROM orders ORDER BY id DESC LIMIT 50'
);
for (const order of orders) {
  const [customer] = await db.query(
    'SELECT name FROM customers WHERE id = $1', [order.customer_id]
  );
  order.customerName = customer.name;
}

الحل استعلام واحد بـ JOIN، أو جلب كل العملاء المطلوبين مرة واحدة بشرط WHERE id IN (...). معظم أدوات الـ ORM توفر ذلك باسم Eager Loading أو include أو JOIN FETCH حسب الأداة.

sql
SELECT o.id, o.total, c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY o.id DESC
LIMIT 50;

لاكتشاف المشكلة، فعّل سجل الاستعلامات في بيئة التطوير وعدّ الاستعلامات التي تنفذها الصفحة. إذا زاد العدد مع زيادة عدد الصفوف المعروضة، فلديك N+1.

قائمة مراجعة سريعة

قبل أن تقرر أن الخادم يحتاج ترقية، راجع هذه النقاط:

  1. حدّد الاستعلام البطيء فعلًا. في PostgreSQL يساعد الامتداد pg_stat_statements على معرفة أكثر الاستعلامات استهلاكًا للوقت.
  2. شغّل EXPLAIN ANALYZE على نسخة من البيانات، وابحث عن Seq Scan على جداول كبيرة.
  3. أضف فهرسًا على الأعمدة المستخدمة في WHERE وJOIN وORDER BY لذلك الاستعلام، مع مراعاة ترتيب الأعمدة في الفهرس المركب.
  4. تأكد من أن الشرط لا يطبق دالة على العمود المفهرس، ولا يبدأ بعلامة البدل.
  5. أعد تشغيل EXPLAIN ANALYZE وقارن.
  6. عدّ الاستعلامات في الصفحة لتستبعد مشكلة N+1.
  7. راجع الفهارس غير المستخدمة دوريًا.

الخلاصة

الفهرس من أبسط الأدوات وأكثرها تأثيرًا في أداء الأنظمة، لكنه ليس مجانيًا. استخدمه حيث يخدم استعلامًا حقيقيًا، وتأكد من أثره بخطة التنفيذ لا بالتخمين.

إذا كان لديك نظام أعمال أو تطبيق ويب أصبح بطيئًا مع نمو البيانات، فهذا النوع من المراجعة جزء مما نقوم به عند بناء البرامج المخصصة للأعمال وتطبيقات الويب. يمكنك أن تحكي لنا عن مشروعك، وسنخبرك بصراحة إن كان ما تحتاجه فهرسًا أو تغييرًا أكبر.

شارك هذا المقال

Numpelia هو برنامج الحسابات وإدارة الأعمال الذي نبنيه في JAVELARK.