β
Войти Регистрация

PostgreSQL: индексы и EXPLAIN — как ускорить медленный запрос

Коротко о статье

Страница «Мои заказы» открывалась мгновенно, пока заказов было десять тысяч. Через год их два миллиона, и та же страница грузится полсекунды. Код не менялся. Изменились данные, и база начала читать всю таблицу ради двадцати строк.

Разбираем, как в такой ситуации действовать по шагам: прочитать план запроса через EXPLAIN, понять, где база тратит время, подобрать индекс и убедиться, что он помог. Примеры на PostgreSQL, условная таблица заказов на 2 млн строк.

Как читать EXPLAIN

Перед выполнением запроса планировщик PostgreSQL перебирает варианты: читать таблицу целиком или по индексу, в каком порядке соединять таблицы. Для каждого он оценивает стоимость по статистике и выбирает самый дешёвый. Команда EXPLAIN показывает выбранный план, а EXPLAIN ANALYZE ещё и выполняет запрос и добавляет фактические цифры.

  • cost=0.43..80.12. Оценка стоимости в условных единицах: сколько до первой строки и сколько до последней. Это не миллисекунды, сравнивать её имеет смысл только между планами одного запроса.
  • rows. Сколько строк ожидает планировщик. Рядом в ANALYZE видно, сколько получилось на самом деле. Если они расходятся в десятки раз, статистика врёт, и план, скорее всего, неудачный.
  • actual time. Фактическое время в миллисекундах на один проход узла. Умножайте на loops, если узел выполнялся несколько раз.
  • Порядок чтения. План читается изнутри наружу: самые вложенные узлы выполняются первыми.

Пример: до и после индекса

Запрос страницы «Мои заказы»: двадцать последних заказов клиента. Добавим BUFFERS, чтобы видеть, сколько страниц данных прочитано.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total, created_at
FROM orders
WHERE customer_id = 4821
ORDER BY created_at DESC
LIMIT 20;

Без подходящего индекса план выглядит так:

Limit  (cost=48251.12..48251.17 rows=20 width=24) (actual time=412.533..412.538 rows=20 loops=1)
  ->  Sort  (cost=48251.12..48251.37 rows=98 width=24) (actual time=412.531..412.534 rows=20 loops=1)
        Sort Key: created_at DESC
        Sort Method: top-N heapsort  Memory: 26kB
        ->  Seq Scan on orders  (cost=0.00..48248.50 rows=98 width=24) (actual time=0.412..412.401 rows=103 loops=1)
              Filter: (customer_id = 4821)
              Rows Removed by Filter: 1999897
              Buffers: shared hit=1204 read=22041
Execution Time: 412.571 ms

Главная строка здесь Rows Removed by Filter: 1999897. Чтобы найти 103 заказа одного клиента, база прочитала все два миллиона строк и выбросила почти все. Индекс по клиенту и дате решает обе проблемы сразу, и поиск, и сортировку:

CREATE INDEX CONCURRENTLY orders_customer_created_idx
    ON orders (customer_id, created_at DESC);
Limit  (cost=0.43..80.12 rows=20 width=24) (actual time=0.031..0.074 rows=20 loops=1)
  ->  Index Scan using orders_customer_created_idx on orders  (cost=0.43..391.05 rows=98 width=24) (actual time=0.030..0.070 rows=20 loops=1)
        Index Cond: (customer_id = 4821)
        Buffers: shared hit=23
Execution Time: 0.093 ms

Сортировка исчезла: строки в индексе уже лежат в нужном порядке, и база останавливается на двадцатой. Вместо 23 тысяч страниц данных прочитано 23. Цифры в примере условные, но соотношение типичное.

Узлы плана, которые встречаются чаще всего

Основные узлы плана PostgreSQL
Узел Что делает Когда это нормально
Seq Scan Читает таблицу целиком Маленькая таблица или нужна большая часть строк
Index Scan Находит строки по индексу и читает их из таблицы Условие выбирает малую долю строк
Index Only Scan Берёт данные прямо из индекса, не заходя в таблицу Все нужные колонки есть в индексе
Bitmap Heap Scan Собирает адреса строк по индексу, потом читает таблицу по порядку Строк много, но не большинство
Nested Loop Для каждой строки слева ищет пары справа Слева мало строк, справа есть индекс
Hash Join Строит хеш-таблицу по одной стороне и проходит по другой Соединение больших наборов без подходящего индекса

Seq Scan сам по себе не ошибка. Тревожный признак другой: большая таблица, маленький результат и огромное число в Rows Removed by Filter.

Почему индекс есть, а PostgreSQL его не использует

  1. Функция над колонкой. Индекс по email не поможет условию lower(email) = …. Нужен индекс по выражению: CREATE INDEX ON users (lower(email));
  2. LIKE с процентом в начале. LIKE '%petrov' обычному B-tree не по силам. Для такого поиска есть расширение pg_trgm и индексы GIN.
  3. Не тот порядок колонок. Индекс по (customer_id, created_at) почти бесполезен для условия только по created_at.
  4. Условие выбирает слишком много. Если под фильтр попадает половина таблицы, прочитать её подряд дешевле, чем прыгать по индексу. Планировщик прав.
  5. Устаревшая статистика. После массовой загрузки данных запустите ANALYZE orders; Автоматическая сборка статистики может ещё не успеть.
  6. Несовпадение типов. Сравнение колонки bigint со строкой или неявное приведение типов может помешать использовать индекс.

Самопроверка: три вопроса про индексы

Выберите ответ, сразу увидите разбор. Ничего не отправляется и не сохраняется.

1/3 Есть индекс по (status, created_at). Какому запросу он поможет лучше всего?
Ответ и разбор

Первому. Равенство по первой колонке и сортировка по второй ровно соответствуют порядку в индексе. Для фильтра только по второй колонке индекс почти бесполезен: строки в нём сгруппированы по статусу.

2/3 В плане оценка rows=10, а фактически rows=150000. Что проверить первым?
Ответ и разбор

Статистику. Огромное расхождение оценки и факта значит, что планировщик выбирает план вслепую. Свежая статистика часто исправляет план без новых индексов. Если не помогло, смотрите на корреляцию колонок и расширенную статистику.

3/3 Как добавить индекс в нагруженную таблицу, не остановив запись?
Ответ и разбор

CONCURRENTLY. Обычный CREATE INDEX блокирует запись на всё время построения, даже ночью. CONCURRENTLY строится дольше, но таблица остаётся доступной. Внутри транзакции эта команда не работает.

Это разминка. В экспресс-квизе вопросы подбираются под ваш стек.

Составные, частичные и покрывающие индексы

  • Составной. Сначала колонки, которые сравниваются на равенство, потом колонка для диапазона или сортировки. Так устроен индекс из примера выше.
  • Частичный. Индексирует только нужные строки, например WHERE status = 'active'. Он меньше и быстрее обновляется, если запросы почти всегда смотрят на активные записи.
  • Покрывающий. С PostgreSQL 11 в индекс можно добавить колонки через INCLUDE. Тогда запрос получит все данные из индекса без чтения таблицы, и в плане появится Index Only Scan.

У каждого индекса есть цена. Он обновляется при каждой вставке и изменении, занимает место и место в кэше. Неиспользуемые индексы видно в pg_stat_user_indexes: у них idx_scan остаётся нулевым неделями.

Порядок разбора медленного запроса

Порядок разбора медленного запроса
Шаг Что делать
Найти Отсортировать запросы в pg_stat_statements по суммарному времени. Частый запрос на 50 мс важнее редкого на 2 секунды
Воспроизвести Запустить EXPLAIN (ANALYZE, BUFFERS) с реальными параметрами на копии данных похожего объёма
Найти узкое место Узел с наибольшим временем, большие Rows Removed by Filter, расхождение оценки и факта
Исправить Индекс, переписанное условие, свежая статистика или меньше лишних колонок в выборке
Измерить Сравнить план и время до и после, проверить, не замедлилась ли запись

Вопросы и ответы

Чем EXPLAIN отличается от EXPLAIN ANALYZE?

EXPLAIN показывает план и оценки планировщика, не выполняя запрос. EXPLAIN ANALYZE выполняет запрос по-настоящему и добавляет фактическое время и число строк. Поэтому UPDATE или DELETE с ANALYZE запускайте внутри транзакции и откатывайте её.

Почему PostgreSQL не использует индекс?

Частые причины: на колонку в условии наложена функция, поиск по LIKE с процентом в начале, неподходящий порядок колонок в составном индексе, устаревшая статистика или условие выбирает большую часть таблицы, и последовательное чтение просто дешевле.

В каком порядке ставить колонки в составном индексе?

Сначала колонки, которые сравниваются на равенство, потом колонка для диапазона или сортировки. Индекс по (customer_id, created_at) поможет запросу с условием по customer_id и сортировкой по created_at, но почти не поможет запросу только по created_at.

Можно ли создать индекс на работающей таблице без простоя?

Да, командой CREATE INDEX CONCURRENTLY. Она строит индекс дольше, но не блокирует запись в таблицу. Выполнять её нужно вне транзакции и после проверить, что индекс не остался в состоянии INVALID.

Чем плохо много индексов?

Каждый индекс обновляется при каждой вставке и изменении строки, поэтому запись замедляется, а таблица занимает больше места на диске. Неиспользуемые индексы видно в pg_stat_user_indexes по нулевому idx_scan за длительный период.

Как найти самые медленные запросы в PostgreSQL?

Подключить расширение pg_stat_statements и отсортировать запросы по суммарному времени выполнения. Оптимизировать стоит не самый медленный запрос, а тот, что съедает больше всего времени в сумме.

Как данные ходят между сервисами, читайте в статье про Kafka.

Проверьте знания и закрепите практикой

Пройдите квиз по своему стеку, соберите пет-проект с базой данных и потренируйте рассказ о нём на пробном собеседовании. Регистрация бесплатная.

Начать Смотреть вакансии

Что почитать дальше

Источники

Проверено 7 октября 2026 года. Таблица и цифры в примере условные, составлены редакцией IT Career Gym.

← Вернуться к списку статей

Рассылка

Подписываясь, вы соглашаетесь с политикой обработки персональных данных.