Производительность
6 мин чтения·

Оптимизация SQL-запросов: 10 способов ускорить медленный запрос

Медленный запрос почти никогда не лечится «более мощным сервером». В девяти случаях из десяти причина в самом запросе или отсутствующем индексе — и устраняется она за минуты. Вот чек-лист из десяти техник в порядке, в котором их стоит применять.

1. Начните с EXPLAIN: узнайте, что делает СУБД

Оптимизировать вслепую бессмысленно. Команда EXPLAIN показывает план запроса — как именно СУБД собирается его выполнять:

MySQL 8.1
EXPLAIN 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.1
CREATE 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.1
SELECT 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.1
SELECT 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.1
SELECT 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.1
CREATE 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.1
SELECT 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 — с живыми планами запросов и замерами на каждой технике.

Что дальше

Читайте по теме