# Performance аудит: Sentry Tracing + EXPLAIN slow queries > Аудит по issue [#70](https://git.gendsgn.ru/lekss361/gendesign/issues/70) (B4-3, > ⚙️ Backend API quality). Дата: 2026-06-13. EXPLAIN-замеры — READ-ONLY на prod > (`gendesign-postgres-1`, PostgreSQL 16.4 + PostGIS 3.4), без DDL на проде. > > Источник правды по решениям — Obsidian vault. Здесь — версионированная копия рядом с кодом. ## TL;DR - **Tracing включён** (drift): GlitchTip `traces_sample_rate=0.05`, авто-инструментация FastApi/Sqlalchemy/Httpx/Celery (`app/main.py`, `app/workers/celery_app.py`). - **EXPLAIN top-5 hot-path** прогнан на проде с реальными параметрами (район `Академический`, region 66). 4 из 5 запросов — в пределах p95-таргетов. - **Найден 1 явный missing-index** (большая выгода): analyze «district price ranges» по `objective_lots` (~1.12М строк) на плотном районе — **cold 5286 ms / warm 499 ms**, Bitmap Heap Scan ~41k блоков. Исправлено миграцией **`data/sql/153_objective_lots_district_price_idx.sql`** (частичный covering-index → Index Only Scan). - **pgBouncer** пока **не требуется** (см. §5) — ops-decision при росте конкурентных соединений >50. ## 1. Sentry / GlitchTip Performance (статус) Инструментация активна в обоих процессах: ```python sentry_sdk.init( traces_sample_rate=settings.glitchtip_traces_sample_rate, # default 0.05 integrations=[StarletteIntegration(), FastApiIntegration(), CeleryIntegration(...), SqlalchemyIntegration(), HttpxIntegration(), LoggingIntegration(...)], ) ``` - `SqlalchemyIntegration` создаёт span на каждый SQL → в GlitchTip видно время по запросам внутри транзакции `/parcels/{id}/analyze` и др. - `before_send_transaction=scrub_sensitive_query` redact-ит секреты из span-URL (см. `docs/PII_152FZ_Audit.md` §3) — tracing не светит api-keys. - Замечание по sample-rate: 0.05 (5%) — экономно. Для прицельного сбора p95 на hot-path можно временно поднять (env `GLITCHTIP_TRACES_SAMPLE_RATE`) на период замера, затем вернуть. Это конфиг-tuning, не код. ## 2. Top-5 hot-path запросов — EXPLAIN (ANALYZE, BUFFERS) на проде Параметры: `region_code=66`, `district='Академический'` (самый плотный район), `cad='66:41:0303035'`. Размеры hot-таблиц: `objective_lots` 1.12М строк, `domrf_realization` 144k, `objective_corpus_room_month` 78k, `domrf_kn_objects` 10.7k, `domrf_kn_sales_agg` 2.6k, `cad_buildings` 38k, `cad_quarters_geom` 11k. `cad_parcels_geom` — **VIEW** (не базовая таблица), физически бьёт `cad_parcels`/`cad_buildings`. | # | Запрос (код) | Endpoint | План | Exec (cold/warm) | Вердикт | |---|---|---|---|---|---| | 1 | `market_pulse` `analytics_queries.py:27` | `/analytics/market-pulse` ≤500ms | Bitmap Index Scan `idx_realization_region` | **3.3 ms** | OK | | 2 | analyze geom UNION `parcels.py:1331` | `/parcels/{id}/analyze` ≤3с | Append + Index Scan на PK (3 ветки, 2 `never executed` из-за `LIMIT 1`) | **37.6 ms** | OK | | 3 | `_district_market_saturation` `analytics_queries.py:1426` | analyze/recommend | Aggregate; seq scan `domrf_kn_sales_agg` (2.6k строк, оптимален при таком размере) | **6.0 ms** | OK | | 4 | `_district_velocity_trend` `analytics_queries.py:1468` | recommend | Bitmap Index Scan `objective_crm_district_class_idx` → heap 1691 блоков | cold **283 ms** / warm **14.9 ms** | OK (см. §3) | | 5 | `_competitors_two_dim` `analytics_queries.py:2158` | analyze | Bitmap Index Scan `obj_district_class_active_idx` + `DISTINCT ON` 800→126 + ST_DWithin in-mem | **66 ms** | OK | | 5b | competitors count tier-1 `analytics_queries.py:1731` | analyze | **Index Only Scan** `obj_district_class_active_idx` | **1.0 ms** | OK | ### Запрос-нарушитель (вне таблицы top-5, но критичен) | Запрос | Endpoint | План ДО | Exec ДО | |---|---|---|---| | analyze «District price ranges» `parcels.py:2123` (`MIN/MAX/PERCENTILE_CONT/COUNT` по `objective_lots WHERE district=:dn AND price_per_m2_rub …`) | `/parcels/{id}/analyze` ≤3с | Bitmap Index Scan `objective_lots_district_class_idx` (matches ~182k) → **Bitmap Heap Scan ~41k блоков** (price_per_m2_rub в heap) → filter до 152k | **cold 5286 ms / warm 499 ms** | Это **пробивает** analyze p95 ≤3с на холодном кэше и на плотных районах. Корень: индекс `(district, class)` даёт district-match, но агрегируемая колонка `price_per_m2_rub` не в индексе → планировщик идёт в heap за ценой каждой из 182k строк (~320 МБ I/O cold). ## 3. Выявленные проблемы и что сделано ### P1 (исправлено) — missing covering-index на `objective_lots` **Миграция `data/sql/153_objective_lots_district_price_idx.sql`:** ```sql CREATE INDEX IF NOT EXISTS idx_objective_lots_district_price ON objective_lots (district, price_per_m2_rub) WHERE price_per_m2_rub IS NOT NULL; ``` Обе колонки (фильтр `district` + агрегируемое `price_per_m2_rub`) теперь в индексе → планировщик делает **Index Only Scan** (читает ~283 листовых страницы вместо 41k heap-блоков), heap не трогается. Частичный (`WHERE price_per_m2_rub IS NOT NULL`) — индексируем ~919k из 1.12М строк, зеркалит предикат запроса. Idempotent, не-CONCURRENTLY (deploy.yml гоняет в транзакции). **Ожидаемый эффект:** district-price block уходит с сотен мс (warm) / секунд (cold) в десятки мс. > **Важно — VACUUM обязателен для реализации выигрыша.** Проверено на проде через > аналогичный существующий индекс `objective_lots_rooms_area_idx (rooms_int, area_pd)`: > запрос той же формы действительно идёт `Index Only Scan` (паттерн подтверждён), НО при > устаревшей visibility map даёт `Heap Fetches: 29637` (страницы `dirtied` во время скана) > → Index Only Scan всё равно лезет в heap за видимостью, выигрыш частично теряется > (замер ~404 ms). Поэтому после deploy P1: повторить EXPLAIN запроса из §2, убедиться в > `Index Only Scan using idx_objective_lots_district_price`, и **обязательно** > `VACUUM ANALYZE objective_lots` (или дождаться autovacuum) — чтобы visibility map дал > «Heap Fetches: 0» и реальный exec упал в десятки мс. Без VACUUM индекс создан, но > эффект приглушён. ### P2 (документировано, индекс НЕ нужен) — cold-cache на `objective_corpus_room_month` `_district_velocity_trend` (#4): cold 283 ms — это first-touch disk I/O (1709 блоков), warm 14.9 ms. Индекс по `district` уже есть (`objective_crm_district_class_idx`); 283 мс — не отсутствие индекса, а холодный heap-fetch `deals_total_count` (нет в индексе). Таблица 108 МБ полностью кэшируется после прогрева → реальный p95 под нагрузкой ≈ warm. Covering- индекс дал бы выигрыш только на cold-start; при 108 МБ / постоянном трафике recommend это не стоит лишнего индекса. **Решение: не добавлять**, оставить на наблюдение через tracing. ### P3 (документировано, не проблема) — высокий seq_scan на `domrf_kn_objects` `pg_stat_user_tables`: `domrf_kn_objects` `seq_scan=87771`. Но таблица — 10.7k строк (9 МБ); seq scan для `MAX(snapshot_date)`-подзапросов дешёв. `objective_lots` `seq_tup_read` высок из-за ETL `SELECT * FROM objective_lots` (`objective_etl.py:364`) и `COUNT(*)/MAX(snapshot_date)` без фильтра (landing KPI / admin_scrape) — full-scan там неизбежен, индекс не помогает. Hot-path JOIN'ы по `objective_lots` идут через `project_name`/`district`-индексы. **Действий не требуется.** ## 4. p95-цели (issue #70) | Endpoint | Target p95 | Доминирующие запросы | Статус (по EXPLAIN) | |---|---|---|---| | `/analytics/market-pulse` | ≤500 ms | #1 (3.3 ms) | **в пределах** с запасом | | `/parcels/{id}/analyze` | ≤3 с | #2 (37 ms) + #5 (66 ms) + district-price (был 5.3с cold) | **после P1** — district-price в десятки мс → analyze укладывается; до P1 cold-кэш пробивал | | `/analytics/recommend` | ≤2 с | #3 (6 ms) + #4 (cold 283/warm 15 ms) + competitors | **в пределах** на warm; cold-start recommend стоит замерить через tracing после deploy | > Эти цифры — single-query exec на проде, не сквозной p95 endpoint'а (включает Python- > сериализацию, внешние HTTP в analyze — Open-Meteo через `weather_cache`, последовательные > lookup'ы ИРД/ОКН/ТЭП). Реальный p95 снимать по GlitchTip-tracing (§1) после P1 + прогрева. ## 5. Рекомендации (ops-decision) | # | Тема | Рекомендация | |---|---|---| | **R1** | Замер p95 | Временно поднять `GLITCHTIP_TRACES_SAMPLE_RATE` (напр. 0.2) на 1–2 дня после deploy P1, снять реальный p95 hot-path в GlitchTip, вернуть 0.05. | | **R2** | analyze — последовательные lookup'ы | Issue #70 предлагает `asyncio.gather` для независимых district/POI/competitors. **Блокер:** `analyze_parcel` — `sync def` (под uvicorn идёт в threadpool, см. `parcels.py:909` и `weather_cache.py`); SQLAlchemy-сессия sync. Параллелизация требует рефактора на async-сессии или `run_in_threadpool` per-lookup — отдельный PR, вне scope этого аудита. После P1 основной DB-нарушитель снят, выгода от распараллеливания ниже. | | **R3** | pgBouncer | **Пока не нужен.** Размеры данных умеренные, запросы укладываются в pool. Вводить при устойчивом росте конкурентных соединений >50 (метрика из issue) или если `pool_timeout` начнёт всплывать в логах (ср. инцидент #1202 — audit-write на event loop). Ops-decision. | | **R4** | VACUUM (обязательно) | После применения P1 — `VACUUM ANALYZE objective_lots`. Не опционально: на проде аналогичный индекс при stale visibility map давал `Heap Fetches: 29637` (~404 ms) вместо 0 — без VACUUM Index Only Scan лезет в heap и выигрыш приглушается (§3 P1). | ## 6. Acceptance (issue #70) - [x] EXPLAIN top-5 hot-path прогнан на проде с реальными цифрами (§2). - [x] Явный missing-index найден и исправлен миграцией `data/sql/153` (§3 P1). - [x] p95-цели зафиксированы + статус по каждой (§4). - [x] Tracing-статус + рекомендации по замеру / pgBouncer / async-рефактору (§1, §5). - [x] Audit-doc. ## Ссылки (код) - Tracing init: `backend/app/main.py`, `backend/app/workers/celery_app.py` - Hot-path SQL: `backend/app/services/analytics_queries.py`, `backend/app/api/v1/parcels.py` - Миграция-фикс: `data/sql/153_objective_lots_district_price_idx.sql` - analyze sync-def контекст: `backend/app/api/v1/parcels.py:909`, `backend/app/services/weather_cache.py`