perf(objective_lots): drop 3 dead indexes to cut UPSERT write-amp (#1953)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m17s
CI / backend-tests (pull_request) Successful in 14m47s

perf-hunt finding #4: objective_lots is a hot UPSERT table (1.76M ins /
1.39M upd over 35d) — each write maintains every index. These three have
near-zero lifetime idx_scan AND no sargable consumer anywhere in the
backend/SQL, so they are pure write-amplification:

  - objective_lots_status_idx   (idx_scan=8,  19 MB) — consumers use
    LOWER(COALESCE(status,''))='продан', not sargable on plain B-tree
  - objective_lots_rooms_area_idx (idx_scan=7, 78 MB) — rooms_int/area_pd
    only in SELECT projections + CASE-bucket, never a WHERE/ORDER filter
  - objective_lots_bank_idx     (idx_scan=2,  8 KB) — bank_name only in an
    IS NOT NULL projection; WHERE bank_name= is a different table

Verified on prod: EXPLAIN of each real query shape → Seq Scan (index not
chosen even when present). Frees ~97 MB + per-write WAL/maintenance.
Idempotent (DROP INDEX IF EXISTS), BEGIN/COMMIT, dry-run clean.
Rollback: recreate from data/sql/68_schema_objective.sql:250-256.

NOT touched: project_name_trgm_idx (236 MB) — has an ETL consumer
(objective_backfill.py similarity()); needs separate ETL-cadence check.
This commit is contained in:
Light1YT 2026-06-28 19:13:42 +05:00
parent 94d01ed066
commit 800e68af58

View file

@ -0,0 +1,65 @@
-- Контекст: perf-hunt finding #4 (follow-up эпика #1953) — срезаем
-- write-amplification на горячей UPSERT-таблице objective_lots.
--
-- objective_lots — append/UPSERT-per-snapshot, очень write-heavy: за 35 дней
-- аптайма (pg_stat без reset, prod 2026-06-28) n_tup_ins=1.76M, n_tup_upd=1.39M.
-- Каждый INSERT/UPDATE обязан поддерживать ВСЕ индексы таблицы → мёртвый индекс =
-- чистый write-amp + место на диске + WAL, ноль пользы для чтения.
--
-- Дропаем 3 индекса, у которых pg_stat_user_indexes.idx_scan почти нулевой за весь
-- аптайм И в кодовой базе (backend services/api/workers + data/sql views) НЕТ ни
-- одного sargable-запроса, который мог бы их использовать. По каждому проверено
-- EXPLAIN на prod: планировщик берёт Seq Scan на реальной форме запроса даже при
-- наличии индекса (доказывает, что индекс не служит ничему).
--
-- ── DROP 1: objective_lots_status_idx ── (idx_scan=8 за 35д, 19 MB)
-- Опр.: ON objective_lots(status) WHERE status IS NOT NULL (68_schema_objective.sql:250)
-- Единственные потребители status: market_metrics._STOCK_SQL,
-- supply_layers, competitors, special_indices — ВСЕ через
-- `LOWER(COALESCE(ol.status,'')) = 'продан'`. Обёртка LOWER(COALESCE(...))
-- НЕ sargable против plain B-tree на status → индекс не используется НИКОГДА.
-- Bare `status = :x` / `status IN (...)` в коде НЕ встречается (grep пуст).
-- Колонка status уже в payload covering-индекса (176_*, INCLUDE) — для проекции
-- отдельный индекс тоже не нужен. Триггер истории (166_*) читает NEW.status /
-- prev.status построчно (без индекса).
-- EXPLAIN prod: `WHERE LOWER(COALESCE(status,''))='продан'` → Parallel Seq Scan.
--
-- ── DROP 2: objective_lots_rooms_area_idx ── (idx_scan=7 за 35д, 78 MB)
-- Опр.: ON objective_lots(rooms_int, area_pd) WHERE area_pd IS NOT NULL (:252)
-- rooms_int / area_pd встречаются только в SELECT-проекциях и внутри CASE-bucket
-- выражений (market_metrics, sales_series, analytics) — НЕ как WHERE/ORDER/JOIN
-- предикат. Room-bucket фильтр оборачивает обе колонки в CASE → не sargable.
-- Никакого `WHERE rooms_int = / IN` или `area_pd >/</BETWEEN`-фильтра в коде нет
-- (grep пуст). Драйвер этих запросов — district/premise_kind/class
-- (objective_lots_district_class_idx, idx_scan=49746 — жив, НЕ трогаем).
-- EXPLAIN prod: CASE-bucket-форма → Parallel Seq Scan; индекс не выбран.
--
-- ── DROP 3: objective_lots_bank_idx ── (idx_scan=2 за 35д, 8 KB)
-- Опр.: ON objective_lots(bank_name) WHERE bank_name IS NOT NULL (:256)
-- Единственный потребитель objective_lots.bank_name — special_indices
-- ._ARTIFICIAL_DEMAND_SQL: `(ol.encumbrance_type IS NOT NULL OR
-- ol.bank_name IS NOT NULL) AS has_mortgage` — это проекция в SELECT, не фильтр;
-- WHERE там по premise_kind/is_sold/contract_date/district/class. Запросы с
-- `WHERE bank_name = ...` есть только к ДРУГОЙ таблице (domrf_rates,
-- 40_relations.sql:270/273), не к objective_lots.
-- EXPLAIN prod: реальная форма → Parallel Seq Scan; индекс не выбран.
--
-- НЕ ТРОГАЕМ: objective_lots_project_name_trgm_idx (236 MB, idx_scan=19) — у него
-- ETL-потребитель (objective_backfill.py, similarity()); дроп требует отдельной
-- проверки ETL-каденса → отдельный тикет, вне scope этой миграции.
--
-- Откат: пересоздать из data/sql/68_schema_objective.sql:250-256
-- (CREATE INDEX IF NOT EXISTS ...). Дроп индекса обратим, данные не теряются.
--
-- Порядок применения для prod: миграция самодостаточна (ничего в app-коде не
-- зависит от этих индексов — потому и дропаем), порядок deploy не критичен.
-- Idempotent (DROP INDEX IF EXISTS). Self-wrapped BEGIN/COMMIT.
-- Экономия: ~97 MB диска + соответствующий WAL/maintenance write-amp на каждый
-- из ~3.15M lifetime INSERT/UPDATE.
BEGIN;
DROP INDEX IF EXISTS objective_lots_status_idx;
DROP INDEX IF EXISTS objective_lots_rooms_area_idx;
DROP INDEX IF EXISTS objective_lots_bank_idx;
COMMIT;