perf(sql): покрывающий индекс под offer-trend §4.3 — 6.8с → 1.1с (миграция 186) #2331
1 changed files with 123 additions and 0 deletions
123
data/sql/186_objective_lots_history_offer_trend_covering_idx.sql
Normal file
123
data/sql/186_objective_lots_history_offer_trend_covering_idx.sql
Normal 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 накладных) ≈ 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;
|
||||
Loading…
Add table
Reference in a new issue