From 5888b0b9d338dd57285ab2ad58be1a2ce2598990 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 00:49:08 +0000 Subject: [PATCH] =?UTF-8?q?perf(db):=20physflat-covering=20=D0=B8=D0=BD?= =?UTF-8?q?=D0=B4=D0=B5=D0=BA=D1=81=20v2=20=D1=81=20price=5Fper=5Fm2=5Frub?= =?UTF-8?q?=20=D0=B2=20INCLUDE=20(#2177=20follow-up)=20(#2243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ve_lots_physflat_covering_v2_price_idx.sql | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 data/sql/183_objective_lots_physflat_covering_v2_price_idx.sql diff --git a/data/sql/183_objective_lots_physflat_covering_v2_price_idx.sql b/data/sql/183_objective_lots_physflat_covering_v2_price_idx.sql new file mode 100644 index 00000000..07c540f0 --- /dev/null +++ b/data/sql/183_objective_lots_physflat_covering_v2_price_idx.sql @@ -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;