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
#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
153 lines
13 KiB
Markdown
153 lines
13 KiB
Markdown
# 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`
|