Медленный 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=Nvsactual 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 — смотри план запроса прямо в браузере.