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

153 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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) на 12 дня после 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`