SQLLab
Все статьи

Оптимизация SQL-запросов: практическое руководство

Как ускорить медленные SQL-запросы: индексы, переписывание запросов, EXPLAIN ANALYZE, типичные антипаттерны. Реальные примеры и советы для PostgreSQL.

16 марта 2026 г.·5 мин чтения·

Медленный SQL-запрос — одна из самых частых проблем в продакшене. В этой статье — практические приёмы оптимизации с реальными примерами. Без теории ради теории.

Шаг 0: сначала измерь, потом оптимизируй

Никогда не оптимизируй вслепую. Сначала найди узкое место.

-- EXPLAIN ANALYZE показывает реальный план и время
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.created_at >= '2026-01-01'
GROUP BY u.name;

Смотри на:

  • Seq Scan на большой таблице — тревожный знак, нужен индекс
  • cost=X..Y — оценка стоимости (чем меньше, тем лучше)
  • actual time=X..Y — реальное время выполнения
  • rows=N vs actual rows=N — если сильно расходятся, нужен ANALYZE

Антипаттерн 1: SELECT * вместо конкретных колонок

-- Плохо: тащим все колонки, в том числе тяжёлые BLOB/TEXT
SELECT * FROM users WHERE id = 42;

-- Хорошо: только нужное
SELECT id, name, email FROM users WHERE id = 42;

Особенно критично при JOIN — SELECT * из нескольких таблиц передаёт огромный объём данных.


Антипаттерн 2: функция над индексированной колонкой

Индекс не используется, если колонка обёрнута в функцию.

-- Плохо: индекс на email не работает
SELECT * FROM users WHERE LOWER(email) = 'ivan@mail.ru';

-- Хорошо: функциональный индекс
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'ivan@mail.ru';

-- Или: хранить email уже в нижнем регистре (ещё лучше)
-- Плохо: индекс на created_at не работает
SELECT * FROM orders WHERE DATE(created_at) = '2026-01-15';

-- Хорошо: диапазон напрямую
SELECT * FROM orders
WHERE created_at >= '2026-01-15'
  AND created_at < '2026-01-16';

Антипаттерн 3: NOT IN с подзапросом

-- Плохо: медленно + баг если в подзапросе есть NULL
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM banned_users);

-- Хорошо: NOT EXISTS быстрее и безопаснее
SELECT * FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM banned_users b WHERE b.user_id = u.id
);

-- Или LEFT JOIN IS NULL
SELECT u.*
FROM users u
LEFT JOIN banned_users b ON b.user_id = u.id
WHERE b.user_id IS NULL;

Антипаттерн 4: LIKE с % в начале

-- Плохо: индекс не используется (поиск суффикса)
SELECT * FROM products WHERE name LIKE '%phone%';

-- Хорошо: полнотекстовый поиск
CREATE INDEX idx_products_fts ON products USING GIN(to_tsvector('russian', name));
SELECT * FROM products
WHERE to_tsvector('russian', name) @@ to_tsquery('russian', 'телефон');

-- Или: LIKE с % только в конце — индекс работает
SELECT * FROM products WHERE name LIKE 'iPhone%';

Антипаттерн 5: OR между разными колонками

-- Плохо: OR может не использовать индексы
SELECT * FROM orders
WHERE user_id = 42 OR status = 'pending';

-- Хорошо: UNION ALL (если записи не пересекаются)
SELECT * FROM orders WHERE user_id = 42
UNION ALL
SELECT * FROM orders WHERE status = 'pending' AND user_id != 42;

Правильные индексы

Составной индекс: порядок имеет значение

-- Запрос: WHERE user_id = ? AND status = ? ORDER BY created_at
-- Правильный порядок: сначала точные условия, потом диапазоны
CREATE INDEX idx_orders_user_status_date
  ON orders(user_id, status, created_at DESC);

-- Неправильно — индекс почти не помогает:
CREATE INDEX bad_idx ON orders(created_at, user_id, status);

Покрывающий индекс (Index Only Scan)

-- Запрос обращается только к этим колонкам
SELECT user_id, SUM(amount)
FROM orders
WHERE user_id = 42 AND status = 'paid'
GROUP BY user_id;

-- Покрывающий индекс — база вообще не читает таблицу
CREATE INDEX idx_covering
  ON orders(user_id, status)
  INCLUDE (amount);  -- PostgreSQL 11+

Частичный индекс

-- Индекс только для «активных» заказов (99% запросов идут туда)
CREATE INDEX idx_orders_active
  ON orders(user_id, created_at)
  WHERE status != 'cancelled';

-- Меньше размер, быстрее обновление, лучше кэширование

Переписывание медленных запросов

Коррелированный подзапрос → JOIN

-- Плохо: выполняется для каждой строки
SELECT name,
  (SELECT SUM(amount) FROM orders WHERE user_id = u.id) AS total
FROM users u;

-- Хорошо: один проход по orders
SELECT u.name, COALESCE(o.total, 0) AS total
FROM users u
LEFT JOIN (
  SELECT user_id, SUM(amount) AS total
  FROM orders
  GROUP BY user_id
) o ON o.user_id = u.id;

COUNT с условием вместо нескольких запросов

-- Плохо: три отдельных запроса к одной таблице
SELECT COUNT(*) FROM orders WHERE status = 'paid';
SELECT COUNT(*) FROM orders WHERE status = 'pending';
SELECT COUNT(*) FROM orders WHERE status = 'cancelled';

-- Хорошо: один запрос
SELECT
  COUNT(*) FILTER (WHERE status = 'paid')      AS paid,
  COUNT(*) FILTER (WHERE status = 'pending')   AS pending,
  COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled
FROM orders;

Обслуживание: VACUUM и ANALYZE

-- Обновить статистику — планировщик будет выбирать лучший план
ANALYZE orders;

-- Посмотреть «раздутость» таблицы
SELECT
  relname,
  n_live_tup,
  n_dead_tup,
  ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

-- Найти медленные запросы (нужен pg_stat_statements)
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

Чек-лист перед деплоем

  • Нет SELECT * в горячих запросах
  • Нет функций над индексированными колонками в WHERE
  • Составные индексы соответствуют порядку условий
  • EXPLAIN ANALYZE показывает Index Scan, не Seq Scan на больших таблицах
  • NOT IN заменён на NOT EXISTS или LEFT JOIN IS NULL
  • Нет N+1 запросов (коррелированных подзапросов в SELECT)

Хочешь попрактиковаться? В нашем тренажёре есть задачи на оптимизацию и инструмент EXPLAIN — смотри план запроса прямо в браузере.

Похожие статьи

Попробуй на практике

Тренажёр с реальными задачами — бесплатно и без регистрации

Открыть тренажёр →