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

بقلم فريق تقني ·· برمجة
الفهرس المركب في SQL: ترتيب الأعمدة يحسم الأداء

القاعدة التي تحلّ أغلب مشاكل الاستعلامات البطيئة تُختصر في سطر: ضع أعمدة المساواة أولاً، ثم عمود المدى، ثم عمود الترتيب. الأعمدة التي تقارنها بـ= تتصدّر تعريف الفهرس، ويليها العمود الذي تقارنه بـ> أو < أو BETWEEN، ثم عمود ORDER BY.

ثلاثة فهارس مفردة على ثلاثة أعمدة لا تعوّض فهرساً مركّباً واحداً بترتيب صحيح. أقصى ما يفعله المخطِّط دمج فهرسين — خريطة بتات في PostgreSQL، وتقاطع مُعرِّفات الصفوف في MySQL — والدمج يُلغي ترتيب الفهارس الأصلية فيستدعي فرزاً منفصلاً مع ORDER BY.

الفارق في المثال أدناه، على جدول تجريبي بمليوني صف: 613 مللي ثانية مقابل 0.079 مللي ثانية.

الفهرس المركّب ترتيبٌ مجمّد، لا مجموعة أعمدة

أدقّ وصف للفهرس على (customer_id, status, created_at) أنه نتيجة ORDER BY customer_id, status, created_at مجمّدة على القرص: الصفوف مفروزة حسب customer_id، وداخل كل قيمة متساوية حسب status، وداخل كل زوج متساوٍ حسب created_at.

من هذا الوصف وحده تُشتقّ بقية القواعد. إن عرفت customer_id قفزت مباشرة إلى منطقته. وإن عرفت status وحده، فصفوف paid موزّعة داخل كل عميل على حدة — أي على امتداد الشجرة كلها، بلا موضع واحد تقفز إليه. والأهم أن created_at مرتّب داخل كل مجموعة (customer_id, status) وحدها، لا على مستوى الفهرس ككل؛ فهو يخدم ORDER BY created_at فقط حين تكون الأعمدة السابقة مثبّتة بشروط مساواة.

البادئة اليسارية: MySQL يرفض معمارياً وPostgreSQL يرفض اقتصادياً

توثيق MySQL ينصّ صراحةً: لا يستطيع المحرّك استخدام الفهرس للبحث إن لم تشكّل أعمدة WHERE بادئة يسارية منه. فهرس على (col1, col2, col3) يخدم (col1) و(col1, col2) و(col1, col2, col3) — وهذا كل شيء. ومع شرط WHERE col2 = val وحده لا يُستخدم الفهرس للبحث.

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

هناك استثناء واحد اسمه المسح القافز: يقفز المحرّك بين القيم المميزة للعمود القائد ليستعمل بقية الفهرس. أُضيف في MySQL 8.0.13 (أكتوبر 2018) ويظهر في Extra بقيمة Using index for skip scan، ووصل إلى PostgreSQL 18 (صدر في 25 سبتمبر 2025). لكنه مشروط بأن تكون قيم العمود القائد قليلة بما يكفي ليتخطّى المسح معظم صفحات الفهرس. لا تبنِ تصميمك عليه.

تشريح لوحة طلبات العميل: مليونا صف مقابل 734

بنية الجدول التي سنعمل عليها:

CREATE TABLE orders (
  id          bigserial PRIMARY KEY,
  customer_id bigint      NOT NULL,
  status      text        NOT NULL,
  created_at  timestamptz NOT NULL,
  total       numeric(10,2) NOT NULL
);

والاستعلام المستهدف — لوحة يفتحها العميل ليرى آخر طلباته المدفوعة:

SELECT id, total, created_at
FROM orders
WHERE customer_id = 4821
  AND status = 'paid'
  AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;

المخرجات من جدول تجريبي بنحو مليوني صف، والأرقام توضيحية معروضة بالتنسيق الرسمي لكل محرّك. قبل أي فهرس، PostgreSQL:

 Limit  (cost=38196.53..38196.58 rows=20 width=26) (actual time=612.944..612.951 rows=20.00 loops=1)
   ->  Sort  (cost=38196.53..38198.06 rows=612 width=26) (actual time=612.942..612.946 rows=20.00 loops=1)
         Sort Key: created_at DESC
         Sort Method: top-N heapsort  Memory: 27kB
         ->  Seq Scan on orders  (cost=0.00..38180.25 rows=612 width=26) (actual time=0.412..612.283 rows=734.00 loops=1)
               Filter: ((customer_id = 4821) AND (status = 'paid'::text) AND (created_at >= '2026-01-01 00:00:00+03'::timestamptz))
               Rows Removed by Filter: 1999266
               Buffers: shared hit=1024 read=17171
 Execution Time: 613.402 ms

السطر الفاضح هو Rows Removed by Filter: 1999266. قرأ المحرّك مليوني صف ليحتفظ بـ734. والفهرس المطابق للقاعدة:

CREATE INDEX idx_orders_cust_status_created
  ON orders (customer_id, status, created_at DESC);

والنتيجة:

 Limit  (cost=0.43..12.18 rows=20 width=26) (actual time=0.031..0.058 rows=20.00 loops=1)
   ->  Index Scan using idx_orders_cust_status_created on orders  (cost=0.43..359.62 rows=612 width=26) (actual time=0.029..0.054 rows=20.00 loops=1)
         Index Cond: ((customer_id = 4821) AND (status = 'paid'::text) AND (created_at >= '2026-01-01 00:00:00+03'::timestamptz))
         Buffers: shared hit=24
 Execution Time: 0.079 ms

المكسب الأهم ليس الرقم بل غياب عقدة Sort. الترتيب المختار خدم WHERE وORDER BY معاً: الفرز الصريح كان سيعالج كل البيانات المطابقة، بينما الفهرس المطابق للترتيب جلب أول 20 صفاً وتوقّف. وملاحظة إجرائية: منذ PostgreSQL 18 صار BUFFERS مُفعّلاً تلقائياً مع EXPLAIN ANALYZE، فلم تعد تطلبه صراحةً.

في MySQL قبل الفهرس تحصل على type: ALL وkey: NULL وrows: 1987432 وExtra: Using where; Using filesort. وبعده: type: range وkey: idx_orders_cust_status_created وkey_len: 14 وrows: 734 وExtra: Using where.

key_len يكشف كم جزءاً من الفهرس استُخدم فعلاً

في نسخة MySQL من الجدول يكون status من نوع ENUM وcreated_at من نوع DATETIME، والرقم 14 يمكن التحقق منه بالحساب: BIGINT NOT NULL يساوي 8 بايت، وENUM بعناصر لا تتجاوز 255 وبقيد NOT NULL يساوي بايتاً واحداً، وDATETIME بدقّة كسرية 0 يساوي 5 بايت. المجموع 14: الأجزاء الثلاثة كلها دخلت في تحديد المدى. وkey_len يخبرك كم جزءاً من المفتاح المركّب استعمل المحرّك فعلاً؛ اقرأه قبل أي رقم آخر.

الآن اقلب ترتيب العمودين الأخيرين إلى (customer_id, created_at, status) وشغّل الاستعلام نفسه في MySQL:

type: range، key_len: 13، rows: 18422، Extra: Using index condition; Backward index scan

إشارتان تحسمان الحكم:

  • key_len نزل من 14 إلى 13، أي أن بايت status لم يدخل في تحديد المدى.
  • rows قفز من 734 إلى 18422: المحرّك يمسح كل طلبات العميل منذ بداية السنة ثم يستبعد غير المدفوعة، فيقرأ نحو 25 ضعفاً من إدخالات الفهرس ليعيد العشرين نفسها.

لاحظ ما لم يتغيّر: Using filesort لم يظهر. created_at لا يزال يلي عمود مساواة مباشرة، فيبقى الفهرس مرتّباً به داخل العميل الواحد، ويقرأه المحرّك عكسياً. الترتيب الخاطئ هنا لا يكلّفك الفرز، بل عرض المسح.

Index Cond لا يعني أن العمود قلّص المسح

بقيت في Extra عبارة تبدو مطمئنة وليست كذلك. Using index condition تعني أن شرط status دُفع إلى طبقة التخزين ويُفحص داخل الفهرس، فيوفّر قراءات من الجدول — لكنه لا يقلّص نطاق المسح داخل الفهرس. أنت تقرأ 18422 إدخالاً ثم تصفّي، بدل أن تقرأ 734 مباشرة.

وتوثيق PostgreSQL يقول الشيء نفسه: قيود المساواة على الأعمدة القائدة، مع قيود عدم المساواة على أول عمود بلا قيد مساواة، هي التي تحدّد الجزء الممسوح؛ أما القيود على الأعمدة التالية فتُفحَص داخل الفهرس فتوفّر زيارات الجدول، «لكنها لا تقلّل بالضرورة الجزء الممسوح». والاكتشاف هنا أصعب: بالترتيب الخاطئ نفسه يظهر status داخل Index Cond كأن كل شيء سليم. المؤشران الحقيقيان Buffers وactual time؛ قارِنهما بين الترتيبين ولا تحكم بشكل الخطة.

متى ينهار الترتيب فعلاً: حين يفترق المدى عن الفرز

في مثالنا يؤدّي created_at الدورين معاً — عمود المدى وعمود الترتيب — فنجا الاستعلام من الفرز حتى بترتيب أعمدة رديء. والقاعدة تُختبر حين يفترق الدوران. غيّر الترتيب إلى ORDER BY total DESC وستقف أمام خيار لا مفرّ منه: فهرس (customer_id, status, created_at) يضيّق المسح ويترك لك الفرز، وفهرس (customer_id, status, total) يعطيك الترتيب جاهزاً لكنه يقرأ كل طلبات العميل بلا حدّ زمني.

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

وهنا تسقط نصيحة شائعة لا يسندها أي توثيق رسمي: «ضع الأكثر انتقائية أولاً». عمود عالي الانتقائية موضوع بعد عمود مدى لا يقلّل نطاق المسح إطلاقاً. الترتيب يحكمه دور العمود في الاستعلام، لا عدد قيمه المميزة. واعتدل في العدد كذلك: ما تجاوز ثلاثة أعمدة نادراً ما يفيد إلا في أنماط استخدام شديدة الخصوصية.

الفهرس المغطّي: حين لا يلمس المحرّك الجدول

إن وُجدت كل الأعمدة التي يحتاجها الاستعلام داخل الفهرس، فلا رجوع إلى الجدول. في PostgreSQL تُضاف الأعمدة الإضافية بجملة INCLUDE التي وصلت في الإصدار 11 (2018):

CREATE INDEX idx_orders_covering
  ON orders (customer_id, status, created_at DESC)
  INCLUDE (total);

أعمدة INCLUDE لا تصلح للبحث ولا للترتيب — للإرجاع فقط. والنتيجة تظهر كـIndex Only Scan، لكن الدليل الحقيقي على المكسب سطر Heap Fetches: 0. قيمة مرتفعة هناك تعني جدولاً كثير التغيّر وخريطة رؤية غير محدّثة، فتدفع كلفة فهرس أضخم بلا مقابل، والعلاج VACUUM أو ضبط التنظيف التلقائي.

MySQL لا يملك INCLUDE؛ تُلحق الأعمدة بالفهرس نفسه، والمقابل في EXPLAIN هو Using index لا Using index condition. ولـInnoDB أفضلية هنا: كل سجل في الفهرس الثانوي يحمل أعمدة المفتاح الأساسي أصلاً، فلا تُضِف id يدوياً. أما PostgreSQL فلا يضمّنه تلقائياً. وفي المحرّكين معاً، لا تُفرط: الأعمدة العريضة تضخّم الفهرس وقد تبطّئ البحث.

ستة أسباب تجعل المخطِّط يتجاهل فهرسك

السببالعلاج
دالة على العمود: DATE(created_at) = '2026-01-01'أعد الصياغة كمدى: created_at >= '2026-01-01' AND created_at < '2026-01-02'، أو فهرس تعبير — في PostgreSQL CREATE INDEX ... ON t (lower(col))، وفي MySQL أجزاء مفتاح دالّية منذ 8.0.13 بصيغة CREATE INDEX idx ON t ((col1 + col2))
تحويل نوع ضمني: WHERE str_col = 1صحّح نوع القيمة في التطبيق؛ مقارنة عمود نصّي بعدد تُبطل الفهرس في MySQL
اختلاف مجموعة المحارف أو الترتيب اللغوي بين عمودي الوصلوحّد الترميز والترتيب على طرفَي الوصل؛ توثيق MySQL ينصّ على أن مقارنة عمود utf8mb4 بعمود latin1 تمنع استخدام الفهرس
LIKE '%كلمة' ببداية حرف بدللا يخدمه فهرس B-tree؛ انتقل إلى بحث نصّي كامل. LIKE 'محمد%' يعمل، لكن في PostgreSQL خارج المحليّة C يلزم صنف المُعامِلات text_pattern_ops
انتقائية منخفضة أو جدول صغيرلا شيء — المسح التتابعي أرخص فعلاً. توثيق PostgreSQL يصف الاختبار على بيانات صغيرة بأنه «قاتل بشكل خاص»: 100 صف تتّسع في صفحة قرص واحدة
إحصاءات قديمةANALYZE في PostgreSQL، ANALYZE TABLE في MySQL. وInnoDB بإعداده الافتراضي يعيد الحساب تلقائياً عند تغيّر أكثر من 10% من صفوف الجدول

للتشخيص في PostgreSQL وحده: أوقف enable_seqscan مؤقتاً في جلستك وقارن الزمن، لتعرف إن كان المخطِّط مخطئاً في التقدير أم محقّاً في اختياره. أداة تشخيص لا إعداد إنتاج.

ترميز مُرحَّل وبحث بالبادئة: عطبان يتكرّران في بيانات عربية

صفّان من الجدول أعلاه يستحقّان وقفة. الأول اختلاف الترميز: الجداول المُرحَّلة القديمة كثيراً ما تكون على utf8mb3 وutf8mb3_general_ci بينما الجداول الجديدة على utf8mb4، ووصل عمودين بترميزين مختلفين يمنع استخدام الفهرس بنصّ التوثيق. والسيناريو المتكرر ربط جدول عملاء قديم بجدول طلبات جديد، فيتحوّل الوصل إلى مسح كامل بلا أي خطأ أو تحذير.

والثاني البحث بالبادئة على اسم عربي. WHERE name LIKE 'محمد%' لن يستخدم فهرس B-tree عادياً في PostgreSQL خارج المحليّة C، والحل صنف المُعامِلات text_pattern_ops — بتحذير: هذا الفهرس لا يخدم مقارنات < و> العادية، فقد تحتاج فهرسين على العمود نفسه.

كل فهرس فاتورة تُدفع عند كل كتابة

الفهرس ليس مجانياً: يُحدَّث مع كل INSERT وUPDATE يمسّ أعمدته. فاحذف ما نادراً ما يُستخدم أو لا يُستخدم أصلاً. في PostgreSQL راجع pg_stat_user_indexes بالأعمدة relname وindexrelname وidx_scan؛ وعلى الإصدار 16 فما فوق لديك last_idx_scan الذي يخبرك بآخر مرة استُخدم فيها الفهرس، وهو أدقّ من العدّاد التراكمي.

في MySQL استعمل sys.schema_unused_indexes بأعمدة object_schema وobject_name وindex_name، وsys.schema_redundant_indexes الذي يعطيك عمود sql_drop_index جاهزاً للنسخ. الأول مبني على Performance Schema فيلزم تفعيلها، والثاني مبني على information_schema فلا يشترطها. ولا تحكم مبكراً: المنظر الأول لا يفيد إلا بعد أن يعمل الخادم مدة يصبح فيها عبء العمل ممثّلاً.

وإن فكّرت في حشو عمود منخفض الانتقائية داخل الفهرس، ففي PostgreSQL بديل أنظف: الفهرس الجزئي على الصفوف التي تهمّك وحدها — ولا مقابل مباشر له في InnoDB.

فهرس واحد يخدم الترقيم بالمفتاح

الترقيم بالإزاحة يتدهور كلما ابتعدت الصفحة، والبديل الترقيم بالمفتاح — وهو موضوع تفصيلي شرحناه في المفاضلة بين الترقيم بالمؤشر والترقيم بالإزاحة. وفهرس واحد على (created_at, id) يكفي هنا: يخدم شرط التصفية والترتيب معاً.

SELECT id, total, created_at
FROM orders
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;

مقارنة مُنشئ الصف تسير على العناصر من اليسار إلى اليمين وتتوقف عند أول زوج غير متساوٍ، وهو بالضبط ما يحتاجه الفهرس ليحدّد نقطة البداية. لكن في MySQL قد تستخدم الصيغة المفكوكة أجزاء فهرس أكثر: افحص key_len في الصيغتين واختر ما يعطيك الرقم الأكبر.

والاتجاه فخّ منفصل: فهرس عادي على (x, y) يخدم ORDER BY x, y وORDER BY x DESC, y DESC بقراءته عكسياً، لكنه لا يخدم ORDER BY x ASC, y DESC. لهذه الحالة وُجدت خيارات ASC/DESC داخل CREATE INDEX. والفهارس التنازلية في MySQL أُضيفت في الإصدار 8.0 ولـInnoDB وحده؛ قبلها كان المحرّك يقبل الكلمة DESC ثم يتجاهلها.

من أبطأ استعلام لديك إلى تعريف الفهرس

ابدأ من أبطأ استعلام في سجلاتك، لا من مراجعة عامة للمخطط. شغّل EXPLAIN ANALYZE عليه في PostgreSQL أو EXPLAIN في MySQL، واقرأ ثلاثة أشياء: هل هناك Seq Scan أو type: ALL؟ هل هناك Sort أو Using filesort؟ ما الفجوة بين الصفوف المقروءة والصفوف المُعادة؟

بعدها صنّف أعمدة WHERE إلى ثلاث خانات: مساواة، مدى، ترتيب. رتّب الفهرس بهذا التسلسل، وثبّت الاتجاه داخل تعريف الفهرس إن كان ORDER BY مختلطاً. ثم أعد التنفيذ وقارن Buffers وactual time في PostgreSQL، أو key_len وrows في MySQL. وقبل إنشاء فهرس جديد، ابحث في pg_stat_user_indexes أو sys.schema_redundant_indexes عن فهرس قائم يمكن توسيعه بعمود واحد.

وتذكّر أن المشكلة قد تكون أبعد من الفهرسة أصلاً: استعلام يُنفَّذ مرة لكل صف بسبب مشكلة N+1 لن ينقذه أي فهرس، وكذلك اختيار المفتاح الأساسي بين UUID وBIGINT الذي يحدّد حجم كل فهرس ثانوي في الجدول قبل أن تكتب سطر CREATE INDEX الأول.