В прошлом уроке мы смотрели на готовую статистику. А кто её строит? Команда ANALYZE. И главный вопрос — как она это делает на таблице в 500 миллионов строк, не сжигая 20 минут IO на каждый запуск.
Ответ: ANALYZE не сканирует всю таблицу. Он берёт случайный
ANALYZE почти не зависит от размера таблицы. Но даёт и неприятный — на гигантских таблицах с длинным хвостом редких значений сэмпл их не увидит.
Размер сэмпла
Формула простая: sample_size = 300 × statistics_target. При default_statistics_target = 100 это 30 000 строк независимо от того, 100K строк в таблице или 100M. На таблице в 10K — ANALYZE прочитает всё. На таблице в 100M — он прочитает 0.03% строк, и по этим 30K оценит и MCV, и гистограмму, и корреляцию.
300 — это не случайное число: статистический анализ показал, что для оценки MCV/histogram с приемлемой точностью нужно ~300 строк на баскет. Если поднимаешь statistics_target до 1000 — ANALYZE будет читать 300 000 строк, и времени потратит втрое больше.
При том же statistics_target число читаемых строк постоянно, но процент от таблицы меняется драматически.
Как ANALYZE выбирает строки
Алгоритм называется two-stage reservoir sampling (по работе Vitter):
- Стадия 1: выбирается случайный сэмпл страниц. Не строк! Если в таблице 10000 страниц, ANALYZE берёт
sample_sizeслучайных страниц и читает их целиком. - Стадия 2: внутри прочитанных страниц reservoir-алгоритмом выбирается
sample_sizeкортежей. Reservoir держит окно фиксированной длины: каждая новая строка с вероятностьюsample_size/seen_so_farзаменяет случайную существующую.
Почему такой двухуровневый алгоритм? Потому что чтение случайных строк через row-by-row было бы 30000 random I/O. А чтение случайных страниц даёт ~30000 строк дешевле — мы платим за чтение страницы один раз и забираем все её ~100 кортежей.
Побочный эффект: если у тебя на одной странице все одинаковые значения (например, разлили по партициям), то reservoir-сэмпл «недополучает» вариативности. Это известная проблема ANALYZE для partitioned tables с heavy cluster.
Когда сэмпла недостаточно
Есть несколько сценариев, где ANALYZE врёт:
- Очень редкие, но запрашиваемые значения. Допустим, в таблице 100M строк есть 50 строк со статусом
'critical'. Сэмпл в 30K строк увидит их с вероятностью1 − (1 − 50/100M)^30000 ≈ 1.5%. Почти никогда. В итогеWHERE status = 'critical'оценивается как «нет такого значения» или «1 строка» — а реально их 50, и план через индекс был бы выгоден. - Сильно skewed распределения с длинным хвостом. Например, лог событий, где 99.9% —
'click', 0.1% — другие 200 типов. MCV-список из 100 значений захватит топ-100 типов после'click', но их частоты будут оценены по сэмплу, а не по реальности. Ошибка ~10-20%. - Только что заинсёрченные данные.
ANALYZEпока не успел. Текущая статистика говорит «таких значений нет» → план будетSeq Scanили ошибочныйIndex Scanс плохим селективатером.
Решения:
- Поднимать
statistics_targetдля конкретной колонки (ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 1000). - Вручную дёргать
ANALYZEпосле массивных загрузок (COPYне вызывает autovacuum). - Для редких значений — добавить partial index на «горячее» подмножество, тогда индекс будет рекомендован независимо от селективности.
Autovacuum: кто дёргает ANALYZE сам
ANALYZE руками никто не запускает в проде. Postgres делает это сам через
- Postgres считает в
pg_stat_user_tables.n_mod_since_analyze— сколько INSERT/UPDATE/DELETE прошло с последнегоANALYZE. - Когда эта цифра превышает
autovacuum_analyze_threshold + autovacuum_analyze_scale_factor × reltuples— таблица помечается дляANALYZE. - По умолчанию:
threshold = 50,scale_factor = 0.1→ ANALYZE запустится после ~10% изменений в таблице.
Для большой таблицы это редко (10% от 100M = 10M изменений между ANALYZE — это часы или дни). Поэтому для горячих таблиц настраивают:
ALTER TABLE orders SET (
autovacuum_analyze_scale_factor = 0.02,
autovacuum_analyze_threshold = 1000
);
Это даст ANALYZE после 2% изменений, что для критичных таблиц обычно правильный баланс.
Смотрим в практике
Информация о последнем ANALYZE и текущей оценке reltuples. Датасет инициализируется ~5 секунд.
n_mod_since_analyze — это «бюджет» до следующего autoanalyze. last_analyze — руками, last_autoanalyze — autovacuum’ом.
Внесём 5000 INSERT'ов и посмотрим, как изменился n_mod_since_analyze. До этого ANALYZE был выполнен в seed.
Видно, что n_mod_since_analyze поднялся на 5000. Когда он превысит 0.1 × n_live_tup, autovacuum пометит таблицу для ANALYZE.
Запустим ANALYZE вручную и проверим, что счётчик обнулился.
n_mod_since_analyze = 0, last_analyze обновился. Если бы это был autovacuum — обновился бы last_autoanalyze.
Sample size vs accuracy: считаем руками
Пусть колонка имеет распределение с пиком (значение X — 0.1% строк). Сэмпл в 30K увидит X ожидаемо 30 раз. Это даёт оценку частоты с относительной ошибкой ~20% (стандартная для биномиального распределения: σ = √(np), у нас np = 30, σ ≈ 5.5, относительная — 18%).
Если поднять statistics_target = 1000 → sample = 300K, увидим 300 случаев, относительная ошибка σ/np ≈ √300/300 = 5.7%. Точнее, но ANALYZE будет читать в 10 раз больше страниц.
Правило большого пальца: не трогай statistics_target, пока не докажешь через EXPLAIN ANALYZE, что планировщик ошибается из-за сэмпла.
Чек-лист
ANALYZEстроит статистику по случайному сэмплу, не по полной таблице.- Размер сэмпла:
300 × statistics_targetстрок (= 30000 по умолчанию). Не зависит от размера таблицы. - Алгоритм: two-stage reservoir sampling — сначала случайные страницы, потом reservoir по строкам.
- Редкие значения (
< 1/sample_size = 0.003%) почти никогда не попадают в MCV → ошибки оценки. autovacuum_analyze_scale_factor = 0.1по умолчанию (= ANALYZE раз в 10% изменений). Для горячих таблиц снижают до 0.02 или меньше.- Поднимать
statistics_targetстоит только после доказательства, что планировщик ошибается. Это удорожаетANALYZEлинейно.