چند سال پیش، پروژه‌ای داشتم که در آن یک صفحه‌ی گزارش تحلیلی، نُه ثانیه طول می‌کشید تا باز شود. صاحب سایت شکایت داشت که کاربران از گزارش صرف‌نظر می‌کنند و مدیران شرکت هم حاضر نیستند برای دیدنش صبر کنند. با یک نگاه سرسری به کد، اوضاع روشن بود: کوئری‌ها با SELECT * نوشته شده بودند، در حلقه‌ای چندبار به دیتابیس می‌زدند، و روی ستون‌های WHERE، هیچ ایندکسی وجود نداشت. با بازنویسی همان کوئری‌ها و اضافه‌کردن سه ایندکس مناسب، زمان از نُه ثانیه به کمتر از چهارصد میلی‌ثانیه رسید — بدون تغییر هاست، بدون افزودن سخت‌افزار، فقط با استفاده‌ی درست از ابزارهایی که همین دیتابیس در اختیارمان می‌گذارد. آن پروژه برایم تبدیل شد به پرونده‌ی مرجع بهینه سازی کوئری های MySQL؛ از آن روز به بعد، هر وقت با کندی دیتابیس روبه‌رو می‌شوم، اول همان مسیر را می‌روم: اندازه‌گیری، تحلیل، بازنویسی، و ایندکس. در این مقاله، همان مسیر را گام‌به‌گام با هم طی می‌کنیم — با تجربه‌های میدانی از پروژه‌هایی که خودم روی آن‌ها کار کرده‌ام.

چرا بهینه‌سازی کوئری، اولویت اول است؟

اگر با مفاهیم پایه‌ی MySQL آشنایی کم دارید، اول آموزش MySQL از صفر را بخوانید. اما اگر با SELECT، WHERE و JOIN راحت هستید، دیگر وقت آن است که به کیفیت کوئری‌ها فکر کنید. تجربه‌ی من می‌گوید در نود درصد پروژه‌های کند، دو دلیل اصلی وجود دارد: نبود ایندکس مناسب، و کوئری‌هایی که ناکارا نوشته شده‌اند. اگر با ایندکس‌گذاری در MySQL آشنا هستید، این مقاله مکمل طبیعی آن است — چون ایندکس، شرط لازم است، ولی کافی نیست.

سه دلیل که در پروژه‌های واقعی به آن‌ها رسیده‌ام، نشان می‌دهد چرا بهینه‌سازی کوئری، از هر تصمیم دیگر مهم‌تر است:

  • اثر فوری: تغییر یک کوئری یا افزودن یک ایندکس، می‌تواند زمان پاسخ یک صفحه را از چند ثانیه به چند صد میلی‌ثانیه برساند — بدون نیاز به ارتقای سخت‌افزار.
  • تأثیر روی همه‌ی کاربران: یک کوئری کند که روی هر بار باز کردن یک صفحه اجرا می‌شود، به‌طور پنهانی تمام بار سرور را می‌خورد. برعکس، بهینه‌سازی همان کوئری، تجربه‌ی همه‌ی کاربران را بهتر می‌کند.
  • قابل‌اندازه‌گیری: برخلاف بسیاری از بحث‌های معماری، بهینه‌سازی کوئری، عددی است. با EXPLAIN و slow_query_log، می‌توانید قبل و بعد را دقیق بسنجید و مطمئن شوید تلاش‌تان مؤثر بوده. همین اصل را در بهینه‌سازی جداول MySQL برای سرعت بیشتر هم روی سطوح دیگری از دیتابیس دیده‌ام.

نکته‌ی مهمی که در پروژه‌های واقعی به آن رسیده‌ام: بهینه‌سازی کوئری، فقط برای زمانی نیست که سایت کند است. اگر سایت شما امروز سریع است، اما کوئری‌های بدی دارد، دیر یا زود با رشد داده به بحران می‌رسد. تجربه‌ی من در چند پروژه این بوده که سایتی با هزار رکورد بدون مشکل کار می‌کند، ولی در پنجاه هزار رکورد، همان کوئری‌ها به دیوار می‌خورند. پیشگیری در اینجا، بسیار ارزان‌تر از درمان است — اصلی که در تأثیر دیتابیس بر سرعت سایت با جزئیات بیشتری توضیح داده‌ام.

بهینه‌سازی کوئری، صرفه‌جویی در توان سرور نیست؛ صرفه‌جویی در اعتماد کاربر است — چون کاربر، تفاوت بین کوئری بد و کوئری خوب را با چشم می‌بیند.

گام صفر: یافتن کوئری‌های کند

قبل از هر تغییری، باید بدانید چه کوئری‌هایی کند هستند. بدون این گام، بهینه‌سازی حدس و گمان است. دو ابزار اصلی که در پروژه‌های واقعی به‌کار می‌برم:

۱) Slow Query Log

-- فعال‌سازی لاگ کوئری‌های کند
SET GLOBAL slow_query_log = "ON";
SET GLOBAL long_query_time = 1;  -- کوئری‌های بیش از یک ثانیه
SET GLOBAL slow_query_log_file = "/var/log/mysql/slow.log";

-- بررسی تنظیمات
SHOW VARIABLES LIKE "%slow%";

بعد از چند روز، فایل لاگ پر از کوئری‌های کند می‌شود. اما نکته‌ی مهم این است که نباید همه‌ی کوئری‌های لاگ را بهینه کنید — فقط آن‌هایی که پرارزش هستند. معیار من: کوئری‌هایی که هم کند هستند و هم پرتکرار. یک کوئری نیم‌ثانیه‌ای که روزی یک بار اجرا می‌شود، از یک کوئری صد‌میلی‌ثانیه‌ای که هزار بار در روز اجرا می‌شود، ضرر کمتری دارد.

۲) Performance Schema

SELECT
    DIGEST_TEXT,
    COUNT_STAR AS exec_count,
    ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms,
    ROUND(SUM_TIMER_WAIT / 1000000000, 2) AS total_ms
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 15;

این کوئری، پانزده کوئری که بیشترین بار را به سرور تحمیل کرده‌اند، نشان می‌دهد. تفاوتش با slow_query_log در این است که معیار سنجشش، مجموع زمان اجراست نه فقط کندی. تجربه‌ی من: در بیشتر پروژه‌ها، دو یا سه کوئری وجود دارد که با هم، هفتاد درصد بار دیتابیس را می‌خورند. اگر همان‌ها را بهینه کنید، بازی را بردید.

یک نکته‌ی عملی از تجربه: در پروژه‌های وردپرسی، قبل از هر تغییر، دستورات پرکاربرد MySQL را در یک برگه مرور کنید. دستورات SHOW PROCESSLIST و SHOW STATUS در شناسایی کوئری‌های گیرکرده، بی‌نظیرند — خصوصاً وقتی سایت زیر بار است و نمی‌دانید کدام کوئری مسئول قفل است.

EXPLAIN: رادیوگرافی کوئری

EXPLAIN، مهم‌ترین ابزار شما در بهینه‌سازی کوئری است. با آن، به‌جای حدس‌زدن، می‌بینید MySQL دقیقاً چه می‌کند:

EXPLAIN SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.created_at >= "2026-01-01"
ORDER BY o.total DESC
LIMIT 20;

سه ستونی که در خروجی EXPLAIN، اولین چیزی است که نگاه می‌کنم:

  • type: نوع جستجو. اگر ALL ببینم، یعنی MySQL کل جدول را اسکن می‌کند — سیگنال قرمز. اگر ref، const یا range ببینم، یعنی ایندکس به‌کار رفته.
  • rows: تخمین تعداد رکوردهای بررسی‌شده. اگر عدد بزرگ است و نوع جستجو ALL، حتماً ایندکس لازم است.
  • Extra: اگر Using filesort یا Using temporary ببینم، یعنی مرتب‌سازی یا گروه‌بندی، در حافظه‌ی موقت انجام می‌شود و بهینه‌سازی نیاز دارد.

در MySQL ۸.۰ به بعد، EXPLAIN ANALYZE هم موجود است که کوئری را واقعاً اجرا می‌کند و زمان دقیق هر مرحله را نشان می‌دهد. در پروژه‌های واقعی، برای کوئری‌های پیچیده، همین EXPLAIN ANALYZE تفاوت بین حدس و دانستن را می‌سازد — چون نشان می‌دهد کدام مرحله، وقت‌گیر است.

در دیتابیس، حدس گران‌ترین ابزار است؛ EXPLAIN، ارزان‌ترین راه برای تبدیل حدس به دانش.

ایندکس، سریع‌ترین راه‌حل

در بیشتر پروژه‌ها، اولین و مؤثرترین گام بهینه‌سازی، افزودن ایندکس مناسب است. اصول کامل ایندکس‌گذاری را در ایندکس‌گذاری در MySQL باز کرده‌ام؛ اینجا فقط سه نکته‌ی کلیدی که در بهینه‌سازی کوئری به‌کار می‌آید:

۱) ایندکس روی ستون‌های WHERE و JOIN

-- ایندکس روی ستون پرتکرار در WHERE
CREATE INDEX idx_orders_created ON orders (created_at);

-- ایندکس روی کلید خارجی که در JOIN استفاده می‌شود
CREATE INDEX idx_orders_user ON orders (user_id);

نکته‌ی مهمی که در طراحی دیتابیس در MySQL هم رویش تأکید کرده‌ام: در MySQL، کلید خارجی به‌طور خودکار ایندکس نمی‌شود (برخلاف بعضی دیتابیس‌های دیگر). اگر روی user_id در جدول orders ایندکس نگذارید، هر JOIN تبدیل به full scan می‌شود.

۲) ایندکس ترکیبی برای کوئری‌های چندشرطی

CREATE INDEX idx_orders_user_status ON orders (user_id, status);

اگر کوئری شما شرط WHERE user_id = 5 AND status = "paid" دارد، ایندکس ترکیبی روی (user_id, status) مؤثرتر از دو ایندکس جداگانه است. ترتیب ستون‌ها مهم است — از چپ به راست، ستون‌ها باید به همان ترتیبی که در کوئری فیلتر می‌شوند، در ایندکس بیایند. تجربه‌ی من: همیشه ستونی که مقدارهای متمایز بیشتری دارد را در ابتدای ایندکس می‌گذارم.

۳) ایندکس پوششی برای گزارش‌های پرتکرار

CREATE INDEX idx_orders_user_total ON orders (user_id, total);

SELECT total FROM orders WHERE user_id = 5;

اگر ایندکس، همه‌ی ستون‌های کوئری را داشته باشد، MySQL لازم نیست به جدول اصلی برگردد. این تکنیک، در گزارش‌های تحلیلی و کوئری‌های پرتکرار، تفاوت بین چند صد میلی‌ثانیه و چند میلی‌ثانیه را می‌سازد. در خروجی EXPLAIN، اگر Extra حاوی Using index بود، ایندکس پوششی به‌کار رفته است.

پرهیز از SELECT *

یکی از بزرگ‌ترین دشمنان کارایی، SELECT * است. سه دلیل که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

  • حجم داده‌ی منتقل‌شده: اگر جدول شما یک ستون TEXT یا BLOB دارد و شما SELECT * می‌زنید، همه‌ی آن را می‌کشید — بی‌آنکه لازم داشته باشید.
  • جلوگیری از ایندکس پوششی: MySQL فقط وقتی از ایندکس پوششی استفاده می‌کند که همه‌ی ستون‌های SELECT در ایندکس باشند. با SELECT *، این امکان از بین می‌رود.
  • شکنندگی در تغییر ساختار: اگر ستون جدیدی اضافه کنید، کوئری شما به‌طور خودکار آن را می‌خواند — که می‌تواند ناخواسته حجم پاسخ را بالا ببرد.
-- بد
SELECT * FROM users WHERE id = 5;

-- خوب
SELECT id, name, email FROM users WHERE id = 5;

در پروژه‌های واقعی، این تغییر ساده، در جداول حجیم، بار دیتابیس را تا ۳۰ درصد کم می‌کند. مخصوصاً اگر ستون‌های سنگینی مثل متن یا JSON در جدول دارید، اثرش چند برابر است.

شرایطی که ایندکس را بی‌اثر می‌کنند

گاهی ایندکس وجود دارد ولی MySQL از آن استفاده نمی‌کند. این یکی از پرتکرارترین باگ‌های پنهان در پروژه‌های واقعی است. سه سناریو:

۱) اعمال تابع روی ستون

-- ایندکس روی created_at استفاده نمی‌شود
SELECT * FROM orders WHERE YEAR(created_at) = 2026;

-- درست: ایندکس استفاده می‌شود
SELECT * FROM orders
WHERE created_at >= "2026-01-01" AND created_at < "2027-01-01";

۲) LIKE با % در ابتدا

-- بی‌اثر
SELECT * FROM users WHERE name LIKE "%Ali%";

-- مؤثر
SELECT * FROM users WHERE name LIKE "Ali%";

۳) تبدیل نوع پنهان

-- اگر user_id INT است، این کوئری ایندکس را بی‌اثر می‌کند
SELECT * FROM orders WHERE user_id = "5";

-- درست
SELECT * FROM orders WHERE user_id = 5;

در پروژه‌های چندزبانه، این اشتباه زیاد دیده می‌شود — چون بعضی زبان‌ها (مثل پایتون) رشته را بدون تمایز پاس می‌دهند. نمونه‌های عملی این تعامل را در اتصال پایتون به MySQL با جزئیات توضیح داده‌ام.

بهینه‌سازی JOIN

JOINها، پرکاربردترین و در عین حال، پرهزینه‌ترین بخش کوئری‌ها هستند. اگر با مفاهیم پایه‌ی JOIN آشنا نیستید، آموزش JOIN در MySQL مسیر را نشان می‌دهد. در اینجا، سه نکته‌ی بهینه‌سازی که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

۱) ترتیب JOIN مهم است

MySQL از چپ به راست، جداول را پردازش می‌کند. جدول کوچک‌تر (با رکوردهای کمتر) را اول بیاورید تا دامنه‌ی جستجو کوچک‌تر باشد:

-- بهتر: جدول کوچک‌تر اول
SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE u.id = 5;

۲) ایندکس روی ستون‌های JOIN

ستون‌هایی که در شرط ON استفاده می‌شوند (مثل user_id در جدول orders) باید ایندکس داشته باشند. تجربه‌ی من: در پروژه‌ای که هفت جدول با هم JOIN می‌شدند، افزودن ایندکس روی سه ستون کلیدی، زمان کوئری را از چهار ثانیه به کمتر از نیم‌ثانیه رساند.

۳) مراقب fan-out باشید

اگر شرط JOIN یکتا نباشد، تعداد رکوردهای خروجی می‌تواند از هر دو جدول بیشتر شود. این پدیده، «fan-out» نام دارد و در پروژه‌های واقعی، به گزارش‌های اشتباه و کوئری‌های کند منجر می‌شود. قبل از هر JOIN چندسطری، تعداد رکوردهای خروجی را تخمین بزنید:

SELECT COUNT(*)
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.status = "paid";

اگر این عدد از تعداد کاربران یا سفارش‌ها بیشتر شد، یعنی fan-out رخ داده و باید ساختار را بازبینی کنید.

زیرکوئری یا JOIN؟

یک بحث همیشگی در بهینه‌سازی: زیرکوئری یا JOIN؟ تجربه‌ی من می‌گوید جواب یکسان نیست، ولی یک قاعده‌ی سرانگشتی وجود دارد:

-- زیرکوئری با IN
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 1000000);

-- معادل با JOIN
SELECT DISTINCT u.*
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.total > 1000000;

در نسخه‌های جدید MySQL، بهینه‌ساز می‌تواند این دو را به هم تبدیل کند، ولی در پروژه‌های واقعی، همین نکته باعث تفاوت‌های محسوس می‌شود:

  • زیرکوئری با IN: وقتی نتیجه‌ی زیرکوئری کوچک است و تکرارها در جدول اصلی ندارید، انتخاب بهتری است.
  • زیرکوئری با EXISTS: برای زیرکوئری‌های بزرگ، سریع‌تر از IN است چون در اولین تطبیق متوقف می‌شود.
  • JOIN: وقتی نیاز به ستون‌هایی از جدول دوم دارید یا می‌خواهید چندبار استفاده کنید.

یک نکته‌ی عملی: در MySQL ۸.۰ به بعد، IN با زیرکوئری بهتر از گذشته بهینه می‌شود، ولی در نسخه‌های قدیمی‌تر، JOIN انتخاب امن‌تری است. اگر با پروژه‌ای کار می‌کنید که روی MySQL قدیمی اجرا می‌شود، قواعد بهینه‌سازی را با دقت بیشتری اعمال کنید.

در بهینه‌سازی کوئری، هیچ راه‌حل جادویی وجود ندارد؛ فقط یک الگوی ذهنی: هر چیزی که در EXPLAIN قرمز است، نیازمند توجه؛ هر چیزی که سبز است، فعلاً بگذارید بماند.

صفحه‌بندی سریع با cursor

یکی از کندترین کوئری‌ها در پروژه‌های واقعی، صفحه‌بندی با OFFSET بزرگ است:

-- کند در صفحات پایانی
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;

مشکل این کوئری این است که MySQL ابتدا باید ۱۰۰٬۰۰۰ رکورد را از دست بدهد و بعد بیست رکورد بعدی را بخواند. در جدول‌های بزرگ، این کار به‌شدت کند می‌شود. راه‌حل، صفحه‌بندی مبتنی بر cursor:

-- سریع: با cursor
SELECT * FROM posts
WHERE id < 12345
ORDER BY id DESC
LIMIT 20;

در این روش، به‌جای شمردن رکوردهای قبلی، از شناسه‌ی آخرین رکورد صفحه‌ی قبل استفاده می‌کنید. تجربه‌ی من: در یک فروشگاه با میلیون‌ها محصول، تغییر صفحه‌بندی از OFFSET به cursor، زمان پاسخ صفحات آخر را از چند ثانیه به چند میلی‌ثانیه رساند.

نکته‌ی مهم: اگر می‌خواهید صفحه‌بندی را در جایی پیاده کنید که کاربر بخواهد به صفحه‌ی مثلاً پنجم بپرد، OFFSET راحت‌تر است. ولی اگر کاربر با اسکرول یا دکمه‌ی «بعدی» جلو می‌رود، cursor انتخاب درستی است. این نوع تصمیم‌گیری، در پروژه‌های وردپرسی هم رایج است — مخصوصاً برای آرشیوهای بزرگ که علت کندی سایت وردپرسی می‌تواند دقیقاً همین نوع صفحه‌بندی باشد.

تجمیع و GROUP BY بهینه

کوئری‌های تجمیعی (COUNT، SUM، AVG) با GROUP BY، بخش بزرگی از کندی دیتابیس‌های تحلیلی هستند. سه نکته‌ی بهینه‌سازی که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

۱) ایندکس روی ستون‌های GROUP BY

CREATE INDEX idx_orders_status_created ON orders (status, created_at);

SELECT status, COUNT(*)
FROM orders
WHERE created_at >= "2026-01-01"
GROUP BY status;

ایندکس ترکیبی روی (status, created_at) می‌تواند هم فیلتر و هم گروه‌بندی را سریع کند. در خروجی EXPLAIN، اگر Using temporary ندید، یعنی MySQL از ایندکس استفاده کرده و گروه‌بندی را در حافظه انجام نمی‌دهد.

۲) فیلتر قبل از GROUP BY

هرچه داده‌ی ورودی به GROUP BY کمتر باشد، سریع‌تر است. همیشه اول فیلتر کنید، بعد گروه‌بندی:

-- بهتر: فیلتر در WHERE
SELECT user_id, COUNT(*)
FROM orders
WHERE status = "paid"
GROUP BY user_id;

-- بد: فیلتر در HAVING
SELECT user_id, COUNT(*)
FROM orders
GROUP BY user_id
HAVING status = "paid";

۳) جدول‌های خلاصه برای گزارش‌های پرتکرار

اگر مرتب گزارش فروش ماهانه از ترکیب چند جدول بزرگ می‌سازید، یک جدول خلاصه بسازید و به‌جای محاسبه‌ی مجدد، از همان استفاده کنید:

CREATE TABLE daily_sales_summary (
    sale_date DATE PRIMARY KEY,
    order_count INT NOT NULL,
    total_amount DECIMAL(14, 2) NOT NULL,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

این جدول با یک اسکریپت زمان‌بندی‌شده (cron) هر شب به‌روز می‌شود. گزارش روزانه، به‌جای محاسبه‌ی مجدد از orders، فقط این جدول کوچک را می‌خواند — که چند صد برابر سریع‌تر است. این رویکرد، یکی از اصول کلاسیک انبار داده (Data Warehouse) است که در پروژه‌های تحلیلی، تفاوت بین گزارش‌های همیشه‌کند و گزارش‌های آنی را می‌سازد.

کش و کوئری‌های تکراری

در پروژه‌های واقعی، بسیاری از کوئری‌ها با همان پارامترها، بارها اجرا می‌شوند. کش کردن نتیجه‌ی این کوئری‌ها، یکی از مؤثرترین راه‌های کاهش بار دیتابیس است. سه سطح کش که در پروژه‌های خودم به‌کار برده‌ام:

۱) کش سطح برنامه

در PHP یا پایتون، می‌توانید نتیجه‌ی یک کوئری را در حافظه‌ی برنامه (Redis، Memcached یا APCu) کش کنید:

$cache_key = "top_products";
$data = apcu_fetch($cache_key);

if ($data === false) {
    $data = $pdo->query("SELECT * FROM products ORDER BY sales DESC LIMIT 10")->fetchAll();
    apcu_store($cache_key, $data, 300);  // ۵ دقیقه
}

این الگو، در وردپرس با transient پیاده می‌شود. اگر با کش در وردپرس کار می‌کنید، بهترین افزونه‌های کش وردپرس این سطح از بهینه‌سازی را از دیدگاه یک ابزار بررسی می‌کند.

۲) کش سطح دیتابیس

MySQL خودش یک query_cache دارد، ولی در نسخه‌های جدید این قابلیت حذف شده چون در پروژه‌های پرنویس، عملکرد ضعیفی داشت. راه‌حل امروز، استفاده از InnoDB Buffer Pool است که خود MySQL نتایج را در حافظه نگه می‌دارد:

SHOW VARIABLES LIKE "innodb_buffer_pool_size";

در پروژه‌های واقعی، افزایش innodb_buffer_pool_size به ۷۰٪ حافظه‌ی سرور، می‌تواند سرعت خواندن را چند برابر کند. اصول کاربردی تنظیم این متغیر در بهینه‌سازی جداول MySQL آمده است.

۳) کش سطح لبه (CDN)

برای صفحات عمومی که برای همه‌ی کاربران یکسان هستند، کش در CDN یا reverse proxy، بار دیتابیس را به‌طور کامل حذف می‌کند. تجربه‌ی من: در یک فروشگاه، با کش کردن صفحات دسته‌بندی روی CDN، بار دیتابیس تا نیمی کاهش یافت — چون همان کوئری‌ها روزی هزاران بار، اما با پاسخ یکسان، اجرا می‌شدند.

مشکل N+1 در برنامه

یکی از بزرگ‌ترین دشمنان بهینه‌سازی، مشکل N+1 است. این مشکل، نه در کوئری‌های تک، بلکه در الگوی اجرای کوئری‌ها ظاهر می‌شود. مثال:

// بد: N+1
$users = $db->query("SELECT * FROM users LIMIT 100")->fetchAll();

foreach ($users as $user) {
    $orders = $db->query("SELECT * FROM orders WHERE user_id = " . (int) $user["id"])->fetchAll();
    // پردازش
}

در این کد، یک کوئری برای گرفتن کاربران و صد کوئری جداگانه برای گرفتن سفارش هر کاربر اجرا می‌شود. مجموع: ۱۰۱ کوئری. راه‌حل درست:

// خوب: دو کوئری
$users = $db->query("SELECT * FROM users LIMIT 100")->fetchAll();
$user_ids = array_column($users, "id");

$placeholders = implode(",", array_fill(0, count($user_ids), "?"));
$stmt = $db->prepare("SELECT * FROM orders WHERE user_id IN ($placeholders)");
$stmt->execute($user_ids);
$orders = $stmt->fetchAll();

تجربه‌ی من: در پروژه‌ای با پایتون و Django، مشکل N+1 در یک API، باعث می‌شد برای برگرداندن یک لیست از سی کاربر، صد و هشتاد کوئری اجرا شود. با اضافه‌کردن select_related و prefetch_related، تعداد کوئری‌ها به چهار رسید و زمان پاسخ از سه ثانیه به سیصد میلی‌ثانیه کاهش یافت. همین اصل در PHP و وردپرس هم صدق می‌کند — اصول کلی در بهینه‌سازی کوئری‌های وردپرس با کدنویسی آمده است.

اشتباهاتی که در پروژه‌های واقعی دیده‌ام

در بازبینی کوئری‌های پروژه‌های مختلف، این اشتباهات را زیاد دیده‌ام:

  • بهینه‌سازی بدون اندازه‌گیری: بعضی تیم‌ها کوئری‌ها را «بهتر» می‌نویسند بدون این‌که بدانند کدام کوئری کند است. نتیجه: تغییرات زیاد، بهبود کم. همیشه اول اندازه بگیرید، بعد تغییر دهید.
  • نادیده‌گرفتن EXPLAIN: کوئری را تغییر می‌دهند ولی چک نمی‌کنند که واقعاً سریع‌تر شده. بدون EXPLAIN، حدس‌زدن است، نه بهینه‌سازی.
  • تغییر همه چیز با هم: به‌جای تغییر یک متغیر در هر مرحله، پنج چیز را همزمان عوض می‌کنند. اگر نتیجه بد شد، نمی‌فهمند کدام تغییر مسئول بوده. یک‌بار یک چیز تغییر دهید، اندازه بگیرید، بعد تغییر بعدی.
  • فراموش کردن SELECT *: همان اشتباه کلاسیک که در بخش‌های قبل توضیح دادم.
  • مشکل N+1 نادیده گرفته می‌شود: چون هر کوئری تک، سریع است، فکر می‌کنند مشکلی نیست. ولی صد کوئری سریع، مجموعاً کند است.
  • نبود ایندکس روی کلیدهای خارجی: همان‌طور که در ایندکس‌گذاری در MySQL گفتم، MySQL به‌طور خودکار ایندکس نمی‌سازد. اگر یادتان برود، JOINها تبدیل به full scan می‌شوند.
  • بهینه‌سازی قبل از کش: بعضی کوئری‌ها را می‌توان با کش، به‌طور کامل حذف کرد. قبل از بازنویسی، بپرسید آیا می‌توان نتیجه را کش کرد؟
  • بازنویسی بی‌دلیل: بعضی کوئری‌ها در واقع سریع هستند ولی به‌نظر پیچیده می‌آیند. قبل از بازنویسی، زمان‌شان را اندازه بگیرید. اگر سریع است، دست نزنید.
  • نادیده‌گرفتن fan-out در JOIN: کوئری طولانی می‌نویسند و بعد متعجب می‌شوند که تعداد رکوردها بیشتر از انتظار است. قبل از هر JOIN، تعداد رکوردهای خروجی را تخمین بزنید.
  • نبود monitoring: کوئری‌های کند را فقط وقتی می‌بینند که سایت بخوابد. با slow_query_log و performance_schema، می‌توانید قبل از بحران، مشکل را شناسایی کنید.
  • ایندکس زیاد در جداول پرنویس: بعضی تیم‌ها روی همه‌چیز ایندکس می‌گذارند و بعد متعجب می‌شوند که INSERT کند شده. ایندکس، هزینه‌ی نوشتن دارد.
  • نبود مستندسازی: کوئری‌ها را بهینه می‌کنند ولی دلیلش را ثبت نمی‌کنند. شش ماه بعد، توسعه‌دهنده‌ی جدید همان کوئری را به شکل ناکارا بازمی‌نویسد و مشکل برمی‌گردد.
  • فراموش کردن backup پیش از تغییر: قبل از هر تغییر ساختاری روی دیتابیس تولید، بکاپ بگیرید. اصول پشتیبان‌گیری در پشتیبان‌گیری از MySQL آمده است.

یک توصیه‌ی عملی از تجربه: بهینه‌سازی کوئری را مثل یک فرآیند درمانی ببینید، نه یک تعمیر یک‌باره. هر ماه، پانزده دقیقه به performance_schema نگاه کنید و کوئری‌های پرتکرار را بازبینی کنید. این عادت کوچک، جلوی خیلی از بحران‌ها را می‌گیرد — چون داده‌ها رشد می‌کنند و کوئری‌ای که امروز سریع است، شش ماه بعد کند می‌شود. اگر با امنیت دیتابیس هم سر و کار دارید، امنیت دیتابیس چیست لایه‌های مکمل را نشان می‌دهد و تراکنش‌ها در MySQL توضیح می‌دهد که چطور قفل‌ها و کوئری‌های طولانی، با هم تعامل می‌کنند.

سخن آخر

بهینه‌سازی کوئری‌های MySQL، یک تکنیک نیست؛ یک فرآیند است. سه نکته‌ی اصلی که در این مقاله به آن‌ها رسیدیم: اول، قبل از هر تغییری اندازه بگیرید — slow_query_log و performance_schema نقطه‌ی شروع هر بهینه‌سازی هستند؛ دوم، EXPLAIN را جدی بگیرید — تحلیل خروجی EXPLAIN، تفاوت بین حدس و دانستن است؛ سوم، هر کوئری را جداگانه بهینه کنید، نه همه را با هم — یک‌بار یک تغییر، اندازه‌گیری، بعد قدم بعدی.

اگر امروز می‌خواهید شروع کنید، سه کار کوچک پیشنهاد می‌کنم: slow_query_log را فعال کنید و چند روز داده جمع کنید، پنج کوئری پرتکرار را با EXPLAIN تحلیل کنید، و روی ستون اصلی WHERE یکی از آن‌ها، ایندکس بگذارید. همین سه کار، در بیشتر پروژه‌ها، تفاوت محسوسی در سرعت می‌سازد. اگر تجربه‌ای از بهینه‌سازی کوئری در پروژه‌های خودتان دارید — مخصوصاً اگر با یک کوئری پیچیده روبرو شده‌اید که با یک تغییر ساده، چند برابر سریع‌تر شد — در دیدگاه‌ها بنویسید؛ همین نکته‌های میدانی، برای خواننده‌ی بعدی از هر مستند رسمی ارزشمندتر است. ⚡