كيف تعرف خطة تنفيذ استعلام SQLite باستخدام EXPLAIN QUERY PLAN
قبل أن تبدأ بتحسين أي استعلام بطيء في SQLite، يجب أن تعرف أولاً ماذا يفعل المحرك فعلياً خلف الكواليس. الأمر EXPLAIN QUERY PLAN يعرض لك ملخصاً مختصراً للخطة التي اختارها مُخطِّط الاستعلامات: ما هي الجداول التي سيمر عليها، وبأي ترتيب، وهل سيستخدم فهارس أم لا. الاستعلام نفسه لن يُنفَّذ، أنت تحصل فقط على الخطة.
كل ما عليك هو إضافة هذه الكلمات قبل أي عبارة SQL:
المخرجات تبدو بهذا الشكل تقريبًا:
QUERY PLAN
`--SEARCH users USING INDEX sqlite_autoindex_users_1 (email=?)
هذا السطر وحده يكشف لك الكثير: قاعدة SQLite تنفّذ عملية SEARCH (وليس scan) على جدول users، مستخدمةً الفهرس الفريد الذي أُنشئ تلقائيًا لعمود email، مع email كمفتاح للبحث. هذا تحديدًا ما كنت تتمنى رؤيته.
الفرق بين SCAN و SEARCH: أول ما يجب أن تقرأه
كل سطر في خطة التنفيذ يبدأ إمّا بـ SCAN أو SEARCH، والتمييز بينهما هو أهم إشارة في المخرجات كلها.
SCAN <table>— يقرأ SQLite كل صف في الجدول (أو كل مدخل في الفهرس)، فالتكلفة تتضخّم مع حجم الجدول.SEARCH <table> USING ...— يقفز SQLite مباشرةً إلى الصفوف المطابقة عبر فهرس أو مفتاح أساسي، فالتكلفة هنا مرتبطة بحجم النتيجة لا بحجم الجدول.
إليك مقارنة مباشرة بين الحالتين: عمود يملك فهرسًا وآخر لا يملك:
الخطة الأولى تُظهر SEARCH orders USING INDEX idx_orders_customer، بينما الثانية تُظهر SCAN orders — لا يوجد فهرس على عمود status، لذلك يقرأ SQLite كل صف على حدة. على جدول صغير لن تلاحظ شيئًا، أما على جدول يحتوي مليون صف فالفرق بين أجزاء من الثانية وعدة ثوانٍ كاملة.
ظهور SCAN ليس خطأً دائمًا؛ فمع جداول البحث الصغيرة، أو الاستعلامات التي تُرجع فعليًا معظم الصفوف، يكون المسح الكامل هو الخيار الأنسب. لكن عندما يكون الجدول ضخمًا والمُرشِّح انتقائيًا، فإن ظهور SCAN إشارة واضحة إلى أنك بحاجة لإضافة فهرس.
كيف تتأكد أن الاستعلام يستخدم فهرس sqlite
العبارة التي تبحث عنها هي USING INDEX <name> (أو USING COVERING INDEX <name> — وسنتحدث عنها بعد قليل). إذا أنشأت فهرسًا وأنت تأمل أن يلتقطه مُخطِّط الاستعلام، فهذه هي الطريقة للتحقق:
يفترض أن ترى SEARCH events USING INDEX idx_events_user (user_id=?). أمّا إذا ظهرت لك الخطة بصيغة SCAN events، فهذا يعني أن شيئًا ما يمنع مُخطِّط الاستعلامات من استخدام الفهرس. الأسباب الشائعة لهذه المشكلة هي تغليف العمود بدالة (مثل WHERE lower(user_id) = ...)، أو المقارنة بين أنواع بيانات مختلفة، أو استخدام LIKE '%foo%' بمحرف بدل (wildcard) في البداية.
هذا اختبار سريع للحالة:
إضافة + 0 تُبطل عمل الفهرس، فترى الخطة قد رجعت إلى SCAN events. أي عملية حسابية على العمود المفهرس تؤدي إلى النتيجة نفسها.
الفهارس المُغطية تظهر بشكل مختلف
عندما يحتوي الفهرس على جميع الأعمدة التي يحتاجها الاستعلام، يستطيع SQLite الإجابة عليه من الفهرس وحده دون الرجوع إلى الجدول. في هذه الحالة تُظهر الخطة USING COVERING INDEX:
الخطة: SEARCH products USING COVERING INDEX idx_products_sku_price (sku=?). الاستعلام يطلب عمود price، والفهرس بالأصل يحتوي على sku وprice معًا، فلا يحتاج SQLite لقراءة الجدول الأصلي إطلاقًا. الفهارس المُغطِّية (Covering Indexes) هي أسرع خطة ممكنة لأي عملية بحث — معلومة تستحق التذكر حين تقرر أي الأعمدة تضعها معًا في فهرس واحد.
قراءة خطط الـ JOIN في sqlite
عمليات الـ JOIN هي المكان التي تصير فيه خطط الاستعلام ممتعة فعلًا. كل سطر في الخطة يقابل جدولًا واحدًا من جداول الـ JOIN، وترتيب السطور هو نفسه ترتيب زيارة SQLite للجداول. الجدول الأول هو الجدول الخارجي (outer)، أما الجداول التي تليه فيُبحث فيها مرة واحدة لكل صف من صفوف الجدول الخارجي.
خطة تنفيذ نموذجية:
QUERY PLAN
|--SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
`--SEARCH o USING INDEX idx_orders_customer (customer_id=?)
اقرأ الخطة من الأعلى إلى الأسفل: يعثر SQLite على العميل المطلوب عبر المفتاح الأساسي، ثم يجلب الطلبات المرتبطة بهذا العميل باستخدام الفهرس الموجود على customer_id. لاحظ أن السطرين كليهما يبدآن بـ SEARCH بدون أي مسح كامل، وهذا هو السلوك المثالي الذي تسعى إليه.
أما لو ظهر لك SCAN o في السطر الثاني، فهذا يعني أن كل عملية بحث عن عميل ستتسبب في مرور كامل على جدول orders. ومع جدول ضخم تكون النتيجة كارثية على الأداء. الحل في الغالب بسيط: أضف فهرسًا على عمود الربط (join column).
الاستعلامات المركّبة والاستعلامات الفرعية
خطط التنفيذ الخاصة بـ UNION وEXCEPT والاستعلامات الفرعية تظهر بشكل متداخل، حيث يُعرض كل فرع بإزاحة تحت الفرع الأب الخاص به:
ستجد صفّين فرعيّين تحت العنوان COMPOUND QUERY، صفًّا لكل فرع. الاستعلامات الفرعية وتعابير CTE تعمل بالأسلوب ذاته تقريبًا؛ يحصل كلٌّ منها على عقدة خطة مستقلة بمسافة بادئة خاصة بها، وتقرأها بالمنطق نفسه: SCAN مقابل SEARCH.
الاستعلام الفرعي يظهر كعقدة مستقلة في الخطة (باسم "LIST SUBQUERY" أو ما شابه)، ولها استراتيجية وصول خاصة بها. طبّق نفس الفحوصات على كل مستوى.
الفرق بين EXPLAIN و EXPLAIN QUERY PLAN
هذان أمران مختلفان تمامًا، وكثيرًا ما يخلط بينهما المطورون.
EXPLAIN (بدون QUERY PLAN) يُخرج لك البايت كود الذي ستُنفّذه آلة SQLite الافتراضية — عشرات الأوامر منخفضة المستوى مثل OpenRead وSeekRowid وColumn وResultRow. مفيد لو كنت تُصحّح أخطاء المحرك نفسه، لكنه نادراً ما يفيد في تحسين الأداء.
أمّا EXPLAIN QUERY PLAN فهو الملخّص المقروء الذي تحتاجه فعلاً. كلما ترددت، اذهب مباشرةً إلى EXPLAIN QUERY PLAN.
خطوات عملية لتشخيص الاستعلامات البطيئة في sqlite
حين يكون الاستعلام بطيئاً، تسير الدورة على هذا النحو:
- شغّل عليه
EXPLAIN QUERY PLAN. - لكل سطر يخصّ جدولاً، اسأل نفسك: هل هذا
SCANأمSEARCH؟ في الجداول الكبيرة،SCANهو المتّهم الأول. - إذا كان
SCANيُرشّح بناءً على عمود معيّن، ففكّر في إضافة فهرس على ذلك العمود. - في عمليات الـ JOIN، تأكّد أن جداول الحلقة الداخلية تستخدم
SEARCH USING INDEXعلى عمود الربط. - أعِد تشغيل
EXPLAIN QUERY PLANبعد إضافة الفهرس. يُفترض أن تتغيّر الخطة. إن لم تتغيّر، فهذا يعني أن المُخطّط رأى أن فهرسك لا يستحقّ الاستخدام — عادةً لأن الجدول صغير أو لأن المُرشِّح ليس انتقائياً بما يكفي.
مثال تطبيقي على الخطوة الخامسة:
تغيّرت الخطة من SCAN إلى SEARCH. هذه هي الإشارة التي تخبرك بأنّ الفهرس يقوم بعمله. (مع جدول جديد شبه فارغ، قد يستمر المخطط في إجراء مسح كامل لأنّ البيانات ليست كافية ليتكبّد عناء استخدام الفهرس — املأ الجدول أو نفّذ ANALYZE وستجد الاختيار يتبدّل في الغالب.)
ما الذي لن تخبرك به خطة الاستعلام
أمر EXPLAIN QUERY PLAN يصف لك الاستراتيجية لا التكلفة. فهو لن يخبرك أنّ الاستعلام استغرق 800 مللي ثانية أو أنّه أعاد 50,000 صف. لمعرفة ذلك تحتاج إلى قياس الزمن (.timer on داخل الـ CLI) وعدّ الصفوف. الخطة والتوقيت يكمّلان بعضهما — الخطة تخبرك لماذا الاستعلام بطيء، والمؤقّت يخبرك هل هو بطيء فعلًا أم لا.
وهناك قيدان آخران يستحقّان الانتباه:
- الخطة قد تتغيّر مع نمو البيانات. استعلام كان يمسح جدولًا فيه 100 صف بكلّ أريحية سيحتاج إلى فهرس حين يبلغ الجدول مليون صف. أعد فحص الخطط على بيانات بحجم الإنتاج، لا على بيانات التطوير التجريبية.
- المخطّط يعتمد على الإحصائيات التي يجمعها
ANALYZE. وبدونها يرجع إلى قيم افتراضية ليست مثالية دائمًا. الإحصائيات المفقودة أو القديمة سبب شائع للخطط المفاجئة.
التالي: ANALYZE و VACUUM
يبني مخطّط الاستعلامات قراراته اعتمادًا على إحصائيات حول جداولك وفهارسك. وإذا كانت هذه الإحصائيات غائبة أو قديمة، فحتى المخطّط (schema) المُفهرَس بشكل مثالي قد يُنتج خطّة سيّئة. الأمر ANALYZE هو وسيلتك للحفاظ على هذه الإحصائيات محدّثة — والأمر VACUUM هو رفيقه الذي يستعيد المساحة ويعيد ترتيب ملفّ قاعدة البيانات. هذا ما سنتناوله تاليًا.
الأسئلة الشائعة
ما وظيفة EXPLAIN QUERY PLAN في SQLite؟
هو يطلب من SQLite أن يصف لك الطريقة التي سينفّذ بها الاستعلام دون أن يشغّله فعليًا. المخرجات تُظهر الجداول التي ستُمسح، والفهارس المستخدمة، وترتيب عمليات الـ JOIN. كل ما عليك هو إضافة EXPLAIN QUERY PLAN قبل أي SELECT أو INSERT أو UPDATE أو DELETE لرؤية الخطة.
ما الفرق بين SCAN و SEARCH في المخرجات؟
كلمة SCAN تعني أن SQLite يقرأ كل صفوف الجدول أو الفهرس — وهذا مقبول مع الجداول الصغيرة، لكنه مكلف جدًا مع الجداول الكبيرة. أما SEARCH فمعناها أن المحرك يقفز مباشرة إلى الصفوف المطابقة عبر فهرس أو مفتاح أساسي. في الجداول الضخمة، تريد دائمًا أن ترى SEARCH على أعمدة التصفية.
كيف أتحقق من أن استعلامي يستخدم فهرسًا فعلًا؟
شغّل EXPLAIN QUERY PLAN على الاستعلام وابحث في المخرجات عن USING INDEX <name> أو USING COVERING INDEX <name>. إذا لم تجد سوى SCAN <table> بدون أي ذكر لفهرس، فالاستعلام يقوم بمسح كامل للجدول، وغالبًا ستحلّ المشكلة بإضافة فهرس مناسب.
ما الفرق بين EXPLAIN و EXPLAIN QUERY PLAN؟
أمر EXPLAIN يعرض الـ bytecode منخفض المستوى الذي يولّده محرك SQLite الافتراضي — مفيد لمن يعمل على داخليات المحرك، ونادرًا ما يفيد في تحسين الاستعلامات. بينما EXPLAIN QUERY PLAN يعطيك ملخصًا مقروءًا للبشر يبيّن كيفية الوصول إلى الجداول واستخدام الفهارس. لتحسين الأداء، ستحتاج دائمًا تقريبًا إلى EXPLAIN QUERY PLAN.