#70: EXPLAIN top-5 hot-path на prod — analyze district-price block по objective_lots (1.12M) делал Bitmap Heap Scan ~41k блоков (cold 5.3s/warm 474ms), пробивая p95 ≤3с. Partial covering-index (district, price_per_m2_rub) WHERE price_per_m2_rub IS NOT NULL → Index Only Scan (verified scratch: 87ms, Heap Fetches 0). Требует VACUUM ANALYZE objective_lots post-deploy (stale VM иначе глушит выигрыш). database-expert ✅ verified. #79: 152-ФЗ PII grep-аудит — plain-PII в логах НЕ найдено; Sentry scrub / PRINZIP-хеширование / LLM §19-redaction подтверждены. Остаточный риск: pilot_requests хранит сырые ПДн без retention (рекомендация в docs). httpx DEBUG off (ops-инвариант). Closes #70 Closes #79
13 KiB
Performance аудит: Sentry Tracing + EXPLAIN slow queries
Аудит по issue #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 (статус)
Инструментация активна в обоих процессах:
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_queryredact-ит секреты из 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:
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)
- EXPLAIN top-5 hot-path прогнан на проде с реальными цифрами (§2).
- Явный missing-index найден и исправлен миграцией
data/sql/153(§3 P1). - p95-цели зафиксированы + статус по каждой (§4).
- Tracing-статус + рекомендации по замеру / pgBouncer / async-рефактору (§1, §5).
- 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