الخطوات
-
1
اكتشف الاستعلام البطيء أولاً
لا يمكنك إصلاح ما لا تجده. ثلاثة أماكن للبحث، مرتبة حسب الدقة:
- سجل الاستعلامات البطيئة في MySQL — المصدر الأكثر موثوقية. فعّله واضبط عتبة زمنية؛ سيكتب MySQL كل استعلام يتجاوزها.
- Laravel Telescope / Debugbar — يعرض كل استعلامات الطلب ومدتها ومكدس الاستدعاء الذي أطلقها. لا غنى عنه في بيئة التطوير.
- DB::listen() في AppServiceProvider — سجّل الاستعلامات في ملف في أي بيئة، بدون حزمة إضافية.
sql-- Enable slow query log (add to my.cnf or run at runtime) SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1; -- log queries over 1 second SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log'; -- View the log -- tail -f /var/log/mysql/slow.log -- Or use mysqldumpslow to summarize it -- mysqldumpslow -s t -t 10 /var/log/mysql/slow.log -
2
الاستماع للاستعلامات في Laravel
في بيئة التطوير، سجّل مستمع استعلامات في
AppServiceProvider::boot(). هذا يسجّل كل استعلام مع روابطه ووقت التنفيذ — بدون أدوات خارجية. أزله أو قيّده بفحصAPP_DEBUGقبل النشر.php<?php // app/Providers/AppServiceProvider.php use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; public function boot(): void { if (config('app.debug')) { DB::listen(function ($query) { if ($query->time > 100) { // only log queries over 100ms Log::channel('daily')->warning('Slow query', [ 'sql' => $query->sql, 'bindings' => $query->bindings, 'time_ms' => $query->time, ]); } }); } } -
3
شغّل EXPLAIN على الاستعلام البطيء
أضف
EXPLAINقبل SELECT واقرأ المخرجات. الأعمدة الأهم:- type — نوع الوصول. من الأسوأ إلى الأفضل:
ALL(مسح كامل) →index→range→ref→eq_ref→const. تريدrefأو أفضل. - key — الفهرس المستخدم فعلاً. NULL يعني عدم استخدام أي فهرس.
- rows — العدد التقديري للصفوف التي سيفحصها MySQL. اضربه عبر كل صفوف المخرجات للحصول على الجهد الكلي.
- Extra — انتبه لـ
Using filesortوUsing temporary— كلاهما مكلف.
sqlEXPLAIN SELECT o.id, o.created_at, u.email FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'pending' ORDER BY o.created_at DESC LIMIT 20; -- Example bad output: -- +----+------+--------+------+------+------+-------------+ -- | id | type | key | ref | rows | filt | Extra | -- +----+------+--------+------+------+------+-------------+ -- | 1 | ALL | NULL | NULL | 980k | 0.50 | Using where | -- +----+------+--------+------+------+------+-------------+ -- type=ALL + key=NULL = full table scan on 980,000 rows - type — نوع الوصول. من الأسوأ إلى الأفضل:
-
4
أضف الفهرس المفقود
الحل لمسح الجدول الكامل في WHERE/ORDER BY هو في الغالب فهرس. للاستعلام أعلاه، فهرس مركّب على
(status, created_at)يغطي كلاً من الفلتر والترتيب في مسح فهرس واحد. ترتيب الأعمدة مهم: ضع عمود المساواة أولاً (status)، ثم عمود النطاق/الترتيب (created_at).sql-- Add a composite index that covers the WHERE and ORDER BY ALTER TABLE orders ADD INDEX idx_orders_status_created (status, created_at); -- Run EXPLAIN again to verify EXPLAIN SELECT o.id, o.created_at, u.email FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'pending' ORDER BY o.created_at DESC LIMIT 20; -- Expected improved output: -- +----+------+---------------------------+-------+------+ -- | id | type | key | rows | Extra| -- +----+------+---------------------------+-------+------+ -- | 1 | ref | idx_orders_status_created | 4200 | | -- +----+------+---------------------------+-------+------+ -
5
توقف عن تغليف الأعمدة المفهرسة بدوال
الدوال المُطبَّقة على عمود في جملة WHERE تمنع استخدام الفهرس كلياً. لا يمكن لـ MySQL استخدام فهرس على
created_atإن كان الشرطDATE(created_at) = '2024-01-15'— يجب عليه حسابDATE()لكل صف.الحل دائماً إعادة كتابة الشرط كنطاق على قيمة العمود الخام.
sql-- Bad: function wraps the indexed column → full scan SELECT * FROM orders WHERE DATE(created_at) = '2024-01-15'; -- Bad: same problem with YEAR/MONTH SELECT * FROM orders WHERE YEAR(created_at) = 2024 AND MONTH(created_at) = 1; -- Good: range query — MySQL can use the index SELECT * FROM orders WHERE created_at >= '2024-01-15 00:00:00' AND created_at < '2024-01-16 00:00:00'; -- Good: LOWER() on a column kills the index too -- Bad: WHERE LOWER(email) = 'user@example.com' -- Good: store emails already lowercased, or use a case-insensitive collation -
6
استخدم EXPLAIN ANALYZE للتوقيتات الفعلية
يدعم MySQL 8.0+ الأمر
EXPLAIN ANALYZE، الذي ينفّذ الاستعلام فعلاً ويُعيد أعداد الصفوف والتوقيتات الحقيقية إلى جانب التقديرات. هذه الأداة الأدق لتشخيص الاستعلامات البطيئة — التقديرات فيEXPLAINالعادي قد تكون بعيدة عن الواقع بأوامر من الحجم على البيانات غير المتوازنة.sql-- MySQL 8.0+ only — actually runs the query EXPLAIN ANALYZE SELECT o.id, u.email FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'pending' ORDER BY o.created_at DESC LIMIT 20; -- Output shows: -- -> Limit: 20 row(s) (actual time=0.318..0.319 rows=20 loops=1) -- -> Index lookup on o using idx_orders_status_created -- (status='pending') (cost=1820 rows=4200) -- (actual time=0.089..0.304 rows=20 loops=1) -
7
أصلح استعلامات N+1 في طبقة التطبيق
مشكلة N+1 هي نمط استعلام-لكل-صف: تجلب 50 منشوراً، ثم الحلقة تُطلق 50 استعلاماً منفصلاً لجلب كاتب كل منشور. لا تظهر كاستعلام واحد بطيء — بل كـ 51 استعلاماً سريعاً يستغرقان معاً ثانيتين.
في Eloquent الحل هو
with(). في SQL العادي الحل هو JOIN. القاعدة: لا تستعلم داخل حلقة أبداً.php<?php // Bad: N+1 — fires one query per post $posts = Post::all(); foreach ($posts as $post) { echo $post->author->name; // SELECT * FROM users WHERE id = ? × N } // Good: eager load — 2 queries total regardless of N $posts = Post::with('author')->get(); foreach ($posts as $post) { echo $post->author->name; } // Also good: nested eager loading $posts = Post::with(['author', 'comments.author'])->paginate(20); // Laravel Debugbar or Telescope will flag N+1s visually — // enable them in development and look for repeated identical queries. -
8
قِس الأداء ببيانات واقعية الحجم
استعلام سريع على 1,000 صف ليس بالضرورة سريعاً على 1,000,000. قِس دائماً بحجم بيانات واقعي قبل أن تُعلن انتهاء الإصلاح. استخدم
EXPLAINقبل وبعد، سجّل وقت الاستعلام الفعلي بـEXPLAIN ANALYZEأو مؤقت، واحتفظ بالنتيجتين لتتمكن من إظهار التحسن.sql-- Benchmark: compare before and after -- 1. Record baseline SET @start = NOW(6); SELECT COUNT(*) FROM orders WHERE status = 'pending' ORDER BY created_at DESC; SELECT TIMESTAMPDIFF(MICROSECOND, @start, NOW(6)) / 1000 AS ms; -- 2. Add the index ALTER TABLE orders ADD INDEX idx_orders_status_created (status, created_at); -- 3. Run again and compare SET @start = NOW(6); SELECT COUNT(*) FROM orders WHERE status = 'pending' ORDER BY created_at DESC; SELECT TIMESTAMPDIFF(MICROSECOND, @start, NOW(6)) / 1000 AS ms; -- Also check SHOW STATUS LIKE 'Handler_read%' before and after -- to see how many rows were actually read by the storage engine.
نصائح ومحاذير
- <code>EXPLAIN</code> يعرض خطة الاستعلام قبل التنفيذ؛ <code>EXPLAIN ANALYZE</code> (MySQL 8+) يعرض التنفيذ الفعلي — دائماً فضّل <code>ANALYZE</code> حين تحتاج الحقيقة.
- عمود <code>rows</code> في EXPLAIN تقديري. في الجداول ذات توزيعات البيانات المتحيزة قد يكون بعيداً كثيراً — شغّل <code>ANALYZE TABLE orders</code> أولاً لتحديث الإحصاءات.
- إضافة فهرس تُسرّع القراءات لكنها تُبطئ الكتابة (INSERT/UPDATE/DELETE). في الجداول ذات الكتابة العالية، لكل فهرس تكلفة — احتفظ فقط بالمستخدم منها فعلاً.
- استخدم <code>SHOW INDEX FROM table_name</code> لرؤية كل الفهارس الموجودة وكثافتها قبل إضافة فهرس مكرر.
- تحتوي <code>performance_schema</code> و<code>sys</code> في MySQL على views مثل <code>sys.statements_with_full_table_scans</code> تكشف الأنماط البطيئة عبر كل حركة المرور، لا مجرد جلستك الحالية.
خاتمة
الاستعلامات البطيئة دائماً قابلة للتشخيص — EXPLAIN يخبرك بالضبط ما يفعله المحسّن ولماذا. ابدأ بإيجاد الاستعلام، افهم نوع الوصول، أضف الحد الأدنى من الفهارس التي تغطي WHERE وORDER BY، ثم تحقق ببيانات حقيقية. مشكلة N+1 هي النصف الآخر من أداء قاعدة البيانات؛ أصلحها في طبقة Eloquent قبل أن تتضاعف.