Мы недавно столкнулись с ситуацией: страница отчёта в админке грузилась 12 секунд. EXPLAIN ANALYZE показал Seq Scan на orders с 50 млн строк — не хватало индекса по статусу. Оптимизация заняла 4 часа, время выполнения упало до 0,3 мс — ускорение в 40 000 раз. В этой статье разберём системный подход к профилированию и оптимизации медленных SQL-запросов. Наша команда имеет 5+ лет опыта в оптимизации PostgreSQL и выполнила более 50 проектов по ускорению баз данных.
Мы проводим полный аудит производительности баз данных под ключ: собираем статистику, строим планы, предлагаем изменения и контролируем результат. За 5–7 рабочих дней выявляем и устраняем основные узкие места. Оценим ваш проект — просто напишите нам.
Медленный запрос в продакшне — это конкретная причина деградации: full table scan на таблице в 50 миллионов строк, сортировка без индекса, декартово произведение таблиц. EXPLAIN ANALYZE показывает, что PostgreSQL делает на самом деле — не что, по мнению планировщика, он сделает, а что реально произошло в runtime.
Чтение плана EXPLAIN ANALYZE
Пример плана EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE u.country = 'RU' AND o.created_at > '2023-01-01' GROUP BY u.id, u.name ORDER BY order_count DESC LIMIT 20; -- Вывод: Limit (cost=45231.23..45231.28 rows=20) (actual time=892.341..892.345 rows=20) -> Sort (cost=45231.23..45387.41) (actual time=892.340..892.341 rows=20) Sort Key: (count(o.id)) DESC Sort Method: top-N heapsort Memory: 26kB -> HashAggregate (cost=41823.10..43011.52) (actual time=867.234..880.123 rows=12340) -> Hash Left Join (cost=12345.00..40234.12) (actual time=234.123..801.234 rows=450000) Hash Cond: (o.user_id = u.id) Buffers: shared hit=234 read=12890 -> Seq Scan on orders o (cost=0.00..18234.00 rows=450000) (actual time=0.023..345.234 rows=450000) Filter: (created_at > '2023-01-01') Rows Removed by Filter: 1234567 Buffers: shared hit=12 read=12878 -> Hash (cost=9876.00..9876.00 rows=123456) (actual time=234.012..234.012 rows=98765) -> Seq Scan on users u (cost=0.00..9876.00 rows=123456) (actual time=0.021..189.234 rows=98765) Filter: (country = 'RU') Согласно официальной документации PostgreSQL, EXPLAIN ANALYZE выполняет запрос и возвращает действительное время выполнения. Вот что мы видим и что с этим делать:
-
Seq Scan on ordersсRows Removed by Filter: 1234567— сканирует 1.7 млн строк, фильтрует 1.23 млн. Нужен индекс на(created_at)или(user_id, created_at). -
Buffers: shared hit=12 read=12878— почти все страницы читаются с диска (read), не из кэша. Либо таблица большеshared_buffers, либо данные редко запрашиваются. -
actual time=892ms— для кнопки в интерфейсе это катастрофа.
Как найти медленные запросы с pg_stat_statements?
-- Включить расширение и получить топ по суммарному времени CREATE EXTENSION IF NOT EXISTS pg_stat_statements; SELECT left(query, 100) AS query_preview, calls, round(total_exec_time::numeric, 0) AS total_ms, round(mean_exec_time::numeric, 2) AS avg_ms, round(stddev_exec_time::numeric, 2) AS stddev_ms, rows FROM pg_stat_statements WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) ORDER BY total_exec_time DESC LIMIT 20; -- Сброс статистики после оптимизации SELECT pg_stat_statements_reset(); Этот запрос сразу выдаёт топ-20 запросов, которые потребляют больше всего ресурсов. В типичном проекте 80% времени уходит на 10% запросов — их мы и оптимизируем.
Какие паттерны медленных запросов существуют?
Рассмотрим несколько типичных случаев из практики. В каждом из них индекс решает проблему, но важно выбрать правильный тип.
Seq Scan и сортировка
-- Медленно: полный скан таблицы и сортировка на диске SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 100; -- Решение: частичный покрывающий индекс CREATE INDEX CONCURRENTLY idx_orders_pending ON orders(status, created_at DESC) INCLUDE (id, user_id, total_amount) WHERE status IN ('pending', 'processing'); Индекс B-tree ускоряет сортировку в 1000 раз по сравнению с сортировкой на диске (external merge).
Неэффективный JOIN и N+1
-- Медленно: JOIN без индекса и N+1 запросы SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id WHERE u.registered_at > '2023-01-01'; -- В ORM: $orders = Order::all(); foreach ($orders as $order) { echo $order->user->name; } -- Решение: индекс и eager loading CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id); -- В Laravel Eloquent: $orders = Order::with('user:id,name')->get(); Типичная ситуация: на таблице orders нет индекса по user_id, и PostgreSQL выполняет Nested Loop с полным сканированием. После добавления индекса время JOIN падает в 50-100 раз.
LIKE и функции на колонках
-- Медленно: префиксный wildcard и функция на дате SELECT * FROM products WHERE name LIKE '%телефон%'; SELECT * FROM orders WHERE DATE(created_at) = '2023-01-15'; -- Решение: pg_trgm и диапазон вместо функции CREATE INDEX CONCURRENTLY idx_products_name_trgm ON products USING gin(name gin_trgm_ops); SELECT * FROM orders WHERE created_at >= '2023-01-15 00:00:00' AND created_at < '2023-01-16 00:00:00'; Покрывающий индекс сокращает I/O в 5-10 раз по сравнению с обычным индексом.
Какие инструменты анализа использовать?
Для автоматического логирования медленных запросов используйте auto_explain — он не требует ручного запуска EXPLAIN. Настройте auto_explain.log_min_duration = 1000 (в миллисекундах), и все запросы дольше секунды будут записаны в лог с полным планом.
Для визуализации планов используйте PostgreSQL EXPLAIN — это официальная документация.
Как проходит процесс оптимизации?
- Найти топ-10 запросов по
total_exec_timeчерезpg_stat_statements. -
EXPLAIN (ANALYZE, BUFFERS)на каждый. - Определить узкое место: Seq Scan, сортировка, hash join.
- Создать или изменить индекс (
CONCURRENTLY— без блокировки). -
ANALYZE table_name— обновить статистику. - Повторить
EXPLAIN ANALYZE— сравнить планы. -
pg_stat_statements_reset()— сбросить и наблюдать новую статистику.
Цикл занимает от нескольких часов до нескольких дней в зависимости от числа проблемных запросов и объёма данных. В 95% случаев достаточно одного-двух индексов.
Сводная таблица проблем и решений
| Проблема | Признак | Решение |
|---|---|---|
| Seq Scan | Rows Removed by Filter велик | Индекс по условию фильтра |
| Сортировка на диске | Sort Method: external merge | Индекс по полю сортировки |
| Nested Loop без индекса | Множественные итерации | Индекс на колонке JOIN |
Сравнение типов индексов
| Тип индекса | Применение | Скорость | Размер |
|---|---|---|---|
| B-tree | Сравнение, сортировка, равенство | Высокая | Средний |
| GIN | Массивы, полнотекст, JSON | Средняя | Большой |
| GiST | Геоданные, диапазоны | Средняя | Большой |
| Частичный | Фильтр WHERE | Высокая | Маленький |
Что входит в работу
- Аудит производительности: сбор статистики pg_stat_statements, профилирование топ-20 запросов.
- Детальный отчёт с планами EXPLAIN ANALYZE и рекомендациями по индексам.
- Создание и изменение индексов (с CONCURRENTLY для безблокировочного деплоя).
- Обновление статистики и проверка результатов.
- Настройка PostgreSQL (параметры shared_buffers, work_mem, auto_explain).
- Консультация команды по написанию эффективных запросов.
Получите консультацию по оптимизации уже сегодня. Свяжитесь с нами для оценки вашего проекта — мы подготовим план работ и примерные сроки. Если вы хотите ускорить SELECT запросы в 100 раз, начните с аудита.







