Оптимизация SQL-запросов: 10 способов ускорить медленный запрос
Медленный запрос почти никогда не лечится «более мощным сервером». В девяти случаях из десяти причина в самом запросе или отсутствующем индексе — и устраняется она за минуты. Вот чек-лист из десяти техник в порядке, в котором их стоит применять.
1. Начните с EXPLAIN: узнайте, что делает СУБД
Оптимизировать вслепую бессмысленно. Команда EXPLAIN показывает план запроса — как именно СУБД собирается его выполнять:
MySQL 8.1EXPLAIN SELECT * FROM orders WHERE user_id = 1;
Seq Scan on orders (cost=0.00..10.70 rows=60 width=44) Filter: (user_id = 1)
Seq Scan означает полный перебор таблицы: СУБД читает все строки подряд и проверяет условие на каждой. На таблице в тысячу строк это незаметно, на десяти миллионах — секунды. Цель большинства оптимизаций — превратить Seq Scan по большой таблице в Index Scan, точечный доступ через индекс.
EXPLAIN ANALYZE дополнительно выполняет запрос и показывает фактическое время и число строк на каждом шаге — именно с него стоит начинать любой разбор медленного запроса.
2. Поставьте индексы на столбцы из WHERE и JOIN
Индекс — главный инструмент ускорения чтения: вместо перебора всей таблицы СУБД спускается по отсортированной структуре прямо к нужным строкам. Кандидаты на индекс — столбцы, которые регулярно встречаются в WHERE, условиях JOIN и ORDER BY:
MySQL 8.1CREATE INDEX idx_orders_user_id ON orders (user_id);
Насколько это меняет картину, мы замерили на таблице в миллион заказов (PostgreSQL 17). Поиск заказов одного клиента до и после создания индекса:
без индекса: Seq Scan, перебраны все 1 000 000 строк — 23.0 ms с индексом: Index Scan, прочитаны только нужные 20 строк — 0.1 ms
Разница в двести раз на ровно том же запросе — и она растёт вместе с таблицей. Абсолютные числа зависят от железа, порядок разницы — нет.
Отдельно проверьте внешние ключи: первичные ключи индексируются автоматически, а вот столбцы-ссылки (user_id в таблице заказов) — нет, хотя именно по ним идут соединения. Подробно про устройство индексов — в уроке Индексы в SQL.
Обратная сторона: каждый индекс замедляет вставку и обновление, ведь его нужно поддерживать. Индексируйте то, по чему ищете, а не всё подряд.
3. Не заворачивайте индексированный столбец в функцию
Условие, в котором столбец обёрнут в функцию, лишает СУБД возможности использовать индекс — ей приходится вычислить функцию для каждой строки:
MySQL 8.1-- индекс по order_date не используется SELECT * FROM orders WHERE EXTRACT(YEAR FROM order_date) = 2024;
Seq Scan on orders (cost=0.00..11.84 rows=2 width=44) Filter: (EXTRACT(year FROM order_date) = '2024'::numeric)
Перепишите условие так, чтобы столбец остался «голым», — диапазоном:
MySQL 8.1-- индекс по order_date работает SELECT * FROM orders WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01';
То же правило ломают WHERE LOWER(email) = ..., WHERE price * 1.2 > 100, WHERE CAST(...). Универсальный приём: переносить вычисления с колонки на константу (price > 100 / 1.2).
Цена вопроса на миллионе строк с индексом по order_date — фильтр за один день двумя способами:
WHERE order_date::date = '2025-03-01' план: Seq Scan — 24.1 ms WHERE order_date >= '2025-03-01' AND order_date < '2025-03-02' план: Index Only Scan — 0.16 ms
Запросы возвращают одни и те же строки, но первый в полтораста раз медленнее: приведение ::date — та же функция вокруг столбца.
Запрос SELECT * FROM users WHERE YEAR(created_at) = 2026 работает медленно, хотя по created_at построен индекс. В чём причина?
4. Уберите SELECT *
SELECT * тянет все столбцы: тяжёлые текстовые поля, JSON, всё, что вам не нужно. Это лишний дисковый ввод-вывод, лишний трафик до приложения и упущенная возможность: если запросу хватает столбцов, которые уже есть в индексе, СУБД может ответить вообще не открывая таблицу. Перечисляйте только то, что используете:
MySQL 8.1SELECT order_id, total_amount FROM orders WHERE user_id = 1;
5. Осторожнее с LIKE, начинающимся с процента
Шаблон LIKE 'SUMMER%' использует индекс: это поиск по префиксу, как в словаре. Шаблон LIKE '%SUMMER%' — нет: совпадение может начаться где угодно, и СУБД остаётся только перебрать все строки.
Если поиск по подстроке нужен регулярно и таблица большая — это повод для полнотекстового поиска (FULLTEXT в MySQL, tsvector/pg_trgm в PostgreSQL), а не для LIKE.
Какой из шаблонов LIKE сможет использовать обычный индекс по столбцу name?
6. Проверьте подзапросы: EXISTS против IN
Для проверки «есть ли связанная запись» предпочитайте EXISTS — он останавливается на первом совпадении, тогда как IN сначала материализует весь список:
MySQL 8.1SELECT u.user_id FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.user_id);
Отдельная ловушка — NOT IN с подзапросом: если в списке встретится хоть один NULL, условие не вернёт ни одной строки. NOT EXISTS свободен от этой проблемы и обычно быстрее.
7. Фильтруйте до агрегации, а не после
WHERE отбрасывает строки до группировки, HAVING — после. Условия, не зависящие от агрегатов, всегда ставьте в WHERE:
MySQL 8.1-- лишняя работа: сгруппировали всё, потом выбросили половину SELECT user_id, SUM(total_amount) FROM orders GROUP BY user_id HAVING MIN(order_date) >= '2024-01-01'; -- если условие про отдельные строки — фильтруйте сразу SELECT user_id, SUM(total_amount) FROM orders WHERE order_date >= '2024-01-01' GROUP BY user_id;
Обратите внимание: это разные запросы по смыслу — второй суммирует только заказы 2024 года. Принцип общий: чем раньше СУБД избавится от лишних строк, тем меньше работы на всех следующих шагах.
8. Пагинация: OFFSET читает всё, что пропускает
Классическая пагинация LIMIT 20 OFFSET 100000 заставляет СУБД прочитать и выбросить сто тысяч строк, чтобы отдать двадцать. Чем дальше страница, тем медленнее запрос.
Решение — keyset-пагинация: запоминаем последнее показанное значение и продолжаем от него по индексу:
MySQL 8.1SELECT order_id, order_date FROM orders WHERE order_id > 100020 ORDER BY order_id LIMIT 20;
Такой запрос выполняется одинаково быстро и для первой страницы, и для тысячной. На той же таблице в миллион строк: страница после OFFSET 500000 — 79.6 ms, keyset-запрос с WHERE order_id > 500000 — 0.05 ms.
9. Составной индекс: порядок столбцов решает
Если запрос фильтрует по двум столбцам сразу, два отдельных индекса хуже одного составного:
MySQL 8.1CREATE INDEX idx_orders_user_status ON orders (user_id, status);
Правило: индекс по (user_id, status) работает для условий по user_id и по паре user_id + status, но не помогает запросу только по status — как телефонная книга, отсортированная по фамилии, бесполезна для поиска по имени. Ставьте первым столбец, который фильтрует чаще и жёстче.
10. Избавьтесь от N+1 и построчных изменений
Иногда медленный SQL — это не один запрос, а тысяча маленьких. Классика — цикл в приложении: достали список заказов, потом для каждого отдельным запросом — клиента. База выполняет N+1 запросов вместо одного JOIN:
MySQL 8.1SELECT o.order_id, u.email FROM orders o JOIN users u ON u.user_id = o.user_id;
Та же логика для записи: одна вставка ста строк через INSERT INTO ... VALUES (...), (...), ... в разы быстрее ста отдельных INSERT — на каждый запрос накладываются сетевой круг и накладные расходы транзакции.
Куда копать дальше
Этот чек-лист закрывает типовые случаи, но за каждым пунктом стоит своя глубина: селективность индексов, покрывающие и частичные индексы, стоимостная модель оптимизатора, оптимизация JOIN и сортировок. Этому посвящён отдельный курс Оптимизация SQL-запросов в PostgreSQL — с живыми планами запросов и замерами на каждой технике.
Что дальше
- как устроены индексы и какие они бывают — в уроке Индексы в SQL;
- свежий разбор ранжирующих функций для отчётов — в статье ROW_NUMBER, RANK и DENSE_RANK;
- системно прокачать производительность запросов — в курсе Оптимизация SQL в PostgreSQL.
Читайте по теме
SQL с нуля: пошаговый план изучения для начинающих в 2026
Дорожная карта на 6 недель — от первого SELECT до собеседования
Нормализация баз данных: нормальные формы простыми словами
1НФ, 2НФ и 3НФ на одном сквозном примере
ROW_NUMBER, RANK и DENSE_RANK в SQL: отличия на одном примере
Три ранжирующие функции, один запрос — и разница видна