Раздел 42 · Аналитика данных с нуля

EXPLAIN глазами аналитика: почему запрос идёт 10 минут

middle~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Бран остался, и наклейка «Монетка» на доске впервые висит рядом с остальными как настоящий клиент, а не как тендер. Первый рабочий вопрос Фреи приходит на следующее утро.

Сервер побольше это ответ «база медленная», и «Монетка» заплатит за него, не узнав, правда ли это. Почти всегда виноват запрос. База делает ровно то, что ты написал, только иногда ты написал «две тысячи раз перечитай всю таблицу». Сегодня учимся спрашивать у базы, что она собирается делать, и узнавать такие места до того, как запрос съест обед. Разработчику ту же тему рассказывает урок EXPLAIN и EXPLAIN ANALYZE; здесь угол аналитика: как найти и переписать медленный отчёт без помощи администратора базы.

Маршрут до поездки

Перед поездкой навигатор строит маршрут и пишет: 40 минут, две пробки, платная дорога. Ты ещё не выехал, а уже видишь, где застрянешь. План запроса это такой же маршрут. Команда EXPLAIN перед запросом показывает план и не выполняет сам запрос.

EXPLAIN
SELECT count(*)
FROM transactions
WHERE created_at >= '2026-05-20' AND created_at < '2026-05-21';
Aggregate  (cost=2085.33..2085.34 rows=1 width=8)
  ->  Seq Scan on transactions  (cost=0.00..2084.15 rows=469 width=0)
        Filter: ((created_at >= '2026-05-20 00:00:00+00'::timestamp with time zone) AND ...

Читается снизу вверх, от самого глубокого отступа. Seq Scan on transactions значит: прочитать таблицу целиком, строку за строкой, и к каждой применить Filter. Потом Aggregate сворачивает отобранное в одно число.

  • Seq Scan: прочитать все 71 277 операций, чтобы найти несколько сотен за один день;
  • cost=0.00..2084.15: оценка стоимости в условных единицах, первое число до первой строки, второе до последней. Сравнивать стоит только стоимости разных планов друг с другом;
  • rows=469: сколько строк база ожидает. Это прогноз по статистике, а не факт.

Если нужен факт, пиши EXPLAIN ANALYZE: запрос выполнится, и рядом с прогнозом появятся фактические время и число строк. На этом же фильтре через created_at::date = '2026-05-20' база ждала 356 строк, а нашла 565, и Rows Removed by Filter: 70712 показывает, сколько строк прочитано зря.

Почему стажёрский запрос идёт полторы минуты

Вот запрос стажёра:

SELECT c.id,
       (SELECT max(t.created_at)
        FROM transactions t
        JOIN cards k ON k.id = t.card_id
        WHERE k.client_id = c.id) AS last_tx
FROM clients c;

Подзапрос в SELECT ссылается на c.id из внешнего запроса. Такой коррелированный подзапрос мы встречали в уроке про подзапросы. План показывает, чем он платит:

Seq Scan on clients c  (cost=0.00..3929015.25 rows=2000 width=12)
  SubPlan 1
    ->  Aggregate
          ->  Hash Join
                ->  Seq Scan on transactions t  (cost=0.00..1727.77 rows=71277 ...)

SubPlan под каждой из 2000 строк клиентов. Внутри него Seq Scan on transactions: на каждого клиента база заново читает все 71 277 операций. Это 142 миллиона прочитанных строк ради 2000 дат. Стоимость 3 929 015, и в нашем прогоне EXPLAIN ANALYZE показал 97 секунд. Песочница остановит такой запрос по таймеру, поэтому в ней смотри только план, без ANALYZE.

Тот же ответ одним проходом:

SELECT k.client_id, max(t.created_at) AS last_tx
FROM transactions t
JOIN cards k ON k.id = t.card_id
GROUP BY k.client_id;
HashAggregate  (cost=2346.55..2366.55 rows=2000 width=12)
  ->  Hash Join  (cost=74.98..1990.17 rows=71277 width=12)
        ->  Seq Scan on transactions t  (cost=0.00..1727.77 rows=71277 ...)
        ->  Hash
              ->  Seq Scan on cards k

Таблица операций читается один раз, карты складываются в хеш-таблицу, группировка идёт за тот же проход. Стоимость 2367 вместо почти четырёх миллионов, время 0,14 секунды вместо 97. Секунды зависят от машины и её загрузки, на твоём ноутбуке они будут другими; стоимость плана от машины не зависит, поэтому планы сравнивай по cost. Ответ одинаковый: у всех 2000 клиентов есть операции, и обе версии вернули по 2000 строк с теми же датами.

Что искать в плане

Аналитику не нужно знать все узлы плана. Хватает трёх вопросов.

Есть ли повторное полное чтение большой таблицы? Ищи Seq Scan внутри SubPlan или вложенного цикла и проверяй loops в EXPLAIN ANALYZE. Тысячи полных проходов могут загрузить процессор и чтение данных. Но само по себе loops больше единицы не ошибка: короткий индексный поиск для каждого клиента может быть дешевле одного полного прохода. Сравни, что читается на каждой итерации.

Есть ли Seq Scan по большой таблице ради малой доли строк? Тогда помогает индекс, о нём следующий урок. Поиск по первичному ключу уже идёт по индексу: WHERE id = 500 даёт Index Scan using transactions_pkey со стоимостью 8,31.

Сильно ли расходится rows с фактом? Если база ждала 1 строку, а пришло 50 000, она могла выбрать неудачный способ соединения. Это повод проверить фильтры и типы.

Хорошая новость: NOT EXISTS выглядит как коррелированный подзапрос, но PostgreSQL часто превращает его в анти-соединение. Запрос «клиенты без одобренных операций в июне» в нашем плане становится Hash Anti Join за один проход по операциям, а не две тысячи. Проверяй это по плану своего запроса, а не по наличию слова EXISTS.

урок из курса

Урок из курса «Аналитика данных с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.