Почему медленный SQL-запрос: причины и решения

Карьерник — квиз-тренажёр в Telegram с 1500+ вопросами для собесов аналитика. SQL, Python, A/B, метрики. Бесплатно.

Короткий список частых причин

  1. Нет индекса на колонку в WHERE / JOIN
  2. Seq scan больших таблиц вместо index scan
  3. Плохой JOIN (cartesian, wrong key)
  4. DISTINCT / ORDER BY без индекса
  5. LIKE с префиксом % — не использует индекс
  6. Функция в WHERE на индексированной колонке
  7. Устаревшая статистика планировщика
  8. Слишком много JOIN
  9. Subquery пересчитывается на каждую строку
  10. Нет партиционирования на огромных таблицах

Шаг 1. Посмотреть план выполнения

PostgreSQL

EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 42;

Смотрите:

  • Seq Scan — полный перебор таблицы. Плохо для больших данных.
  • Index Scan — поиск по индексу. Хорошо.
  • Nested Loop — O(n×m). Может быть медленно.
  • Hash Join / Merge Join — обычно хорошо.
  • cost / actual time — сколько реально.

MySQL

EXPLAIN SELECT * FROM orders WHERE user_id = 42;

Обращайте внимание:

  • type: ALL (полный скан — плохо), ref / range (ок), const / eq_ref (отлично)
  • rows: сколько строк планирует прочитать
  • key: какой индекс использует

Типичные проблемы

1. Нет индекса на WHERE

SELECT * FROM orders WHERE user_id = 42;

Без индекса на user_id — seq scan таблицы. Решение:

CREATE INDEX idx_orders_user_id ON orders(user_id);

2. Функция на индексированной колонке

-- индекс не используется
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';

-- решение: functional index
CREATE INDEX idx_users_email_lower ON users(LOWER(email));

-- или нормализовать email до сравнения
SELECT * FROM users WHERE email = 'alice@example.com';

3. LIKE с % в начале

-- не использует индекс
SELECT * FROM users WHERE name LIKE '%Alice%';

-- индекс работает
SELECT * FROM users WHERE name LIKE 'Alice%';

Для поиска по подстроке — используйте full-text search (tsvector в Postgres, MATCH в MySQL).

4. Implicit type cast

-- user_id INT, но сравниваем со строкой
SELECT * FROM orders WHERE user_id = '42';

Cast делается для каждой строки → индекс может не использоваться. Решение — одинаковый тип.

5. OR в WHERE

-- планировщик может не использовать индексы для OR
SELECT * FROM users WHERE email = 'a@b.c' OR phone = '123';

-- переписать через UNION
SELECT * FROM users WHERE email = 'a@b.c'
UNION
SELECT * FROM users WHERE phone = '123';

6. IN с большим списком

-- 10 000 значений в IN — медленно
SELECT * FROM users WHERE user_id IN (1, 2, ..., 10000);

-- через temporary table
CREATE TEMP TABLE ids (id INT);
INSERT INTO ids VALUES (1), (2), ...;
SELECT * FROM users JOIN ids ON ids.id = users.user_id;

7. Subquery вместо JOIN

-- подзапрос вычисляется для каждой строки
SELECT *,
    (SELECT SUM(total) FROM orders WHERE user_id = u.id) AS total
FROM users u;

-- JOIN + GROUP BY работает быстрее
SELECT u.*, o.total
FROM users u
LEFT JOIN (
    SELECT user_id, SUM(total) AS total FROM orders GROUP BY user_id
) o ON o.user_id = u.id;

8. DISTINCT на большой выборке

-- сканирует + сортирует всё
SELECT DISTINCT user_id FROM events;

-- иногда быстрее через GROUP BY
SELECT user_id FROM events GROUP BY user_id;

Или использовать approximate count distinct в DWH:

-- ClickHouse / BigQuery
SELECT uniq(user_id) FROM events;  -- approximate

9. ORDER BY без индекса

-- сортирует миллион строк
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;

-- индекс на created_at даст O(log n)
CREATE INDEX idx_orders_created_at ON orders(created_at);

10. Устаревшая статистика

Планировщик использует статистику о данных. Если она старая — выбирает неоптимальный план.

-- Postgres
ANALYZE orders;

-- MySQL
ANALYZE TABLE orders;

Как искать узкое место

1. Найти медленные запросы

  • Postgres: pg_stat_statements
  • MySQL: slow query log (long_query_time)

2. EXPLAIN ANALYZE

Смотрите самый долгий node. Обычно это:

  • Seq Scan на большой таблице
  • Hash Join с плохим предсказанием
  • Sort без индекса

3. Проверить индексы

-- Postgres
SELECT * FROM pg_indexes WHERE tablename = 'orders';

-- MySQL
SHOW INDEXES FROM orders;

4. Размер таблиц

Иногда таблица просто ОГРОМНАЯ. Тогда:

  • Партиционирование по дате
  • Отдельный data warehouse (ClickHouse)
  • Материализованные view

Правила оптимизации

1. Сначала замерь

Не оптимизируйте «на глаз». EXPLAIN + замер времени.

2. Индекс на WHERE и JOIN

Каждая колонка в условиях должна быть проиндексирована.

3. Избегать SELECT *

-- медленно, если много колонок
SELECT * FROM orders WHERE user_id = 42;

-- только нужные колонки
SELECT order_id, total, created_at FROM orders WHERE user_id = 42;

Особенно важно для columnar-СУБД (ClickHouse, BigQuery).

4. Covering index

Если часто запрашиваете 3 колонки — создайте индекс, включающий все:

-- Postgres
CREATE INDEX idx ON orders(user_id) INCLUDE (total, created_at);

Не придётся обращаться к основной таблице → быстрее.

5. Materialized views

Если агрегат считаете часто — предрасчитать:

CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS rev
FROM orders
GROUP BY 1;

-- обновление
REFRESH MATERIALIZED VIEW monthly_revenue;

Связанные темы

FAQ

Первое, что проверить при медленном запросе?

EXPLAIN ANALYZE. Смотрите Seq Scan на больших таблицах и отсутствующие индексы.

Всегда ли индекс ускоряет?

Нет. Для маленьких таблиц или при выборке >20% строк seq scan может быть быстрее.

Как узнать, что индекс не используется?

В плане: если видно Seq Scan, а на колонке есть индекс — значит, не использовался.

Можно ли ускорить JOIN на миллионе строк?

Индекс на JOIN-колонке. Если не помогает — партиционирование, predicate pushdown.


Тренируйте SQL-оптимизацию — откройте тренажёр с 1500+ вопросами для собесов.