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.
65 lines
5 KiB
PL/PgSQL
65 lines
5 KiB
PL/PgSQL
-- Контекст: 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;
|