From 904e945035028add3250c5093e46f01bea2a2eb8 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 14 Jun 2026 21:32:38 +0500 Subject: [PATCH] =?UTF-8?q?perf(sql):=20geography=20GIST=20+=20covering=20?= =?UTF-8?q?index=20=D0=B4=D0=BB=D1=8F=20=D0=B3=D0=BE=D1=80=D1=8F=D1=87?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=20analyze-=D0=BF=D1=83=D1=82=D0=B8=20SF=20(1?= =?UTF-8?q?57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EXPLAIN ANALYZE на проде выявил Seq Scan на каждый analyze из-за geom::geography предикатов (плоский geom-GIST не применим). 4 индекса: - osm_poi_ekb/osm_noise_sources_ekb/cad_buildings geography GIST: POI 36->0.2мс, noise 92->3.8мс (4x/analyze), соседи 314->0.3мс - objective_lots(project_name) INCLUDE(price,area,is_sold): competitor 493->355мс Lock SHARE (writes-only, читателей analyze не блокирует), build ~2.2с (как 153). Idempotent (IF NOT EXISTS). Refs #70 --- data/sql/157_analyze_hotpath_indexes.sql | 122 +++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 data/sql/157_analyze_hotpath_indexes.sql diff --git a/data/sql/157_analyze_hotpath_indexes.sql b/data/sql/157_analyze_hotpath_indexes.sql new file mode 100644 index 00000000..a2e5eb39 --- /dev/null +++ b/data/sql/157_analyze_hotpath_indexes.sql @@ -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; -- 2.45.3