fix(tradein/db): снять побайтовый дубль индекса на trade_in_estimates(expires_at) (#2752) #2784
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2784
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "chore/2752-duplicate-index"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Закрывает #2752 (основной пункт). Хвосты разобраны ниже — отдельными правками, в этот PR не смешаны.
Дословное сравнение, а не по имени
pg_indexна проде 2026-08-07:trade_in_estimates_expires_idx(001)trade_in_estimates_expires_at_idx(229)Совпадает всё: колонка, класс операторов, направление сортировки, NULLS-порядок (
indoption=0→ ASC/NULLS LAST у обоих), коллация, отсутствие частичного предиката, метод доступа. Предпосылка задачи подтверждена: это побайтовый дубль.Почему у «нулевого» дубля появились сканы
В теле задачи было «0 сканов, статистика не показательна». Сейчас 15 — но важнее другое: счётчик старого заморожен. Два замера подряд:
Старый +0, новый +4. Планировщик перевёл на дубль весь живой трафик; 234 у старого — накопленное историческое, не текущее.
Причина физическая, не семантическая: индексы идентичны, но новый собран вчера с нуля и плотнее —
relpages5 против 6.genericcostestimate()считает спуск по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost и выигрывает при прочих равных. Нового запроса не появилось:idx_tup_read/idx_scanу обоих одного порядка (1.88 у старого, 0.93 у нового) — тот же класс точечных lookup'ов, просто переехавший на более свежий индекс. Со временем новый забронзовел бы так же и они поменялись бы обратно.ОПРОВЕРГНУТО: обоснование индекса в самой 229
229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу
purge_expired_trade_in_data— без индекса batched-DELETE делал бы full scan». На проде это не так.EXPLAINбоевого запроса изapp/tasks/purge_expired_trade_in_data.py:Запрос сужен по
created_by IS NULL(129 строк из 1061), планировщик берёт более селективный индекс, аexpires_atостаётсяFilter'ом. Ни один из двух expires-индексов в этом плане не участвует. То есть развилки из задачи («если в 229 индекс заводился под конкретный запрос — оставить его») не существует: этот запрос его не использует. Оставлен индекс из 001 — он объявлен в миграции, создающей таблицу, и на свежей БД переживший индекс совпадёт с прод-состоянием, без «001 создаёт → 250 сносит» на каждой новой базе.Планы ДО и ПОСЛЕ
Индексы побайтово идентичны, поэтому смена узла невозможна в принципе — меняется только имя в строке плана и cost на одну страницу спуска. Снято на чистом PostgreSQL 16.4 (та же минорная версия, что на проде) с воспроизведённым перекосом плотности:
Форма плана,
Index CondиFilterидентичны. На проде разрыв плотности меньше синтетического (5 против 6 страниц, а не 5 против 8) → и дельта cost будет меньше.pg_stat_statementsв этой БД не установлен (pg_extension: pg_trgm, pgcrypto, plpgsql, postgis, postgres_fdw) — потребители искались по коду, а не по статистике запросов.Гранты и зависимости
pg_dependпо обоим индексам — 0 строк;pg_constraint.conindid— 0 строк, ни один не обслуживает ограничение.DROP INDEXбез CASCADE. Гранты живут на таблице (role_table_grantsнаtrade_in_estimates: толькоtradein, 7 привилегий), индекс их не несёт — цепочка, на которой C3 терял гранты FDW-пользователю, здесь не задействована.Чего стоит блокировка
Обычный
DROP INDEXберётACCESS EXCLUSIVEна таблицу. Здесь дёшево:trade_in_estimates— 1061 строка, heap 1856 kB (231 страница), сносимый индекс 40 kB.DROP INDEXничего не переписывает — удаление строк каталога плюс unlink файла, единицы миллисекунд.CONCURRENTLYне нужен и был бы хуже. Раннер (.forgejo/workflows/deploy-tradein.yml) гоняетpsql -v ON_ERROR_STOP=on < "$sql_file"без-1, то есть autocommit —DROP INDEX CONCURRENTLYформально прошёл бы, но потребовал бы отказаться отBEGIN/COMMITв файле ради операции, которая и так занимает миллисекунды.Проверка
Файл прогнан дважды на чистом PostgreSQL 16.4: второй прогон печатает
NOTICE: index ... does not exist, skippingи завершаетсяCOMMIT. Идемпотентность под strictON_ERROR_STOP=onподтверждена.Критерий приёмки (записан ДО применения)
После деплоя
idx_scanуtrade_in_estimates_expires_idxдолжен сдвинуться с 234 в течение часа. Останется 234 — значит трафик ушёл не на переживший индекс, а в Seq Scan; это опровергло бы разбор выше и потребовало бы отката.Три хвоста — разобраны отдельно, здесь не тронуты
hph_source_idx— сносить НЕЛЬЗЯ, префикс-избыточность тут обманчива. Он(source), аhouse_placement_history_source_ext_item_id_key—(source, ext_item_id), то есть формально да, префикс. Но размеры: 1336 kB против 8696 kB, и профиль нагрузки не точечный: 94 скана прочитали 4 544 184 тупла (≈48 тыс. на скан) при 757 233 fetch. Это bulk-проходы поsource, где узкий индекс в 6.5 раза дешевле по I/O. Снос переселил бы их на индекс в 6.5 раза толще на таблице 104 MB / 100 724 строки. Предпосылка «префикс-избыточный ⇒ лишний» здесь опровергнута; отдельного PR на снос быть не должно.indisvalid. Конкретного повода нет:SELECT ... FROM pg_index WHERE NOT indisvalid OR NOT indisready OR NOT indisliveпо всей боевой БД — 0 строк. Недостроенных индексов нет ни здесь, ни где-либо ещё, так что «занимает место и мешает пересозданию» к этому случаю не относится.CONCURRENTLY— дыра реальная и по-прежнему открыта. Вdata/sql13 файлов сCREATE INDEX CONCURRENTLY; механика: файл падает по таймауту →INSERT INTO _schema_migrationsне выполняется → следующий деплой прогоняет файл заново →IF NOT EXISTSвидит невалидный индекс, молча его не пересоздаёт → файл «успешен» и записывается как применённый.225_listing_source_snapshots_run_id_idx.sqlэтот сценарий уже описал словами, но проверки нет. Правильное место — не по DO-блоку в каждый файл, а один запрос в раннере после цикла миграций: ловит все файлы, прошлые и будущие, без бойлерплейта. Отдельной задачей: правкаdeploy-tradein.ymlтянетinfra-фильтр и полную пересборку — не место для попутного коммита в PR про индекс.