diff --git a/data/sql/186_objective_lots_history_offer_trend_covering_idx.sql b/data/sql/186_objective_lots_history_offer_trend_covering_idx.sql new file mode 100644 index 00000000..34a85fc7 --- /dev/null +++ b/data/sql/186_objective_lots_history_offer_trend_covering_idx.sql @@ -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 накладных) ≈ 59–64 B/запись → оценка ~120–150 MB. Для +-- сравнения _lot_idx (2 ключа, без INCLUDE, все 2.24M) = 90 MB — оценка +-- согласована. BUILD такого индекса на ~1.9M строк — ориентировочно 20–60 с +-- (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): миграция создаёт ~120–150 MB индекс под +-- кратковременным SHARE-lock на history (~20–60 с 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;