perf(sql): geography GIST + covering index для analyze-пути Site Finder (157) #1334

Merged
bot-backend merged 1 commit from perf/analyze-hotpath-indexes into main 2026-06-14 16:45:23 +00:00

View file

@ -0,0 +1,122 @@
-- 157_analyze_hotpath_indexes.sql
-- Perf/index аудит горячего analyze-пути Site Finder (POST /parcels/{cad}/analyze).
-- Прецедент: migration 153 (#70) — partial covering index objective_lots(district,
-- price_per_m2_rub) → cold 5.3s→87ms Index Only Scan. Этот файл — аналогичные wins,
-- найденные EXPLAIN (ANALYZE, BUFFERS) на проде (2026-06-14, district='Академический',
-- centroid POINT(60.4869 56.7992), objective_lots ~1.12M / 2.1 ГБ, cad_buildings 38k / 88 МБ).
--
-- КОРНЕВАЯ ПРИЧИНА (3 из 4 индексов): запросы фильтруют по `X.geom::geography` для
-- точного метрового ST_DWithin (radius в метрах). Существующие GIST построены на ПЛОСКОМ
-- `geom` (geometry, SRID 4326) → планировщик НЕ может ими воспользоваться для
-- geography-предиката → Seq Scan всей таблицы на КАЖДЫЙ analyze. Функциональный GIST на
-- выражении `(geom::geography)` закрывает именно этот предикат (Index Cond через `&&`
-- + _st_expand). Плоские geom-GIST НЕ трогаем — они обслуживают ST_Intersects(geom,…)
-- (geometry) ветки (например overlap_rows в _neighbors_summary) и остаются нужны.
--
-- ИЗМЕРЕНИЯ (EXPLAIN ANALYZE до/после; «после» — warm 2-й прогон с индексом в BEGIN…ROLLBACK):
--
-- 1) osm_poi_ekb (POI в 1км, parcels.py:1488 «сек.3», 1× на analyze):
-- ДО: Seq Scan, Rows Removed by Filter 4824, 538 buf, ~36 ms
-- ПОСЛЕ: Index Scan на (geom::geography), 12 buf, ~0.2 ms (≈190x)
-- Индекс ~384 кБ, build 28 мс.
--
-- 2) osm_noise_sources_ekb (шум/гидрология/инж.сети, parcels.py:1773/1840/1889, 4× на analyze):
-- ДО: Parallel Seq Scan, Rows Removed 5122, 1701 buf, ~92 ms КАЖДЫЙ
-- ПОСЛЕ: Index Scan на (geom::geography), 176 buf, ~3.8 ms (≈24x)
-- Индекс ~816 кБ, build 55 мс. Кумулятивно ~4×88 ≈ 350 мс с одного analyze.
-- NB: geotech-запрос (parcels.py:779, source_type='industrial') уже идёт по
-- osm_noise_sources_ekb_source_idx (селективный source_type) — этот индекс его не
-- ухудшает (планировщик выберет дешевле из двух).
--
-- 3) cad_buildings (_neighbors_summary соседи в 100м, parcels.py:612, 1× на analyze):
-- ДО: Parallel Seq Scan, Rows Removed 12670, 11235 buf, ~314 ms
-- ПОСЛЕ: Index Scan на (geom::geography), 18 buf, ~0.3 ms (≈1000x)
-- Индекс ~2.9 МБ, build 338 мс. САМЫЙ крупный единичный выигрыш analyze-пути.
-- overlap_rows CTE (ST_Intersects(b.geom, wkt), geometry) по-прежнему обслуживается
-- существующим cad_buildings_geom_gist — без регрессии.
--
-- 4) objective_lots covering (obj_pricing CTE в competitor-блоке, parcels.py:1614, 1× на analyze):
-- Запрос агрегирует AVG(price)/AVG(area)/COUNT(is_sold) по ol.project_name =
-- cm.objective_complex_name (objective_complex_mapping, 286 строк).
-- ДО: Parallel Seq Scan ВСЕХ objective_lots, 215761 buf (~1.7 ГБ), Hash Join,
-- warm ~493 ms (cold ~681 ms).
-- ПОСЛЕ: Nested Loop + Index Only Scan на (project_name) INCLUDE(price,area,is_sold),
-- 286 проб по имени, 16586 buf, warm ~355 ms (≈1.4x, 13x buffers).
-- Индекс ~74 МБ, build 1.75 c. Существующий objective_lots_project_idx
-- (project_name, snapshot_date DESC) НЕ покрывает payload-колонки → не давал
-- Index Only Scan; этот — covering, поэтому не дубль.
-- ОГРАНИЧЕНИЕ: запрос всё равно агрегирует ВСЕ лоты ВСЕХ маппингов (не только
-- ~245 конкурентов в 3км). Полноценный фикс — переписать obj_pricing с
-- пушдауном domrf_obj_id IN (nearby) — это backend-engineer (правка SQL в
-- parcels.py), вне scope index-аудита. Индекс — выигрыш «здесь и сейчас» без
-- правки кода. Heap Fetches ~64k остаются (visibility map тёплый после VACUUM;
-- objective_lots dead-tup 1.25% — well-vacuumed).
--
-- ЧТО НЕ ДОБАВЛЕНО (доказанно не нужно):
-- • rosreestr_deals (market_trend, parcels.py:2211): warm ~254 ms, уже partition-pruned
-- (5 subplans removed) + per-partition BitmapAnd по 3 индексам. Композитный
-- (region_code,doc_type,realestate_type_code,period_start_date) пришлось бы строить на
-- ~20+ партициях, каждая ACCESS EXCLUSIVE на ПИШУЩЕЙСЯ таблице → высокий lock-риск,
-- несоразмерный ~2x-выигрышу. Партиционированную таблицу под индекс-аудит не трогаем.
-- • pipeline-блок (parcels.py:1663): warm ~37 мс, корр. подзапрос уже идёт по
-- objective_lots_project_idx (Bitmap Index Scan). Индекс не нужен.
-- • ekburg_construction_permits (LEFT(cadastral_number,…) prefix): уже покрыт
-- idx_ekburg_permits_cad_cpat (text_pattern_ops).
-- • geom-resolution UNION (parcels.py:1334): 15 мс, всё по PK index scan.
-- • district_price_block (parcels.py:2123): уже покрыт idx_objective_lots_district_price (153).
--
-- LOCK-ПРОФИЛЬ: обычный (НЕ CONCURRENTLY) CREATE INDEX берёт SHARE на таблицу — блокирует
-- ТОЛЬКО writes (loaders/objective_etl/NSPD on-demand fetch), читателей analyze НЕ
-- блокирует. CONCURRENTLY здесь невозможен: deploy.yml гоняет файл в транзакции
-- (BEGIN/COMMIT), а CREATE INDEX CONCURRENTLY в tx запрещён. Суммарный build ~2.2 c
-- (74 МБ objective_lots — доминирует, как и в 153 на той же таблице). Приемлемо в
-- deploy-окне (тот же класс риска, что 153/87/97). Не запускать одновременно с тяжёлым ETL.
--
-- Dependencies: osm_poi_ekb, osm_noise_sources_ekb, cad_buildings, objective_lots,
-- objective_complex_mapping (все существуют). PostgreSQL 16.4 + PostGIS 3.4.
-- Idempotent: CREATE INDEX IF NOT EXISTS (re-apply безопасен).
-- Applied: автоматически через deploy.yml в NN-порядке (трекер _schema_migrations).
-- Backend code change НЕ требуется — индексы прозрачны для существующих запросов.
BEGIN;
-- 1) POI в 1км (analyze сек.3) — geography-предикат ST_DWithin(geom::geography,…,1000).
CREATE INDEX IF NOT EXISTS idx_osm_poi_ekb_geom_geog
ON osm_poi_ekb USING gist ((geom::geography));
COMMENT ON INDEX idx_osm_poi_ekb_geom_geog IS
'Geography GIST для analyze POI-блока (#157): ST_DWithin(geom::geography,…,1000м). '
'Плоский osm_poi_ekb_geom_gist не обслуживает geography-cast → был Seq Scan ~36мс, '
'стал Index Scan ~0.2мс. См. parcels.py:1488.';
-- 2) Шумовые источники в 2км (analyze сек.7/9c/9d) — geography ST_DWithin, 4× на analyze.
CREATE INDEX IF NOT EXISTS idx_osm_noise_sources_ekb_geom_geog
ON osm_noise_sources_ekb USING gist ((geom::geography));
COMMENT ON INDEX idx_osm_noise_sources_ekb_geom_geog IS
'Geography GIST для analyze noise/hydro/utility-блоков (#157): '
'ST_DWithin(geom::geography,…,2000м), 4 запроса на analyze. Был Parallel Seq Scan '
'~92мс каждый, стал Index Scan ~3.8мс. См. parcels.py:1773/1840/1889.';
-- 3) Соседние здания в 100м (_neighbors_summary) — geography ST_DWithin на 88МБ-таблице.
CREATE INDEX IF NOT EXISTS idx_cad_buildings_geom_geog
ON cad_buildings USING gist ((geom::geography));
COMMENT ON INDEX idx_cad_buildings_geom_geog IS
'Geography GIST для analyze _neighbors_summary (#157): ST_DWithin(geom::geography,…,100м). '
'Плоский cad_buildings_geom_gist обслуживает ST_Intersects(geom,…) (overlap_rows), но не '
'geography-cast → был Parallel Seq Scan ~314мс, стал Index Scan ~0.3мс. См. parcels.py:612.';
-- 4) Covering-index для obj_pricing competitor-агрегации (analyze сек.5).
CREATE INDEX IF NOT EXISTS idx_objective_lots_pname_pricing
ON objective_lots (project_name)
INCLUDE (price_per_m2_rub, area_pd, is_sold);
COMMENT ON INDEX idx_objective_lots_pname_pricing IS
'Covering-index для analyze competitor obj_pricing (#157): агрегаты по '
'project_name = objective_complex_name через Index Only Scan (286 проб), без '
'Parallel Seq Scan ~1.7ГБ. Существующий objective_lots_project_idx без payload не '
'давал Index Only Scan. Long-term: пушдаун domrf_obj_id IN(nearby) в SQL (backend). '
'См. parcels.py:1614.';
COMMIT;