gendesign/docs/Performance_Audit.md
Light1YT 11aa011d11
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 2m3s
CI / openapi-codegen-check (pull_request) Successful in 2m7s
CI / backend-tests (push) Successful in 8m55s
CI / backend-tests (pull_request) Successful in 8m58s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 29s
Deploy / build-worker (push) Successful in 29s
Deploy / deploy (push) Successful in 1m10s
perf(db): covering-index objective_lots district-price (#70) + 152-ФЗ/perf audit docs (#70 #79)
#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
2026-06-14 00:09:09 +05:00

13 KiB
Raw Blame History

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_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_geomVIEW (не базовая таблица), физически бьёт 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) на 12 дня после deploy P1, снять реальный p95 hot-path в GlitchTip, вернуть 0.05.
R2 analyze — последовательные lookup'ы Issue #70 предлагает asyncio.gather для независимых district/POI/competitors. Блокер: analyze_parcelsync 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