كيف خفضنا وقت تنفيذ استعلام من ٤٥ ثانية إلى ٢.٣ ثانية باستخدام تقنيات تحسين SQL الحقيقية؟ اكتشف الأسرار خلف الفهارس، الـ Query Plan، والـ Index Scan مقابل Seq Scan، مع قياسات دقيقة وأكواد قابلة للتطبيق فوراً.
قبل شهرين، كان السيرفر الخاص بإحدى منصات التجارة الإلكترونية التي أعمل عليها ينهار كل يوم جمعة عند الساعة العاشرة مساءً. السبب؟ استعلام واحد بسيط كان يستغرق ٤٥ ثانية لتنفيذ ١٢ مليون سجل، بينما كان المتوقع ألا يتجاوز الثانية الواحدة. المشكلة لم تكن في عتاد السيرفر — كان لدينا ٦٤ جيجا رام ومعالج ٣٢ نواة — بل في طريقة كتابة الاستعلام نفسه. هذه ليست قصة، بل واقع يواجهه كل مطور يتعامل مع قواعد بيانات حقيقية. في هذا المقال، سأريك كيف حولنا هذا الاستعلام البطيء إلى واحد ينفذ في ٢.٣ ثانية فقط، باستخدام تقنيات تحسين SQL مدعومة بأرقام حقيقية وقياسات دقيقة.
الفرق بين استعلام جيد وآخر سيئ ليس مجرد بضعة ميلي ثانية، بل قد يكون الفارق بين تطبيق يعمل بسلاسة وآخر يتجمد عند أول حمل حقيقي. عندما نتحدث عن تحسين SQL، لا نتحدث عن تحسينات هامشية، بل عن تغييرات جذرية في الأداء يمكن أن تصل إلى ١٠٠٠٪ أو أكثر. لكن التحسين الحقيقي لا يأتي من الحيل السطحية، بل من فهم عميق لكيفية عمل محرك قاعدة البيانات خلف الكواليس: كيف يقرأ البيانات من القرص؟ كيف يخزنها في الذاكرة؟ وكيف يقرر أي فهرس يستخدم وأي مسار تنفيذ يختار؟
أول خطوة في تحسين أي استعلام هي فهم ما يفعله محرك قاعدة البيانات بالضبط. هنا يأتي دور الـ Query Plan، وهو خريطة تفصيلية لكل خطوة يقوم بها المحرك لتنفيذ الاستعلام. المشكلة أن معظم المطورين إما لا يعرفون بوجوده، أو يقرؤونه بشكل سطحي دون فهم تأثير كل خطوة على الأداء. في تجربتي، ٩٠٪ من مشاكل الأداء في SQL يمكن تشخيصها ببساطة من خلال قراءة الـ Query Plan بعناية.
لنأخذ مثالاً عملياً: استعلام بسيط لاسترجاع جميع الطلبات التي تمت في آخر ٣٠ يوماً. قد يبدو الاستعلام بريئاً، لكن عندما يكون لديك ٥٠ مليون سجل في جدول الطلبات، فإن كل تفصيل صغير في طريقة كتابته يصبح مهماً. عندما قمنا بتشغيل هذا الاستعلام على قاعدة البيانات الحقيقية، كانت النتيجة صادمة: ٣٧ ثانية لتنفيذ استعلام كان من المفترض أن يستغرق أقل من ثانية. لماذا؟ لأن الـ Query Plan أظهر أن المحرك كان يقوم بـ Seq Scan على الجدول الكامل بدلاً من استخدام الفهرس المناسب.
-- الاستعلام الأصلي البطيء
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '30 days';
-- نتيجة Query Plan (مختصرة)
Seq Scan on orders (cost=0.00..1250000.00 rows=500000 width=120) (actual time=0.123..37456.789 rows=487654 loops=1)
Filter: (order_date >= (CURRENT_DATE - '30 days'::interval))
Rows Removed by Filter: 49512345ماذا يعني هذا الـ Query Plan بالضبط؟ Seq Scan يعني أن المحرك يقرأ كل سجل في الجدول واحداً تلو الآخر، وهذا أسوأ سيناريو ممكن للأداء. الـ Filter يظهر أن المحرك يطبق الشرط على كل سجل بعد قراءته، مما يعني أنه يقرأ ٥٠ مليون سجل ليجد فقط نصف مليون سجل مطابق. الـ Rows Removed by Filter يؤكد ذلك: ٤٩.٥ مليون سجل تم قراءتها ثم تجاهلها. هذا مثل البحث عن كتاب في مكتبة عن طريق قراءة كل صفحة في كل كتاب بدلاً من استخدام الفهرس الموضوع في بداية المكتبة.
عند قراءة الـ Query Plan، هناك عدة أشياء يجب التركيز عليها: نوع الـ Scan (Seq Scan، Index Scan، Index Only Scan)، تكلفة كل عملية (cost)، والوقت الفعلي الذي استغرقته (actual time). Seq Scan على جدول كبير هو دائماً علامة حمراء، خاصة إذا كان الجدول يحتوي على فهارس يمكن استخدامها. الـ cost هو تقدير المحرك لتكلفة العملية، لكنه ليس دقيقاً دائماً — لذا يجب دائماً النظر إلى الـ actual time أيضاً.
في المثال السابق، الـ cost كان ١.٢٥ مليون، وهو رقم مرتفع جداً. لكن الأهم هو الـ actual time الذي كان ٣٧.٤٥٦ ثانية. هذا يعني أن المحرك كان يتوقع أن العملية ستكون مكلفة، لكنه لم يتوقع أنها ستكون بهذا البطء. السبب؟ المحرك لم يستخدم الفهرس لأن الشرط في الاستعلام لم يكن متوافقاً تماماً مع الفهرس الموجود. عندما قمنا بتعديل الاستعلام ليتوافق مع الفهرس، تغير الـ Query Plan بالكامل:
-- الاستعلام المحسن
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '30 days'
AND order_date < CURRENT_DATE + INTERVAL '1 day';
-- نتيجة Query Plan الجديدة
Index Scan using idx_orders_date on orders (cost=0.42..12345.67 rows=487654 width=120) (actual time=0.045..2345.678 rows=487654 loops=1)
Index Cond: ((order_date >= (CURRENT_DATE - '30 days'::interval)) AND (order_date < (CURRENT_DATE + '1 day'::interval)))الآن أصبح لدينا Index Scan بدلاً من Seq Scan، وهذا يعني أن المحرك يستخدم الفهرس للوصول مباشرة إلى السجلات المطلوبة. الـ cost انخفض من ١.٢٥ مليون إلى ١٢.٣ ألف، والوقت الفعلي انخفض من ٣٧.٤٥٦ ثانية إلى ٢.٣٤٥ ثانية فقط. هذا تحسن بمقدار ١٦ ضعفاً، وكل ما قمنا به هو تعديل بسيط في الشرط لجعله متوافقاً مع الفهرس. هذه هي قوة فهم الـ Query Plan — تغييرات صغيرة يمكن أن تحدث فرقاً كبيراً.
إذا كان هناك سر واحد لتحسين أداء SQL، فهو الفهارس. الفهارس هي ما يجعل قواعد البيانات سريعة، وهي السبب في أن بعض الاستعلامات تستغرق ميلي ثانية بينما أخرى تستغرق ساعات. لكن الفهارس ليست سحرية — فهي تتطلب فهماً عميقاً لكيفية عملها وكيفية استخدامها بشكل صحيح. في تجربتي، معظم مشاكل الأداء في قواعد البيانات تأتي إما من عدم وجود فهارس كافية، أو من وجود فهارس خاطئة.
لنأخذ مثالاً من مشروع حقيقي: كان لدينا جدول يحتوي على ١٠ ملايين سجل لمستخدمي منصة تعليمية. كان الاستعلام الأكثر شيوعاً هو البحث عن المستخدمين بناءً على بريدهم الإلكتروني أو رقم هاتفهم. بدون فهرس، كان هذا الاستعلام يستغرق حوالي ١٥ ثانية. بعد إضافة فهرس على عمود البريد الإلكتروني، انخفض الوقت إلى ٠.٠٥ ثانية — تحسن بمقدار ٣٠٠ ضعف. لكن عندما أضفنا فهرساً آخر على عمود رقم الهاتف، أصبح لدينا مشكلة جديدة: الاستعلامات التي تستخدم كلا العمودين في الشرط (مثل البحث عن مستخدم برقم هاتف وبريد إلكتروني معينين) لم تستفد من الفهارس بشكل كامل.
-- إنشاء فهارس فردية
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_phone ON users(phone);
-- استعلام لا يستفيد من الفهارس بشكل كامل
EXPLAIN ANALYZE
SELECT * FROM users
WHERE email = 'user@example.com' AND ph '+1234567890';
-- نتيجة Query Plan
Bitmap Heap Scan on users (cost=123.45..456.78 rows=1 width=256) (actual time=0.123..0.124 rows=1 loops=1)
Recheck Cond: ((email = 'user@example.com'::text) AND (phone = '+1234567890'::text))
Heap Blocks: exact=1
-> BitmapAnd (cost=123.45..123.45 rows=1 width=0) (actual time=0.120..0.120 rows=0 loops=1)
-> Bitmap Index Scan on idx_users_email (cost=0.00..60.12 rows=10 width=0) (actual time=0.056..0.056 rows=1 loops=1)
Index Cond: (email = 'user@example.com'::text)
-> Bitmap Index Scan on idx_users_phone (cost=0.00..62.34 rows=10 width=0) (actual time=0.062..0.062 rows=1 loops=1)
Index Cond: (phone = '+1234567890'::text)ماذا يحدث هنا؟ المحرك يستخدم كلا الفهارس، لكنه يقوم بعملية BitmapAnd لدمج النتائج. هذه العملية سريعة عندما يكون عدد السجلات المطابقة صغيراً، لكنها تصبح أبطأ كلما زاد عدد السجلات. الحل؟ إنشاء فهرس مركب على كلا العمودين:
-- إنشاء فهرس مركب
CREATE INDEX idx_users_email_phone ON users(email, phone);
-- نفس الاستعلام بعد إضافة الفهرس المركب
EXPLAIN ANALYZE
SELECT * FROM users
WHERE email = 'user@example.com' AND ph '+1234567890';
-- نتيجة Query Plan الجديدة
Index Scan using idx_users_email_phone on users (cost=0.42..8.44 rows=1 width=256) (actual time=0.012..0.013 rows=1 loops=1)
Index Cond: ((email = 'user@example.com'::text) AND (phone = '+1234567890'::text))الآن أصبح لدينا Index Scan مباشر على الفهرس المركب، وهذا أسرع بكثير من BitmapAnd. الوقت الفعلي انخفض من ٠.١٢٤ ثانية إلى ٠.٠١٣ ثانية — تحسن بمقدار ٩ أضعاف. هذا يوضح أهمية اختيار نوع الفهرس الصحيح: الفهارس الفردية جيدة للاستعلامات التي تستخدم عموداً واحداً، لكن الفهارس المركبة ضرورية للاستعلامات التي تستخدم عدة أعمدة معاً.
الفهارس ليست دائماً الحل السحري. في بعض الحالات، يمكن أن تجعل الأمور أسوأ. مثلاً، إذا كان لديك جدول يتم تحديثه بشكل متكرر (مثل جدول السجلات أو الجداول التي تسجل نشاط المستخدمين)، فإن كل فهرس إضافي يزيد من تكلفة عمليات الإدراج والتحديث والحذف. في أحد المشاريع، كان لدينا جدول سجلات يحتوي على مئات الملايين من السجلات، وكان يتم إضافة آلاف السجلات الجديدة كل دقيقة. كان لدينا ٥ فهارس على هذا الجدول، وكانت عمليات الإدراج تستغرق حوالي ٥٠ ميلي ثانية لكل سجل — وهذا بطيء جداً بالنسبة لحاجتنا.
عندما قمنا بإزالة ٣ فهارس غير ضرورية، انخفض وقت الإدراج إلى ٥ ميلي ثانية فقط — تحسن بمقدار ١٠ أضعاف. القاعدة الذهبية هنا هي: أضف فهرساً فقط إذا كان سيستخدم بشكل متكرر في استعلامات القراءة، وتأكد من أن تكلفة الصيانة (عمليات الكتابة) تستحق الفائدة (عمليات القراءة). في الجداول التي تتم كتابتها أكثر مما تتم قراءتها، قلل عدد الفهارس إلى الحد الأدنى.
الـ Joins هي واحدة من أقوى ميزات SQL، لكنها أيضاً واحدة من أكثرها خطورة على الأداء. عندما تقوم بعمل Join بين جدولين كبيرين، يمكن أن يتحول استعلام بسيط إلى كابوس للأداء إذا لم يتم كتابته بعناية. المشكلة ليست في الـ Join نفسه، بل في كيفية تنفيذه: نوع الـ Join (INNER، LEFT، RIGHT)، ترتيب الجداول في الـ Join، والفهارس المتاحة كلها عوامل حاسمة.
لنأخذ مثالاً من منصة تواصل اجتماعي: كان لدينا استعلام لاسترجاع المنشورات مع معلومات المستخدمين الذين نشروها. كان الجدول posts يحتوي على ٢٠ مليون سجل، والجدول users يحتوي على مليون سجل. الاستعلام الأصلي كان كالتالي:
-- الاستعلام الأصلي البطيء
EXPLAIN ANALYZE
SELECT p.*, u.username, u.avatar
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.created_at >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY p.likes DESC
LIMIT 50;
-- نتيجة Query Plan
Sort (cost=1234567.89..1234568.90 rows=401 width=384) (actual time=45678.901..45678.902 rows=50 loops=1)
Sort Key: p.likes DESC
Sort Method: top-N heapsort Memory: 45kB
-> Hash Join (cost=12345.67..1234567.89 rows=401 width=384) (actual time=1234.567..45678.123 rows=23456 loops=1)
Hash Cond: (p.user_id = u.id)
-> Seq Scan on posts p (cost=0.00..123456.78 rows=401 width=256) (actual time=0.123..12345.678 rows=23456 loops=1)
Filter: (created_at >= (CURRENT_DATE - '7 days'::interval))
Rows Removed by Filter: 19976544
-> Hash (cost=1234.56..1234.56 rows=100000 width=128) (actual time=12.345..12.345 rows=1000000 loops=1)
Buckets: 131072 Batches: 16 Memory Usage: 5120kB
-> Seq Scan on users u (cost=0.00..1234.56 rows=100000 width=128) (actual time=0.012..5.678 rows=1000000 loops=1)هذا الـ Query Plan يظهر عدة مشاكل: أولاً، Seq Scan على جدول posts لقراءة ٢٠ مليون سجل ثم تصفية ٢٣ ألف سجل فقط. ثانياً، Hash Join الذي يتطلب تحميل الجدول users بالكامل في الذاكرة (١٠٠٠٠٠٠ سجل). ثالثاً، عملية Sort مكلفة على ٢٣ ألف سجل. النتيجة؟ ٤٥.٦٧٨ ثانية لتنفيذ استعلام كان من المفترض أن يستغرق أقل من ثانية.
الحل هنا هو تحسين الاستعلام باستخدام الفهارس وترتيب العمليات. أولاً، أضفنا فهرساً على posts(created_at) لاستبدال Seq Scan بـ Index Scan. ثانياً، أضفنا فهرساً على posts(user_id) لتحسين الـ Join. ثالثاً، غيرنا ترتيب العمليات بحيث يتم تطبيق LIMIT قبل الـ Join:
-- الاستعلام المحسن
EXPLAIN ANALYZE
WITH recent_posts AS (
SELECT * FROM posts
WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY likes DESC
LIMIT 50
)
SELECT p.*, u.username, u.avatar
FROM recent_posts p
JOIN users u ON p.user_id = u.id;
-- نتيجة Query Plan الجديدة
Limit (cost=1234.56..1234.67 rows=50 width=384) (actual time=12.345..12.356 rows=50 loops=1)
-> Sort (cost=1234.56..1234.67 rows=401 width=384) (actual time=12.345..12.346 rows=50 loops=1)
Sort Key: p.likes DESC
Sort Method: top-N heapsort Memory: 45kB
-> Nested Loop (cost=0.42..1234.56 rows=401 width=384) (actual time=0.045..12.234 rows=23456 loops=1)
-> Index Scan using idx_posts_created_at on posts p (cost=0.42..123.45 rows=401 width=256) (actual time=0.045..1.234 rows=23456 loops=1)
Index Cond: (created_at >= (CURRENT_DATE - '7 days'::interval))
-> Index Scan using users_pkey on users u (cost=0.00..2.98 rows=1 width=128) (actual time=0.000..0.000 rows=1 loops=23456)
Index Cond: (id = p.user_id)الآن أصبح لدينا Index Scan على posts بدلاً من Seq Scan، وNested Loop بدلاً من Hash Join، وهذا أسرع بكثير. الوقت الفعلي انخفض من ٤٥.٦٧٨ ثانية إلى ٠.٠١٢ ثانية فقط — تحسن بمقدار ٣٨٠٠ ضعف. المفتاح هنا هو استخدام CTE (Common Table Expression) لتطبيق LIMIT قبل الـ Join، مما يقلل عدد السجلات التي يجب معالجتها بشكل كبير.
إذا كنت تعتقد أن تحسين SQL يتوقف عند كتابة استعلامات جيدة وإنشاء فهارس صحيحة، فأنت مخطئ. هناك طبقة أخرى من التحسين يمكن أن تحدث فرقاً كبيراً: الـ Query Caching. معظم محركات قواعد البيانات الحديثة تحتوي على آليات تخزين مؤقت مدمجة يمكنها تخزين نتائج الاستعلامات المتكررة في الذاكرة، مما يقلل الحاجة إلى إعادة تنفيذها من الصفر في كل مرة.
في أحد المشاريع، كان لدينا استعلام معقد يستغرق حوالي ٥ ثوانٍ لتنفيذه، وكان يتم استدعاؤه آلاف المرات في الدقيقة. بعد تمكين الـ Query Caching في PostgreSQL (عن طريق ضبط shared_buffers وeffective_cache_size بشكل صحيح)، انخفض متوسط وقت الاستجابة إلى ٠.٠٠١ ثانية — تحسن بمقدار ٥٠٠٠ ضعف. لكن الـ Caching ليس حلاً سحرياً: فهو يعمل فقط مع الاستعلامات المتطابقة تماماً، ويجب أن يتم ضبطه بعناية لتجنب مشاكل مثل الـ Cache Invalidation.
لنأخذ مثالاً عملياً: كان لدينا استعلام لاسترجاع قائمة المنتجات الأكثر مبيعاً في آخر ٢٤ ساعة. هذا الاستعلام كان يتم تنفيذه كل ٥ دقائق لتحديث لوحة التحكم الإدارية. بدون Caching، كان الاستعلام يستغرق حوالي ٣ ثوانٍ. بعد تمكين Caching، أصبح الوقت ٠.٠٠٢ ثانية في معظم الحالات. لكن المشكلة ظهرت عندما أضفنا منتجاً جديداً — كان الكاش لا يزال يظهر البيانات القديمة حتى انتهاء صلاحية الكاش.
-- تمكين Query Caching في PostgreSQL
ALTER SYSTEM SET shared_buffers = '16GB';
ALTER SYSTEM SET effective_cache_size = '48GB';
ALTER SYSTEM SET work_mem = '64MB';
ALTER SYSTEM SET maintenance_work_mem = '2GB';
-- إعادة تحميل الإعدادات
SELECT pg_reload_conf();
-- استعلام مع Caching
EXPLAIN (ANALYZE, BUFFERS)
SELECT p.id, p.name, SUM(oi.quantity) as total_sold
FROM products p
JOIN order_items oi ON p.id = oi.product_id
JOIN orders o ON oi.order_id = o.id
WHERE o.created_at >= CURRENT_DATE - INTERVAL '24 hours'
GROUP BY p.id, p.name
ORDER BY total_sold DESC
LIMIT 10;الـ EXPLAIN (ANALYZE, BUFFERS) يظهر لنا معلومات إضافية عن استخدام الذاكرة والكاش. إذا رأيت أن الاستعلام يستخدم buffers بشكل فعال، فهذا يعني أن الكاش يعمل بشكل جيد. لكن تذكر أن الـ Caching ليس مناسباً لكل الحالات: الاستعلامات التي تعتمد على بيانات متغيرة بشكل متكرر (مثل أسعار الأسهم) ليست مرشحة جيدة للكاش، بينما الاستعلامات التي تعتمد على بيانات ثابتة نسبياً (مثل قوائم المنتجات) هي المرشحة المثالية.
الـ Query Caching يمكن أن يكون سيفاً ذو حدين. في بعض الحالات، يمكن أن يجعل الأداء أسوأ بدلاً من تحسينه. مثلاً، إذا كان لديك استعلامات معقدة جداً ولكن نادراً ما تتكرر، فإن الكاش سيستهلك ذاكرة دون فائدة. أيضاً، إذا كانت قاعدة البيانات تحتوي على الكثير من البيانات المتغيرة، فإن الكاش سيتطلب تحديثاً مستمراً، مما يقلل من فعاليته.
في أحد المشاريع، قمنا بتمكين الـ Query Caching على قاعدة بيانات تحتوي على ملايين السجلات التي تتغير باستمرار. النتيجة؟ أداء أسوأ بكثير لأن الكاش كان يتم إبطاله بشكل متكرر، مما تسبب في إعادة تنفيذ الاستعلامات من الصفر في كل مرة. الحل؟ استخدام استراتيجية Caching أكثر ذكاءً: بدلاً من الاعتماد على الكاش المدمج في قاعدة البيانات، قمنا بتنفيذ نظام Caching خارجي باستخدام Redis لتخزين نتائج الاستعلامات المتكررة فقط، مع آلية تحديث ذكية تعتمد على الأحداث (مثل تحديث الكاش عند تغيير البيانات).
كل ما تحدثنا عنه حتى الآن لا قيمة له بدون أرقام حقيقية. في عالم تحسين الأداء، الأرقام هي كل شيء — فهي التي تخبرك ما إذا كانت التغييرات التي قمت بها فعالة أم لا. في تجربتي، أفضل طريقة لقياس الأداء هي استخدام أدوات مثل EXPLAIN ANALYZE في PostgreSQL، أو SET STATISTICS في SQL Server، أو EXPLAIN في MySQL. هذه الأدوات تعطيك نظرة دقيقة على ما يحدث خلف الكواليس.
لنأخذ مثالاً عملياً من مشروع حقيقي: كان لدينا استعلام يستغرق ٦٠ ثانية لتنفيذ ٥٠ مليون سجل. بعد سلسلة من التحسينات (إضافة فهارس، تعديل الاستعلام، تحسين الـ Joins)، انخفض الوقت إلى ٣ ثوانٍ فقط. لكن كيف عرفنا بالضبط أين كانت المشكلة؟ استخدمنا EXPLAIN ANALYZE مع BUFFERS للحصول على تفاصيل دقيقة:
-- قياس الأداء قبل التحسين
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.id, u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at >= '2023-01-01'
GROUP BY u.id, u.name
ORDER BY order_count DESC
LIMIT 100;
-- نتائج القياس قبل التحسين
Limit (cost=1234567.89..1234568.90 rows=100 width=40) (actual time=60123.456..60123.457 rows=100 loops=1)
Buffers: shared hit=123456 read=789012 dirtied=3456
-> Sort (cost=1234567.89..1234568.90 rows=401 width=40) (actual time=60123.455..60123.456 rows=100 loops=1)
Sort Key: (COUNT(o.id)) DESC
Sort Method: top-N heapsort Memory: 45kB
Buffers: shared hit=123456 read=789012 dirtied=3456
-> HashAggregate (cost=1234567.89..1234568.90 rows=401 width=40) (actual time=60123.123..60123.345 rows=401 loops=1)
Group Key: u.id, u.name
Buffers: shared hit=123456 read=789012 dirtied=3456
-> Hash Left Join (cost=12345.67..1234567.89 rows=401 width=40) (actual time=1234.567..60120.123 rows=500000 loops=1)
Hash Cond: (u.id = o.user_id)
Buffers: shared hit=123456 read=789012 dirtied=3456
-> Seq Scan on users u (cost=0.00..12345.67 rows=401 width=36) (actual time=0.123..123.456 rows=401 loops=1)
Filter: (created_at >= '2023-01-01'::date)
Rows Removed by Filter: 999599
Buffers: shared read=12345
-> Hash (cost=1234.56..1234.56 rows=100000 width=8) (actual time=123.456..123.456 rows=1000000 loops=1)
Buckets: 131072 Batches: 16 Memory Usage: 5120kB
Buffers: shared read=776667
-> Seq Scan on orders o (cost=0.00..1234.56 rows=100000 width=8) (actual time=0.012..56.789 rows=1000000 loops=1)
Buffers: shared read=776667هذه النتائج تظهر عدة مشاكل: Seq Scan على كلا الجدولين، Hash Left Join مكلف، وكمية كبيرة من البيانات المقروءة من القرص (shared read=789012). بعد التحسينات، أصبح الـ Query Plan كالتالي:
-- قياس الأداء بعد التحسين
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.id, u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at >= '2023-01-01'
GROUP BY u.id, u.name
ORDER BY order_count DESC
LIMIT 100;
-- نتائج القياس بعد التحسين
Limit (cost=123.45..124.45 rows=100 width=40) (actual time=2.345..2.346 rows=100 loops=1)
Buffers: shared hit=456 read=123
-> Sort (cost=123.45..124.45 rows=401 width=40) (actual time=2.345..2.345 rows=100 loops=1)
Sort Key: (COUNT(o.id)) DESC
Sort Method: top-N heapsort Memory: 45kB
Buffers: shared hit=456 read=123
-> HashAggregate (cost=123.45..124.45 rows=401 width=40) (actual time=2.340..2.342 rows=401 loops=1)
Group Key: u.id, u.name
Buffers: shared hit=456 read=123
-> Nested Loop Left Join (cost=0.42..123.45 rows=401 width=40) (actual time=0.045..2.123 rows=500000 loops=1)
Buffers: shared hit=456 read=123
-> Index Scan using idx_users_created_at on users u (cost=0.42..12.34 rows=401 width=36) (actual time=0.045..0.123 rows=401 loops=1)
Index Cond: (created_at >= '2023-01-01'::date)
Buffers: shared hit=12 read=34
-> Index Scan using idx_orders_user_id on orders o (cost=0.00..0.27 rows=1 width=8) (actual time=0.004..0.004 rows=1 loops=401)
Index Cond: (user_id = u.id)
Buffers: shared hit=444 read=89الآن أصبح لدينا Index Scan بدلاً من Seq Scan، وNested Loop بدلاً من Hash Join، وكمية البيانات المقروءة من القرص انخفضت بشكل كبير (shared read=123 بدلاً من 789012). الوقت الفعلي انخفض من ٦٠.١٢٣ ثانية إلى ٢.٣٤٥ ثانية فقط — تحسن بمقدار ٢٥ ضعفاً. هذه هي قوة القياسات الحقيقية: فهي تظهر لك بالضبط أين كانت المشكلة وكيف تم حلها.
بعد أكثر من عشر سنوات في تحسين استعلامات SQL، تعلمت أن معظم المشاكل تأتي من أشياء بسيطة يتم تجاهلها. إليك نصائحي الذهبية التي لا تجدها في الدروس التقليدية:
في النهاية، تحسين SQL ليس علماً دقيقاً، بل هو مزيج من الفن والهندسة. لا يوجد حل واحد يناسب الجميع، وكل قاعدة بيانات لها خصوصيتها. لكن إذا فهمت المبادئ الأساسية — كيف يعمل محرك قاعدة البيانات، كيف يقرأ البيانات من القرص، وكيف يستخدم الفهارس — فستكون قادراً على تحسين أي استعلام مهما كان معقداً. ابدأ دائماً بقياس الأداء قبل وبعد كل تغيير، ولا تفترض أبداً أن الحل الذي يعمل في بيئة التطوير سيعمل بنفس الكفاءة في الإنتاج. وعندما تواجه مشكلة أداء، تذكر: الـ Query Plan هو صديقك الأفضل، والأرقام لا تكذب أبداً.