perf(sql): покрывающий индекс под offer-trend §4.3 — 6.8с → 1.1с (миграция 186) #2331

Merged
bot-backend merged 2 commits from feat/offer-trend-covering-index into main 2026-07-03 22:26:30 +00:00
Showing only changes of commit 5238ec9977 - Show all commits

View file

@ -0,0 +1,123 @@
-- Контекст: перф-фикс offer-trend §4.3 (профилирование 2026-07-04). Покрывающий
-- индекс под join+фильтр запроса market_metrics._OFFER_TREND_SQL (CTE `hist`),
-- который считает медиану цены предложения по снапшотам Объектива в радиусе.
--
-- ПРОБЛЕМА (ground-truth, prod EXPLAIN ANALYZE 2026-07-04, центр ЕКБ 66:41:0702048):
-- `hist` джойнит objective_lots_history h на 141 936 radius-лотов (плотный центр) —
-- Nested Loop с ВНУТРЕННИМ `Index Scan using objective_lots_history_lot_idx`, actual
-- loops=141936. Существующий _lot_idx = (objective_lot_id, snapshot_date DESC) БЕЗ
-- INCLUDE: он находит строки по objective_lot_id, но фильтр
-- (is_sold IS NOT TRUE) AND price_per_m2_rub BETWEEN 10000 AND 1000000
-- и проекция (snapshot_date, price_per_m2_rub) НЕ покрыты индексом → на КАЖДУЮ
-- совпавшую индекс-запись планировщик добирает строку из кучи (heap fetch).
-- Итог по этому одному узлу: Buffers shared hit=579133 read=10666 (~589 799
-- буфер-доступов ≈ 4.6 ГБ тронуто), Execution ~6.8 s тёплый / >8 s холодный —
-- пробивает 8-секундный guard ~2 раза в сутки на плотных центрах.
--
-- РЕШЕНИЕ: покрывающий индекс, у которого КЛЮЧ даёт и join-ключ, и порядок
-- DISTINCT ON запроса, а INCLUDE несёт фильтр-колонки и проекцию — тогда каждый
-- probe становится Index-Only Scan без heap fetch.
--
-- Ключ (objective_lot_id, snapshot_date, fetched_at DESC):
-- • objective_lot_id — Index Cond джойна (rl.objective_lot_id = h.objective_lot_id);
-- • snapshot_date, fetched_at DESC — ТОЧНО порядок `hist`
-- ORDER BY h.objective_lot_id, h.snapshot_date, h.fetched_at DESC
-- под DISTINCT ON (objective_lot_id, snapshot_date) (дедуп задвоенного дня —
-- берём последний свип). Индекс отдаёт строки уже в этом порядке per-lot →
-- Unique/Incremental-Sort обслуживается из индекса без досортировки кучи.
-- INCLUDE (price_per_m2_rub, is_sold, status):
-- • price_per_m2_rub — и фильтр (BETWEEN), и единственная проецируемая метрика;
-- • is_sold — фильтр (is_sold IS NOT TRUE) — сильнейший по селективности;
-- • status — фильтр LOWER(COALESCE(status,'')) <> 'продан'. На проде он сейчас
-- ничего не отсекает (2 245 510/2 245 510 проходят — «продан»-состояние несёт
-- is_sold, не строка status), но колонка нужна в INCLUDE, чтобы предикат
-- вычислялся из индекса, а не из кучи; иначе Index-Only невозможен.
--
-- PARTIAL-WHERE price_per_m2_rub BETWEEN 10000 AND 1000000 — зеркало литеральных
-- _OFFER_PRICE_MIN / _OFFER_PRICE_MAX запроса (market_metrics.py). Режет индекс с
-- 2 245 510 до 1 856 499 строк (≈83%, prod-замер) и заодно выкидывает NULL-цены
-- (16.9% null_frac — они и так не проходят BETWEEN). Никакой ДРУГОЙ потребитель
-- history по этому диапазону не фильтрует:
-- • триггер-писатель objective_lots_write_history (166) — SELECT … WHERE
-- objective_lot_id=? ORDER BY snapshot_date DESC LIMIT 1, БЕЗ price-фильтра →
-- остаётся на _lot_idx (18.4M scans, hot);
-- • MV mv_sales_tracker_* (164) — DISTINCT ON (lot, month) без price-фильтра →
-- тоже _lot_idx.
-- Поэтому partial-предикат безопасен: он не должен обслуживать эти пути, а
-- _lot_idx НЕ трогаем (оба индекса сосуществуют, каждый под своим запросом).
-- is_sold в partial-WHERE НЕ выносим (оставляем в INCLUDE): partial по
-- `is_sold IS NOT TRUE` был бы селективнее (39% строк), но такой индекс перестал
-- бы годиться для будущих is-sold-agnostic чтений, а выигрыш поверх price-partial
-- мал — Index-Only и так снимает heap-походы.
--
-- ОЖИДАЕМЫЙ ЭФФЕКТ (гипотеза, проверяется post-deploy — индекс на проде ещё не
-- создан): внутренний `Index Scan using objective_lots_history_lot_idx` + heap
-- fetch → `Index Only Scan using objective_lots_history_offer_trend_idx`,
-- Heap Fetches ≈ 0, Buffers на history-узле падают с ~590k до ~десятков k,
-- Execution с ~6.8 s до субсекунды на этом же центре. Post-deploy verify —
-- команда в ответе воркера (EXPLAIN ANALYZE того же `hist`).
--
-- ── РАЗМЕР / ВРЕМЯ BUILD ─────────────────────────────────────────────────────
-- Таблица objective_lots_history: 2 245 510 строк, 538 MB total (282 MB heap,
-- 255 MB indexes), prod-замер 2026-07-04. Партиал-индекс: ~1 856 499 записей ×
-- (key 20B: lot_id 8 + date 4 + fetched_at 8; INCLUDE 27B: price 11 + is_sold 1
-- + status 15; + ~12B накладных) ≈ 5964 B/запись → оценка ~120150 MB. Для
-- сравнения _lot_idx (2 ключа, без INCLUDE, все 2.24M) = 90 MB — оценка
-- согласована. BUILD такого индекса на ~1.9M строк — ориентировочно 2060 с
-- (heap 282 MB, одиночный сорт), НЕ гигантский, но у верхней границы 30-с эвристики.
--
-- ── LOCK-ПРОФИЛЬ / CONCURRENTLY ──────────────────────────────────────────────
-- Обычный (НЕ CONCURRENTLY) CREATE INDEX берёт SHARE-lock на
-- objective_lots_history — блокирует writes (INSERT снапшотов триггером +
-- недельный objective-scrape) на время сборки. CONCURRENTLY здесь НЕВОЗМОЖЕН:
-- deploy.yml прогоняет каждый .sql целиком через `psql -v ON_ERROR_STOP=on <
-- file` в ОДНОЙ транзакции (файл само-оборачивается BEGIN/COMMIT), а
-- `CREATE INDEX CONCURRENTLY` внутри транзакции PostgreSQL запрещает. Тот же
-- класс/решение, что 176 / 183 / 168 (см. их lock-комменты).
-- ⚠ Деплой ВНЕ недельного окна objective-scrape (bulk INSERT/UPDATE лотов → каскад
-- триггер-INSERT в history): миграция создаёт ~120150 MB индекс под
-- кратковременным SHARE-lock на history (~2060 с writes-block).
-- ⚙ ЕСЛИ build упрётся в >30 с и writes-block нежелателен — применить руками ДО
-- мержа CONCURRENTLY (вне транзакции, без блокировки writes):
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS objective_lots_history_offer_trend_idx
-- ON objective_lots_history (objective_lot_id, snapshot_date, fetched_at DESC)
-- INCLUDE (price_per_m2_rub, is_sold, status)
-- WHERE price_per_m2_rub BETWEEN 10000 AND 1000000;
-- INSERT INTO _schema_migrations (filename)
-- VALUES ('186_objective_lots_history_offer_trend_covering_idx.sql')
-- ON CONFLICT DO NOTHING;
-- Тогда эта миграция при деплое станет no-op (IF NOT EXISTS + уже в трекере) —
-- файл остаётся идемпотентной фиксацией схемы. Иначе (окно тихое) — деплой
-- сам создаёт индекс штатным CREATE INDEX ниже.
--
-- Idempotent: CREATE INDEX IF NOT EXISTS. Self-wrapped BEGIN/COMMIT.
-- Deploy order: чистая СХЕМА, без зависимости от backend-кода (индекс прозрачен —
-- планировщик выбирает его по форме запроса, не по имени; market_metrics.py НЕ
-- меняется). Безопасно катить в любой момент (вне scrape-окна).
BEGIN;
CREATE INDEX IF NOT EXISTS objective_lots_history_offer_trend_idx
ON objective_lots_history (
objective_lot_id,
snapshot_date,
fetched_at DESC
)
INCLUDE (
price_per_m2_rub,
is_sold,
status
)
WHERE price_per_m2_rub BETWEEN 10000 AND 1000000;
COMMENT ON INDEX objective_lots_history_offer_trend_idx IS
'Покрывающий индекс под market_metrics._OFFER_TREND_SQL (CTE hist, §4.3). Ключ '
'(objective_lot_id, snapshot_date, fetched_at DESC) = join-ключ + порядок '
'DISTINCT ON запроса; INCLUDE (price_per_m2_rub, is_sold, status) покрывает '
'фильтр и проекцию → Index-Only Scan вместо Nested-Loop heap-fetch на 141k+ '
'лотов (было ~590k буфер-доступов / ~6.8 s тёплый). Partial-WHERE '
'price BETWEEN 10000 AND 1000000 = литералы _OFFER_PRICE_MIN/MAX (~83% строк). '
'НЕ обслуживает триггер-писатель (166) и MV (164) — те на _lot_idx.';
ANALYZE objective_lots_history;
COMMIT;