مشروع: من استعلام بطيء إلى سريع
تقرير بطيء على 20 ألف طلب: نقيسه ونشخّصه ونسرّعه خطوة خطوة. ⏱ نحو 60 دقيقة.
- تطبق منهجية تحسين الأداء: قِس، اقرأ، أعد الكتابة، فهرِس، قِس.
- تقرأ
EXPLAIN ANALYZEوتكتشفSubPlanوloops. - تحوّل الاستعلام المترابط إلى ربط وتجميع.
- تختار فهرسًا يناسب شرط الاستعلام.
١المهمة
مدير المبيعات يشتكي: «تقرير الطلبات المدفوعة لكل مدينة في 2025 بطيء». البيانات: 3000 عميل و 20 ألف طلب و 40 ألف بند (قاعدة متجر الريان: بيانات كبيرة). سنعمل كما يعمل مهندس الأداء، في خمس خطوات:
| الخطوة | السؤال |
|---|---|
| 1. قِس | كم يستغرق الآن؟ لا تحسين بلا رقم قبله وبعده |
| 2. اقرأ الخطة | أين يذهب الوقت؟ |
| 3. أعد الكتابة | هل يمكن وصف النتيجة بأمر مجموعات واحد؟ |
| 4. اجعل الشرط قابلًا للفهرسة | هل على العمود دالة تمنع الفهرس؟ |
| 5. أضف الفهرس المناسب وقِس | هل تحسّن الرقم فعلًا؟ |
٢1 و 2: القياس وقراءة الخطة
هذا هو التقرير الحالي. شغّله ولاحظ ⏱:
SELECT c.city, (SELECT count(*) FROM orders o JOIN customers c2 ON c2.id = o.customer_id WHERE c2.city = c.city AND o.status = 'paid' AND extract(year FROM o.order_date) = 2025) AS paid_ordersFROM (SELECT DISTINCT city FROM customers) cORDER BY paid_orders DESC, c.city;
| city | paid_orders |
|---|---|
| الدمام | 338 |
| المدينة | 337 |
| أبها | 326 |
| تبوك | 326 |
| جدة | 326 |
| حائل | 326 |
| الرياض | 325 |
| مكة | 325 |
EXPLAIN ANALYZE ينفّذ الاستعلام فعلًا ويعرض الخطة مع الأزمنة الحقيقية وعدد مرات تنفيذ كل عقدة (loops):
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)SELECT c.city, (SELECT count(*) FROM orders o JOIN customers c2 ON c2.id = o.customer_id WHERE c2.city = c.city AND o.status = 'paid' AND extract(year FROM o.order_date) = 2025) AS paid_ordersFROM (SELECT DISTINCT city FROM customers) cORDER BY paid_orders DESC, c.city;
Sort (actual rows=8.00 loops=1)Sort Key: ((SubPlan 1)) DESC, c.citySort Method: quicksort Memory: 17kBBuffers: shared hit=1245-> Subquery Scan on c (actual rows=8.00 loops=1)Buffers: shared hit=1245-> HashAggregate (actual rows=8.00 loops=1)Group Key: customers.cityBatches: 1 Memory Usage: 32kBBuffers: shared hit=29-> Seq Scan on customers (actual rows=3000.00 loops=1)Buffers: shared hit=29SubPlan 1-> Aggregate (actual rows=1.00 loops=8)Buffers: shared hit=1216-> Hash Join (actual rows=328.62 loops=8)Hash Cond: (o.customer_id = c2.id)Buffers: shared hit=1216-> Seq Scan on orders o (actual rows=2629.00 loops=8)Filter: ((status = 'paid'::text) AND (EXTRACT(year FROM order_date) = '2025'::numeric))Rows Removed by Filter: 17371Buffers: shared hit=984-> Hash (actual rows=375.00 loops=8)Buckets: 1024 Batches: 1 Memory Usage: 15kBBuffers: shared hit=232-> Seq Scan on customers c2 (actual rows=375.00 loops=8)Filter: (city = c.city)Rows Removed by Filter: 2625Buffers: shared hit=232Planning:Buffers: shared hit=73Planning Time: 0.555 msExecution Time: 26.220 ms
ابحث عن SubPlan و loops=8: الاستعلام الفرعي المترابط يُنفَّذ مرة لكل مدينة، وفي كل مرة يمسح جدول الطلبات كاملًا (Seq Scan) ويربطه بالعملاء.
٣3 و 4: إعادة الكتابة والشرط القابل للفهرسة
المشكلتان: (أ) استعلام فرعي مترابط = حلقة مخفية، و(ب) extract(year FROM order_date) دالة على العمود تمنع أي فهرس (الدرس 6). الحل: ربط وتجميع مرة واحدة، ومدى تاريخ صريح:
SELECT c.city, count(*) AS paid_ordersFROM orders o JOIN customers c ON c.id = o.customer_idWHERE o.status = 'paid' AND o.order_date >= '2025-01-01' AND o.order_date < '2026-01-01'GROUP BY c.cityORDER BY paid_orders DESC, c.city;
| city | paid_orders |
|---|---|
| الدمام | 338 |
| المدينة | 337 |
| أبها | 326 |
| تبوك | 326 |
| جدة | 326 |
| حائل | 326 |
| الرياض | 325 |
| مكة | 325 |
النتيجة نفسها تمامًا. قارن ⏱ مع النسخة الأولى.
٤5: الفهرس المناسب والقياس الأخير
الاستعلام يبحث في الطلبات المدفوعة ضمن مدى تواريخ. الفهرس الجزئي على التاريخ للمدفوعة فقط يخدمه بأصغر حجم (الدرس 13):
CREATE INDEX ix_paid_date ON orders (order_date) WHERE status = 'paid';ANALYZE orders; EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)SELECT c.city, count(*) AS paid_ordersFROM orders o JOIN customers c ON c.id = o.customer_idWHERE o.status = 'paid' AND o.order_date >= '2025-01-01' AND o.order_date < '2026-01-01'GROUP BY c.cityORDER BY paid_orders DESC, c.city;
Sort (actual rows=8.00 loops=1)Sort Key: (count(*)) DESC, c.citySort Method: quicksort Memory: 17kBBuffers: shared hit=133 read=5-> HashAggregate (actual rows=8.00 loops=1)Group Key: c.cityBatches: 1 Memory Usage: 32kBBuffers: shared hit=133 read=5-> Hash Join (actual rows=2629.00 loops=1)Hash Cond: (o.customer_id = c.id)Buffers: shared hit=133 read=5-> Bitmap Heap Scan on orders o (actual rows=2629.00 loops=1)Recheck Cond: ((order_date >= '2025-01-01'::date) AND (order_date < '2026-01-01'::date) AND (status = 'paid'::text))Heap Blocks: exact=104Buffers: shared hit=104 read=5-> Bitmap Index Scan on ix_paid_date (actual rows=2629.00 loops=1)Index Cond: ((order_date >= '2025-01-01'::date) AND (order_date < '2026-01-01'::date))Index Searches: 1Buffers: shared read=5-> Hash (actual rows=3000.00 loops=1)Buckets: 4096 Batches: 1 Memory Usage: 135kBBuffers: shared hit=29-> Seq Scan on customers c (actual rows=3000.00 loops=1)Buffers: shared hit=29Planning:Buffers: shared hit=63 read=1Planning Time: 0.698 msExecution Time: 2.752 ms
الخطة الآن تقرأ الطلبات المدفوعة في 2025 من الفهرس، وتربطها بالعملاء مرة واحدة (Hash Join).
| النسخة | ما تغيّر |
|---|---|
| الأصلية | استعلام فرعي مترابط لكل مدينة + extract على العمود |
| بعد إعادة الكتابة | ربط وتجميع واحد + مدى تواريخ |
| بعد الفهرس | قراءة الجزء المطلوب فقط |
توقّع وتحقّق
ما الفرق بين EXPLAIN و EXPLAIN ANALYZE؟
loops=8 تحت SubPlan تعني:
extract(year FROM d) = 2025 يُستبدل بـ:
أول خطوة في تحسين الأداء:
الخلاصة
- لا تحسين بلا قياس قبل وبعد.
EXPLAIN ANALYZEينفّذ ويعرض الحقيقة:loopsالكبيرة علامة حلقة مخفية.- إعادة الكتابة بالمجموعات غالبًا أكبر تحسن.
- مدى تواريخ صريح بدل دالة على العمود، ثم فهرس يطابق الشرط.
تحدٍّ برمجي: سرّع تقرير الإيراد
متقدمهذا التقرير يحسب لكل حالة طلب عدد الطلبات وإيرادها في الربع الثالث من 2025 (يوليو إلى سبتمبر)، لكنه بطيء. أعد كتابته بالنتيجة نفسها.
- النتيجة نفسها بالترتيب نفسه.
- بلا استعلامات فرعية داخل SELECT.
- بلا دوال على order_date (لا extract ولا date_part ولا to_char).
- أضف فهرسًا مناسبًا على orders.
👀 اعرض النتيجة المطلوبة
| status | orders | revenue |
|---|---|---|
| cancelled | 826 | 2024478 |
| new | 995 | 2437188 |
| paid | 832 | 2045100 |
| shipped | 832 | 2046896 |
- الكود يعمل بلا أخطاء
- النتيجة نفسها بالترتيب نفسه
- بلا استعلام فرعي داخل SELECT
- بلا دوال على order_date
- فهرس على orders
💡 تلميح 1
GROUP BY o.status.💡 تلميح 2
count(DISTINCT o.id) لا count(*).💡 تلميح 3
order_date >= '2025-07-01' AND order_date < '2025-10-01'.حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.
CREATE INDEX ix_orders_date ON orders (order_date); SELECT o.status, count(DISTINCT o.id) AS orders, sum(i.qty * i.unit_price) AS revenueFROM orders o JOIN order_items i ON i.order_id = o.idWHERE o.order_date >= '2025-07-01' AND o.order_date < '2025-10-01'GROUP BY o.statusORDER BY o.status;