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 лежит на nvme0n1p4 — Micron 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_products | 0.6 ГБ | 4.4 ГБ (7.4×) |
offer_characteristic_facts | 4.2 ГБ | 20 ГБ (4.8×) |
offer_characteristic_raw | 4.1 ГБ | 14 ГБ (3.4×) |
canonical_analog_assignment_lookup | 13 ГБ | 29 ГБ (таблица производная) |
supplier_offers | 6.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.
Две причины, устранять надо обе сразу:
fillfactor= 100 — в странице нет места под HOT.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.
Три ловушки, каждая из которых уже давала неверный вывод:
- Вложенные запросы нельзя складывать с внешними. При
pg_stat_statements.track = allбуферы триггера и вызванной функции учитываются и у внешнего вызова. Ранжировать физическую стоимость только поtoplevel = true, вложенные оставлять как объяснение плана. Проверка:SELECT queryid, bool_or(toplevel), bool_or(NOT toplevel) FROM pg_stat_statements GROUP BY 1. - Среднее нельзя вычитать.
mean_exec_timeне складывается; за окно оно считается какdelta(total_exec_time) / delta(calls), иначе получаются отрицательные значения. - Остаток «всё, что не statements» — не доказательство вины автовакуума. Долю считать по
pg_stat_io(PostgreSQL 16+), а не вычитанием.
Замер по pg_stat_io за 756 секунд:
| процесс | блоков чтения | доля полосы | время чтения | на блок |
|---|---|---|---|---|
| autovacuum worker (vacuum) | 1 801 958 | 53.3% | 521.4 с | 0.29 мс |
| client backend (normal) | 1 521 706 | 45.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_INTERVAL | api-server, App-VPS → scripts/render_production_env.sh | 1h |
Ловушка та же, что была с RETENTION_*: рычаг читает api-server, а он живёт на App-VPS, поэтому значение из deploy/prod-env-template.env (DB-VPS) до него не доходит. Порог короткого замыкания вычисляется как такт минус минута, отдельной переменной нет.
Что делать при следующем насыщении
- Убедиться, что дело в диске (iowait, проба создания контейнера).
- Посмотреть, кто грузит:
pg_stat_progress_vacuum, долгиеDO-блоки ремонтных скриптов,pg_dump. - Автовакуумы не снимать — их работа нужна, а отмена выбрасывает уже проделанное.
- Если нужна выкладка — пауза воркеров освобождает диск за минуты (нагрузка 46 → 16, грязные страницы 1.38 ГБ → 292 МБ). Приём вшит в
deploy-db-vps.sh, отключается черезDEPLOY_SHED_WORKERS=0. - Долгосрочно — сокращать базу и число индексов, а не поднимать таймауты.
Смежное: DB-VPS backup tier, Matching and analogs search.