DB-VPS: насыщение диска — диагностика и рычаги

Раннбук по итогам разбора 2026-08-01/02, когда почти всё на проде — выкладки, присвоения характеристик, вакуумы — упиралось в одну причину.

Как понять, что дело в диске, а не в коде

ssh tracium-db "vmstat 3 2 | tail -1"          # смотреть колонку wa
ssh tracium-db "iostat -x nvme0n1 1 2 | tail -3"
ssh tracium-db "grep -w Dirty /proc/meminfo"
ssh tracium-db "ps -eo state,cmd | awk '\$1==\"D\"' | head"

Признаки насыщения, замеренные в инциденте: iowait 89% при user 7%, 43 процесса заблокированы, f_await 57 мс, глубина очереди 30, грязных страниц до 5.8 ГБ. Контейнеры воркеров при этом показывали около 0% CPU — они просто ждали базу.

Ключевая проверка для докера: docker ps, exec и inspect продолжают работать при насыщенном диске, а вот создание контейнера — нет. Проверять надо именно его:

ssh tracium-db "timeout 60 docker run --rm --network none <образ> /bin/sh -c 'exit 0'"

Если это висит, а docker ps отвечает — виноват не docker и не compose, а ввод-вывод.

Потолок железа

Postgres лежит на nvme0n1p4Micron 2210 MTFDHBA512QFD, 477 ГБ, потребительский QLC. Не enterprise. При заполнении под 80% и постоянной случайной записи деградирует резко. Наблюдаемая задержка чтения 8К под нагрузкой — около 30 мс, для NVMe это в десятки раз хуже нормы.

RAM 61 ГБ, shared_buffers 12 ГБ, 12 ядер. При базе 253 ГБ кэшировалось ~22% — то есть почти любой скан шёл на диск. Сокращение базы даёт больше, чем оптимизация запросов.

Почему вакуумы идут часами

Фаза vacuuming indexes читает все индексы таблицы целиком. Соотношение на 2026-08-02: 117 ГБ индексов против 112 ГБ данных.

таблицаданныеиндексы
canonical_products0.6 ГБ4.4 ГБ (7.4×)
offer_characteristic_facts4.2 ГБ20 ГБ (4.8×)
offer_characteristic_raw4.1 ГБ14 ГБ (3.4×)
canonical_analog_assignment_lookup13 ГБ29 ГБ (таблица производная)
supplier_offers6.9 ГБ10 ГБ (29 индексов)

Настройки вакуума уже агрессивные и запаса не дают: autovacuum_vacuum_cost_limit 2000 при дефолте 200, задержка 2 мс, maintenance_work_mem 2 ГБ (хватает на один проход по индексам).

HOT-обновлений ноль

supplier_offers: 10 806 375 обновлений, HOT — 12. offer_characteristic_facts: 3 из 2 782 721.

Две причины, устранять надо обе сразу:

  1. fillfactor = 100 — в странице нет места под HOT.
  2. last_seen_at бампится каждым обновлением при опросе и входит в девять индексов supplier_offers. HOT невозможен, если меняется хоть один индексируемый столбец.

Поэтому один только fillfactor эффекта не даст.

Индексы: чем доказывать избыточность

idx_scan = 0не доказательство. Два кандидата из двух проверку не прошли: индекс по выражению держал запасной путь матчера с дефолтом флага false, а overlap_idx реально использовался редким путём тира 2. Плюс счётчики могли обнулиться при падении postgres.

Годное доказательство — структурное: индекс, чьи колонки являются строгим префиксом другого. Проверять в откатываемой транзакции:

BEGIN;
DROP INDEX имя_подозреваемого;
EXPLAIN (costs off) <запрос, который его использовал>;
ROLLBACK;

Если планировщик уходит на составной и остаётся на Index Only Scan — удалять можно. Так сняты два индекса offer_observations (миграция 0278).

Освобождение места: что работает

DELETE не возвращает место операционной системе. Возвращают только DROP партиции и VACUUM FULL. Удалять построчно то, что и так уйдёт с дропом партиции, — вред: мёртвые кортежи, WAL, разбуженный автовакуум.

Дроп партиции требует ACCESS EXCLUSIVE на родителя. При непрерывных вставках окна нет, а DETACH CONCURRENTLY не работает при наличии default-партиции. Рабочий приём — короткий lock_timeout с повторами, чтобы не держать вставки в очереди:

for i in $(seq 1 15); do
  docker exec tracium-postgres-1 psql -U tracium -d tracium -v ON_ERROR_STOP=1 -q \
    -c "SET lock_timeout='8s'" -c "DROP TABLE <партиция>" && break
  sleep 6
done

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

pg_dump блокирует дроп. Держит AccessShareLock на копируемой партиции:

SELECT pid, now()-query_start, left(query,60)
  FROM pg_stat_activity WHERE application_name='pg_dump';

Отменять мягко — pg_cancel_backend(pid), не pg_terminate_backend.

Результат разовой уборки 2026-08-02 (дроп двух июльских партиций): диск 313 → 218 ГБ занято, база 253 → 159 ГБ, заполнение 80% → 56%, мгновенно.

Бюджеты запросов

POSTGRES_STATEMENT_TIMEOUT воркеров — 60 секунд, и это бюджет на один запрос, не на тик. Всё, что делается пачками, обязано в него укладываться. CANONICAL_ASSIGNMENT_UPSERT_BATCH = 1000 не укладывался и ронял тик целиком, выбрасывая уже посчитанное; 250 укладывается. Этот же размер задаёт пачки чтения существующих присвоений и загрузки наблюдений.

Профиль ввода-вывода: как посчитать, кто занимает диск

Разбор 2026-08-06. Суммарные счётчики для этого не годятся — они копятся неделями, поэтому профиль строится разницей двух снимков за окно 20–40 минут: scripts/pg_io_profile_report.py.

Три ловушки, каждая из которых уже давала неверный вывод:

  1. Вложенные запросы нельзя складывать с внешними. При pg_stat_statements.track = all буферы триггера и вызванной функции учитываются и у внешнего вызова. Ранжировать физическую стоимость только по toplevel = true, вложенные оставлять как объяснение плана. Проверка: SELECT queryid, bool_or(toplevel), bool_or(NOT toplevel) FROM pg_stat_statements GROUP BY 1.
  2. Среднее нельзя вычитать. mean_exec_time не складывается; за окно оно считается как delta(total_exec_time) / delta(calls), иначе получаются отрицательные значения.
  3. Остаток «всё, что не statements» — не доказательство вины автовакуума. Долю считать по pg_stat_io (PostgreSQL 16+), а не вычитанием.

Замер по pg_stat_io за 756 секунд:

процессблоков чтениядоля полосывремя чтенияна блок
autovacuum worker (vacuum)1 801 95853.3%521.4 с0.29 мс
client backend (normal)1 521 70645.0%3 351.9 с2.20 мс

Вывод, который меняет приоритеты: автовакуум первый по полосе, но клиентские запросы дают 85% времени диска, потому что читают вразброс по одному блоку. Полосу режут правки частоты обновлений и HOT, а ожидание в запросах — индексы и такты фоновых пересчётов.

Привязка запроса к процессу делается через application_name. Пул проставляет его сам именем бинаря (internal/platform/storage/postgres/pool.go); переопределяется через POSTGRES_APPLICATION_NAME или параметр в DSN. До этой правки все бэкенды приходили как (unknown) и связь приходилось искать текстом SQL по репозиторию.

Такт пересчёта снимка качества

supplier_quality_coverage_recompute() читает 427 434 блока (около 3.3 ГБ) за 181 секунду. На прежнем десятиминутном такте — порядка 475 ГБ чтения в сутки ради одной строки снимка.

Единственный потребитель — админский GET /api/v1/admin/quality/overview; в подборе, сопоставлении и ценообразовании снимок не участвует, поэтому такт переведён на час.

рычаггде живётдефолт
QUALITY_OVERVIEW_REFRESH_INTERVALapi-server, App-VPS → scripts/render_production_env.sh1h

Ловушка та же, что была с RETENTION_*: рычаг читает api-server, а он живёт на App-VPS, поэтому значение из deploy/prod-env-template.env (DB-VPS) до него не доходит. Порог короткого замыкания вычисляется как такт минус минута, отдельной переменной нет.

Что делать при следующем насыщении

  1. Убедиться, что дело в диске (iowait, проба создания контейнера).
  2. Посмотреть, кто грузит: pg_stat_progress_vacuum, долгие DO-блоки ремонтных скриптов, pg_dump.
  3. Автовакуумы не снимать — их работа нужна, а отмена выбрасывает уже проделанное.
  4. Если нужна выкладка — пауза воркеров освобождает диск за минуты (нагрузка 46 → 16, грязные страницы 1.38 ГБ → 292 МБ). Приём вшит в deploy-db-vps.sh, отключается через DEPLOY_SHED_WORKERS=0.
  5. Долгосрочно — сокращать базу и число индексов, а не поднимать таймауты.

Смежное: DB-VPS backup tier, Matching and analogs search.