perf(db): physflat-covering индекс v2 с price_per_m2_rub в INCLUDE (#2177 follow-up) (#2243)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 29s
Deploy / build-worker (push) Successful in 29s
Deploy / deploy (push) Successful in 1m26s

This commit is contained in:
bot-backend 2026-07-03 00:49:08 +00:00
parent 4b6d1cbc06
commit 5888b0b9d3

View file

@ -0,0 +1,104 @@
-- Контекст: follow-up ревью #2240 — best-layouts supply-only запрос уходит в
-- heap scan + external-merge Sort на тяжёлых проектах, потому что покрывающий
-- индекс 176-й (objective_lots_physflat_covering_idx) НЕ несёт price_per_m2_rub.
--
-- best_layouts._SUPPLY_ONLY_LOTS_SQL дедуплицирует objective_lots до последнего
-- снапшота на каждый ФИЗИЧЕСКИЙ лот через
-- SELECT DISTINCT ON (project_name, corpus_name, section, floor, lot_number) …
-- ORDER BY …, snapshot_date DESC, id DESC
-- по квартирам (premise_kind='квартира'), а ПОСЛЕ DISTINCT ON проецирует
-- (best_layouts.py:381-386): project_name (ключ), rooms_int, area_pd,
-- price_per_m2_rub, is_sold, status. Все они, КРОМЕ price_per_m2_rub, уже лежат
-- в INCLUDE-нагрузке 176-го covering-индекса. price_per_m2_rub там сознательно
-- НЕ клали (см. коммент 176: «его проецируют только analyze-inline запросы,
-- которые не фильтруют premise_kind='квартира'») — но supply-only запрос
-- #2240 как раз фильтрует premise_kind='квартира' И проецирует price_per_m2_rub,
-- поэтому одной недостающей колонки хватает, чтобы Index-Only стал невозможен:
-- планировщик добирает price_per_m2_rub из кучи → Bitmap/Heap fetch + внешняя
-- сортировка проекции.
--
-- ЗАМЕР (prod EXPLAIN ANALYZE, два тяжелейших проекта, 2026-07-02):
-- ДО (176-й covering без price_per_m2_rub):
-- heap scan + external-merge Sort (~7 MB temp на диск), Execution ~500 ms.
-- ПОСЛЕ (covering + price_per_m2_rub в INCLUDE, создан в BEGIN…ROLLBACK для
-- проверки плана):
-- Index Only Scan using objective_lots_physflat_covering_v2_idx + Unique,
-- внешней сортировки НЕТ, Execution ~54 ms (≈9×).
--
-- Пересоздаём покрывающий индекс, добавляя price_per_m2_rub в INCLUDE. Ключ
-- (physflat + snapshot_date DESC, id DESC) и partial-WHERE (premise_kind=
-- 'квартира') НЕ меняются → тот же DISTINCT ON обслуживает ВСЕХ прежних
-- потребителей 176-го (market_metrics._STOCK_SQL / _SALES_WINDOW_SQL,
-- competitors._SOLD_COUNT_SQL inline, #1959 inline) без регрессии — расширенная
-- нагрузка их только дополняет, ключ и порядок идентичны. Дополнительно теперь
-- Index-Only обслуживается и best_layouts supply-only.
--
-- ── ИМЯ / ПОРЯДОК ────────────────────────────────────────────────────────────
-- Новое имя objective_lots_physflat_covering_v2_idx + DROP старого
-- objective_lots_physflat_covering_idx (тем же приёмом, что 176: CREATE-new →
-- DROP-old). Нельзя `CREATE INDEX IF NOT EXISTS` под ТЕМ ЖЕ именем: старый индекс
-- уже существует под этим именем → IF NOT EXISTS стал бы no-op и молча пропустил
-- бы пересборку (расширение INCLUDE не применилось бы). v2-суффикс + DROP старого:
-- • идемпотентно при ре-apply (IF NOT EXISTS / IF EXISTS);
-- • ни одного окна без индекса под DISTINCT ON — новый уже на месте, когда
-- дропаем старый (оба кратковременно сосуществуют).
-- Имя индекса нигде в backend-коде не хардкодится (планировщик выбирает индекс
-- по форме запроса, не по имени) — переименование прозрачно для приложения.
--
-- ── LOCK-ПРОФИЛЬ / CONCURRENTLY ──────────────────────────────────────────────
-- Обычный (НЕ CONCURRENTLY) CREATE INDEX берёт SHARE-lock на objective_lots —
-- блокирует writes на таблицу на время сборки (~252 MB + одна numeric-колонка на
-- ~1.9M строк). CONCURRENTLY здесь НЕВОЗМОЖЕН: deploy.yml гоняет каждый .sql
-- целиком через `psql < file`, а этот файл (как 176/168/173) само-оборачивается
-- в BEGIN/COMMIT; CREATE INDEX CONCURRENTLY внутри транзакции PostgreSQL
-- запрещает. Тот же класс риска и то же решение, что 176 и 168 (см. их
-- lock-комменты). Поэтому: обычный CREATE INDEX в BEGIN/COMMIT.
-- ⚠ Деплой ВНЕ недельного окна objective-scrape (bulk INSERT/UPDATE на
-- objective_lots): миграция создаёт ~252 MB индекс на ~1.9M строк под
-- кратковременным SHARE-lock на objective_lots, затем дропает старый.
--
-- #5-эхо: ANALYZE objective_lots в конце tx — освежает оценки под новый индекс
-- (тот же приём, что 176; выполняется в транзакции штатно).
--
-- Idempotent (IF NOT EXISTS / IF EXISTS), self-wrapped BEGIN/COMMIT.
-- Порядок применения для prod: миграция применяется деплоем в NN-порядке
-- (трекер _schema_migrations). Backend-код НЕ требует изменений — индекс
-- прозрачен для существующего _SUPPLY_ONLY_LOTS_SQL.
BEGIN;
CREATE INDEX IF NOT EXISTS objective_lots_physflat_covering_v2_idx
ON objective_lots (
project_name,
corpus_name,
section,
floor,
lot_number,
snapshot_date DESC,
id DESC
)
INCLUDE (
rooms_int,
area_pd,
sales_start_date,
is_sold,
contract_date,
status,
class,
price_per_m2_rub
)
WHERE premise_kind = 'квартира';
COMMENT ON INDEX objective_lots_physflat_covering_v2_idx IS
'Покрывающий physflat-индекс v2 (#2240): 176-й covering + price_per_m2_rub в '
'INCLUDE. Обслуживает Index-Only Scan + Unique для DISTINCT ON (physflat) '
'ORDER BY snapshot_date DESC, id DESC по квартирам у ВСЕХ прежних потребителей '
'176-го (market_metrics._STOCK/_SALES, competitors, #1959) И для '
'best_layouts._SUPPLY_ONLY_LOTS_SQL, который проецирует price_per_m2_rub. Без '
'этой колонки supply-only уходил в heap scan + external-merge Sort '
'(~7 MB temp, ~500 ms); с ней — Index Only Scan (~54 ms). См. 176-й.';
DROP INDEX IF EXISTS objective_lots_physflat_covering_idx;
ANALYZE objective_lots;
COMMIT;