MAATRIX / Блог / Почему база тормозит сразу после загрузки данных: статистика, которой ещё нет

Почему база тормозит сразу после загрузки данных: статистика, которой ещё нет

MAATRIX

Вы только что залили в таблицу миллионы строк — импорт каталога, миграция из старой системы, батч из внешнего API — и первые же запросы к этой таблице внезапно тормозят так, будто база забыла, как работать с индексами. Диск не перегружен, CPU не в потолке, индексы на месте. Дело почти наверняка в статистике: планировщик запросов ещё несколько минут (а иногда и часов) продолжает думать, что таблица маленькая, потому что никто не сказал ему, что она выросла.

Откуда планировщик берёт данные для оценки

Планировщик PostgreSQL не смотрит на таблицу «вживую» перед каждым запросом — это было бы слишком дорого. Вместо этого он опирается на снэпшот статистики, который лежит в системном каталоге pg_statistic (и в удобной для чтения форме — в представлении pg_stats). Там хранится:

  • примерное число строк в таблице (pg_class.reltuples) и число занятых страниц (relpages);
  • список наиболее часто встречающихся значений колонки (MCV — most common values) с их частотой;
  • гистограмма распределения остальных значений;
  • оценка доли NULL;
  • корреляция физического порядка строк на диске с порядком значений колонки — от неё зависит, насколько дёшево читать данные по индексу последовательно.

На основе этих чисел планировщик считает *стоимость* разных вариантов выполнения запроса: пройти по индексу или сделать полное сканирование таблицы, сделать hash join или nested loop, в каком порядке соединять таблицы. Он не проверяет, сколько строк реально подойдёт под условие WHERE status = 'processing' — он смотрит в гистограмму и по ней оценивает долю подходящих строк, а дальше умножает эту долю на reltuples.

Если reltuples и гистограмма отражают таблицу такой, какой она была вчера, а не такой, какая она сейчас, — все оценки стоимости оказываются посчитаны для другой таблицы. План при этом не «ошибается» в смысле логики: он абсолютно корректно оптимален для той статистики, которая ему дана. Проблема не в алгоритме планировщика, а в данных, на которых он работает.

Как и когда статистика обновляется на самом деле

Статистику не пересчитывает каждая вставка и не пересчитывает планировщик перед запросом. Её собирает отдельная команда ANALYZE, которая делает выборочный (не полный) проход по таблице и заново строит гистограммы и MCV-списки. Вручную её почти никто не запускает на постоянной основе — этим занимается автовакуум, а точнее его часть, которая называется auto-analyze.

Автовакуум не следит за временем — он следит за счётчиком изменений. У каждой таблицы в pg_stat_user_tables копится счётчик n_mod_since_analyze — сколько строк было вставлено, изменено или удалено с момента последнего ANALYZE. Как только этот счётчик переваливает порог, автовакуум ставит таблицу в очередь на пересбор статистики. Порог считается по формуле:

порог = autovacuum_analyze_threshold + autovacuum_analyze_scale_factor * reltuples

По умолчанию autovacuum_analyze_threshold = 50, а autovacuum_analyze_scale_factor = 0.1 — то есть автовакуум пересчитает статистику, когда изменится примерно 10% строк таблицы плюс полсотни строк сверху. Для таблицы, в которой было 10 000 строк, это около 1050 изменений — довольно быстро. Для таблицы, в которой уже 50 миллионов строк, это 5 миллионов изменений — и вот тут начинается ловушка, о которой ниже.

Важная деталь: порог считается от старого reltuples, а не от нового размера таблицы. Пока статистика не обновилась, база судит о том, «пора ли обновлять статистику», по представлению о размере таблицы, которое само устарело.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Сценарий: массовая загрузка и статистика, которая думает, что таблица маленькая

Вот типичная последовательность, из-за которой база «тормозит сразу после загрузки»:

  1. Таблица events существовала и содержала, скажем, несколько тысяч строк. Статистика на неё собрана, актуальна, планировщик строит по ней разумные планы.
  2. Запускается массовая загрузка — COPY events FROM ..., или пакет INSERT, или pg_restore, или ETL-джоб, который заливает несколько миллионов строк за одну транзакцию или за короткую серию транзакций.
  3. Загрузка завершается за минуты. Реальное распределение данных резко меняется: строк стало на порядки больше, диапазон дат вырос, появились новые значения в колонках, по которым раньше было всего пара уникальных значений.
  4. Приложение сразу начинает читать из этой таблицы — строит отчёты, фильтрует по датам, джойнит с другими таблицами.
  5. Планировщик берёт для этих запросов старую статистику: он всё ещё думает, что в таблице несколько тысяч строк. Он выбирает nested loop join, который прекрасно работал для маленькой таблицы, но на новых объёмах превращается в миллионы повторных обращений к внутренней таблице. Или наоборot — там, где раньше было выгодно пройти по индексу и вернуть горстку строк, теперь под условие подходят миллионы строк, а планировщик всё ещё уверен, что их немного, и продолжает настаивать на индексном доступе вместо последовательного сканирования.
  6. Автовакуум формально «видит» рост n_mod_since_analyze и рано или поздно поставит таблицу в очередь — но это может занять минуты или больше, особенно если автовакуум занят другими таблицами или в системе одновременно идёт вакуум по большой таблице, который блокирует воркеры.

Ключевой момент этого сценария — не то, что статистика в принципе устарела (это бывает и без bulk-загрузки), а то, что она устарела *резко и сразу после операции, которая максимально изменила профиль данных*. Разрыв между «то, что думает планировщик» и «то, что есть на самом деле» здесь не постепенный, а скачкообразный — и именно поэтому эффект так заметен: вчера всё летало, сегодня после загрузки всё тормозит, хотя код запросов не менялся.

Похожая история, но с другой причиной — статистика, которая просто месяцами не обновлялась без всякой массовой загрузки, — разобрана в статье про индекс, который был, но планировщик его не брал. Механизм тот же самый — расхождение оценки и реальности, — но триггер и скорость деградации разные.

Как отличить эту причину от других: смотрим в EXPLAIN

Самый надёжный способ подтвердить, что дело именно в статистике — сравнить оценку планировщика с реальностью через EXPLAIN (ANALYZE, BUFFERS):

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM events
WHERE created_at >= now() - interval '1 day';

В выводе у каждого узла плана есть пара чисел: rows=N в самой строке плана (оценка) и rows=M в блоке actual (то, что реально вернулось). Если оценка и факт различаются в разы или на порядки — это прямой признак того, что статистика не отражает текущее состояние таблицы:

Seq Scan on events  (cost=0.00..1523.00 rows=4200 width=120)
                     (actual time=0.02..842.11 rows=1180340 loops=1)

Здесь планировщик ожидал 4200 строк, а получил больше миллиона — разница на три порядка. Такое расхождение почти всегда означает, что план выбран не под тот объём данных. Стоит также проверить pg_stat_user_tables:

SELECT relname, n_live_tup, n_mod_since_analyze, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'events';

Если last_analyze и last_autoanalyze показывают время до массовой загрузки, а n_mod_since_analyze — огромное число, картина подтверждается: статистика не пересчитывалась с момента, когда таблица была совсем другой.

Это отличает ситуацию от других частых причин медленных запросов после изменений в базе — устаревшего кеша, распухшего индекса из-за bloat, конкуренции за блокировки. У расхождения оценки и факта в EXPLAIN есть довольно узкий круг объяснений, и устаревшая статистика — самое частое из них именно в первые минуты и часы после массовой загрузки.

Почему автообновление не успевает вовремя

Есть несколько причин, по которым автовакуум не подхватывает изменения сразу же после загрузки, а не потому что он «сломан»:

  • Порог считается в процентах от старого размера. Пока автовакуум не пересчитал reltuples, порог срабатывания статистики продолжает считаться от прежнего, меньшего числа строк — но это работает и в вашу пользу тоже: чем меньше была таблица до загрузки, тем раньше сработает порог после неё.
  • Автовакуум может быть занят. Число параллельных воркеров ограничено параметром autovacuum_max_workers (по умолчанию 3). Если в этот момент воркеры заняты вакуумом других, особенно больших, таблиц, ваша таблица просто стоит в очереди.
  • COPY и массовые INSERT сами по себе не запускают ANALYZE. Это отдельная, независимая операция. Даже pg_restore не гарантирует свежую статистику сразу после восстановления дампа — обычно рекомендуется прогнать ANALYZE по базе вручную после восстановления.
  • Если таблица создана только что и загружена в той же транзакции, до коммита автовакуум её вообще не видит — он не может анализировать данные, которые ещё не зафиксированы.
  • На таблицах, где преобладают вставки без обновлений и удалений, до PostgreSQL 13 автовакуум по вставкам не запускался вовсе — только по изменениям и удалениям. Начиная с PostgreSQL 13 появился отдельный порог autovacuum_vacuum_insert_scale_factor, но это про vacuum, а не про analyze напрямую; тем не менее стоит проверить версию сервера и то, включён ли автоанализ для insert-heavy таблиц в вашей конфигурации.

Ни одна из этих причин не означает, что автовакуум настроен неправильно по умолчанию — стандартные настройки рассчитаны на постепенный, равномерный поток изменений, а не на скачок в миллионы строк за одну операцию. Массовая загрузка — это ситуация, которую стандартные пороги не покрывают быстро по конструкции.

Что делать: явный ANALYZE сразу после массовой загрузки

Практический вывод простой: не полагаться на автовакуум сразу после операций, которые резко меняют объём или профиль данных, а обновлять статистику явно, как последний шаг загрузки:

BEGIN;
COPY events FROM '/data/events.csv' WITH (FORMAT csv);
ANALYZE events;
COMMIT;

Если загрузка идёт через приложение или ETL-инструмент пакетами, ANALYZE можно и нужно вызывать по завершении всего джоба, а не после каждого пакета — сама команда не бесплатна, хоть и не сканирует таблицу целиком:

ANALYZE VERBOSE events;

VERBOSE покажет, сколько страниц было просмотрено и сколько строк оценено — полезно для контроля, что команда действительно отработала, а не зависла в очереди блокировок.

Несколько практических нюансов:

  • ANALYZE берёт выборку строк, а не читает таблицу целиком — по умолчанию размер выборки определяется параметром default_statistics_target (по умолчанию 100). Для колонок с сильно неравномерным или «длиннохвостым» распределением значений имеет смысл поднять точность конкретно для этих колонок: ALTER TABLE events ALTER COLUMN status SET STATISTICS 300;, после чего снова выполнить ANALYZE events;.
  • Если загрузка идёт в новую пустую таблицу, которую вы создали только что (например, при пересоздании отчётной таблицы), обязательно проанализируйте её сразу после заполнения — у совсем новой таблицы reltuples изначально может быть равен нулю или не отражать реальность, пока не пройдёт первый ANALYZE.
  • Для партиционированных таблиц статистика собирается по каждой партиции отдельно, и после загрузки в конкретную партицию нужно анализировать именно её (ANALYZE events_2026_08;), а не только родительскую таблицу.
  • ANALYZE не блокирует чтения и обычные записи — он берёт лёгкую блокировку ShareUpdateExclusiveLock, которая конфликтует только с DDL и вакуумом, так что запускать его сразу после загрузки, не дожидаясь «тихого окна», в большинстве случаев безопасно.
  • Если загрузка регулярная (например, ночной батч), проще один раз добавить ANALYZE в конец скрипта загрузки, чем каждый раз вручную вспоминать об этом после инцидента.

Если после этого узкое место оказалось не в статистике, а в самой конфигурации PostgreSQL — размере shared_buffers, work_mem, параметрах вакуума — стоит отдельно пройтись по тюнингу PostgreSQL на сервере, а если проблема оказалась не в скорости обновления статистики, а в самом вакууме, который не поспевает за темпом изменений, — в разборе медленного VACUUM в PostgreSQL. Массовая загрузка данных часто нагружает базу сразу с нескольких сторон — и статистика лишь одна из них, хоть и самая заметная в первые минуты после импорта.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Обязательно ли запускать ANALYZE вручную, или можно просто подождать автовакуум?

Можно подождать, если задержка в несколько минут или часов после загрузки не критична для ваших запросов. Но если сразу после импорта данные должны отдаваться быстро — отчёты, публичный API, дашборд — явный ANALYZE в конце загрузки надёжнее и предсказуемее, чем ожидание срабатывания порога автовакуума.

ANALYZE и VACUUM ANALYZE — это одно и то же?

Нет. VACUUM освобождает место, занятое мёртвыми версиями строк, и обновляет карту видимости; ANALYZE пересчитывает статистику для планировщика. Их можно запускать вместе командой VACUUM ANALYZE table_name;, но после чистой загрузки новых данных без удалений и обновлений обычно достаточно одного ANALYZE — мёртвых строк там взяться неоткуда.

Занимает ли ANALYZE много времени на большой таблице?

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

Можно ли автоматически анализировать таблицу сразу после каждого COPY, не редактируя код загрузки?

Прямого хука на событие COPY в PostgreSQL нет, но можно обернуть загрузку в скрипт или функцию, которая вызывает ANALYZE последним шагом, либо временно снизить autovacuum_analyze_scale_factor для конкретной таблицы перед известной массовой загрузкой: ALTER TABLE events SET (autovacuum_analyze_scale_factor = 0.01);.

Если я использую read-реплики, статистику нужно обновлять и там?

Статистика реплицируется вместе с остальными данными через физическую репликацию (WAL), поэтому отдельный ANALYZE на репликах не нужен и невозможен — реплика в режиме restore не принимает запись. Если же используется логическая репликация в другую схему или базу, статистика там независимая, и ANALYZE на целевой стороне нужен отдельно.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →