Модуль: Базы данных · Уровень: Senior
TL;DR#
- B-tree — дефолтный индекс, отсортированное сбалансированное дерево. Работает на
=,<,>,BETWEEN,IN,IS NULL,LIKE 'prefix%',ORDER BY. Поддерживает уникальность. 90% случаев — это он. - Hash — только
=. С PG 10 WAL-логируется и crash-safe. Чуть компактнее B-tree на равенстве, но без диапазонов и сортировки — почти всегда B-tree предпочтительнее. - GIN — для составных значений:
jsonb, массивы (@>,&&), full-text (tsvector @@ tsquery),pg_trgm(LIKE/ILIKE/похожесть). Хранит «значение → список TID». Медленный на запись, очень быстрый на поиск; естьfastupdate(pending list). - GiST — обобщённое дерево для «перекрытий»: геоданные (PostGIS), диапазоны (
tsrange &&), kNN (ORDER BY point <-> point), exclusion constraints. Lossy по природе. - SP-GiST — несбалансированные структуры (quadtree, radix), для непересекающихся данных (IP, точки).
- BRIN — block range index. Крошечный, хранит min/max по блокам. Только для больших таблиц с физической корреляцией (timestamp append-only). Не точечный — даёт кандидатов на recheck.
- Составной индекс: порядок колонок решает всё. Leftmost prefix rule. Equality-колонки слева, range — справа, по последней range-колонке возможен
ORDER BY. - Partial (
WHERE), covering/INCLUDE (index-only scan + visibility map), expression index (индекс поlower(email)). - Индекс не используется при низкой селективности, функции/каста над колонкой, type mismatch,
OR(иногда),LIKE '%x', устаревшей статистике. - Каждый индекс — налог на каждый
INSERT/UPDATE/DELETE. HOT-update спасает только если меняемые колонки не индексированы. Настраивайfillfactor, следи за bloat.
Теория#
Зачем индекс и общая модель#
Индекс — это вспомогательная структура данных, которая позволяет находить строки без полного сканирования таблицы (heap). В PostgreSQL индексы отделены от таблицы: индекс хранит ключ + указатель TID (ctid = (block, offset)), указывающий на физическое расположение версии строки в heap.
Ключевое следствие архитектуры MVCC: индекс ссылается на конкретную версию строки (tuple), а не на «логическую строку». Это объясняет write amplification, необходимость visibility map для index-only scan и причину bloat индексов.
Планировщик выбирает между:
- Seq Scan — последовательное чтение всего heap (хорош при высокой селективности выборки = много строк).
- Index Scan — обход индекса + случайные обращения в heap за каждой строкой.
- Index Only Scan — данные берутся только из индекса (нужна visibility map).
- Bitmap Index Scan / Bitmap Heap Scan — строит битмап TID’ов, сортирует по блокам, читает heap последовательно. Хорош для средней селективности и для комбинации нескольких индексов (
BitmapAnd,BitmapOr).
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 42;B-tree#
Сбалансированное многоуровневое дерево (вариант B+tree Lehman–Yao с поддержкой конкурентного доступа). Листья хранят отсортированные ключи и связаны в двусвязный список — поэтому возможен эффективный диапазонный обход и обход в обе стороны для ORDER BY ... DESC.
Что обслуживает:
- Равенство:
=,IN (...). - Диапазоны:
<,<=,>,>=,BETWEEN. IS NULL/IS NOT NULL(NULL-ы хранятся в индексе).- Префиксный поиск:
LIKE 'abc%',~ '^abc'— но только если индекс наtext_pattern_opsпри не-C локали (см. ниже). - Сортировку:
ORDER BY colиORDER BY col DESCбез отдельного шага Sort. - Уникальность (
UNIQUE, PK), exclusion на равенство.
Сортировка и направление. Индекс хранит ключи в определённом порядке (ASC NULLS LAST по умолчанию). Если запрос требует другого порядка, планировщик может пройти индекс в обратную сторону. Но для составного индекса со смешанными направлениями нужно совпадение:
-- индекс
CREATE INDEX idx ON events (created_at DESC, id ASC);
-- этот ORDER BY будет index-ordered (без Sort):
SELECT * FROM events ORDER BY created_at DESC, id ASC LIMIT 50;
-- а этот потребует Sort или обратного прохода только если направления зеркальны целиком:
SELECT * FROM events ORDER BY created_at ASC, id DESC LIMIT 50; -- OK (полное зеркало)
SELECT * FROM events ORDER BY created_at ASC, id ASC LIMIT 50; -- НЕ index-ordered -> SortЛокаль и операторные классы. При локали отличной от C сравнение строк нелинейно относительно байтов, поэтому LIKE 'abc%' по умолчанию не использует обычный B-tree. Нужен специальный класс:
CREATE INDEX idx_login_prefix ON users (login text_pattern_ops);
-- теперь работает:
SELECT * FROM users WHERE login LIKE 'admin%';
-- но для = по-прежнему лучше обычный индекс (text_pattern_ops не годен для ORDER BY в локали)Дедупликация (PG 13+). B-tree умеет сжимать повторяющиеся ключи, храня один ключ + список TID. Сильно уменьшает размер индекса по колонке с низкой кардинальностью внутри составного индекса.
// pgx: типичный equality + range запрос
rows, err := pool.Query(ctx,
`SELECT id, amount FROM orders
WHERE user_id = $1 AND created_at >= $2
ORDER BY created_at DESC LIMIT 100`,
userID, since)Hash#
Хранит 32-битный хеш ключа. Поддерживает только =. Никаких диапазонов, сортировки, префиксов, multicolumn.
| B-tree | Hash | |
|---|---|---|
| Операторы | =, <, >, range, ORDER BY | только = |
| Multicolumn | да | нет (PG) |
| Размер | больше | меньше для длинных ключей |
| WAL / crash-safe | да | да (с PG 10) |
| Unique | да | нет |
До PG 10 Hash-индексы не писались в WAL (не реплицировались, терялись при краше) — поэтому исторически их избегали. Сейчас они валидны, но выигрыш мал и узок: имеет смысл на очень длинных ключах (длинные строки, UUID как text), где хеш фиксированной длины экономит место и сравнения по = идут чаще, чем диапазоны. На практике дефолт — B-tree.
GIN (Generalized Inverted Index)#
Инвертированный индекс: для каждого элемента внутри значения хранится список строк, где он встречается. Идеален, когда одно значение содержит много искомых единиц.
Применения:
jsonb— операторы@>,?,?|,?&. Классjsonb_path_opsкомпактнее, но поддерживает только@>.- Массивы —
@>(содержит),<@(содержится в),&&(пересекается),=. - Full-text —
tsvector @@ tsquery. pg_trgm—LIKE '%substr%',ILIKE, похожесть (%оператор).
-- jsonb
CREATE INDEX idx_doc ON documents USING gin (data jsonb_path_ops);
SELECT * FROM documents WHERE data @> '{"status":"active"}';
-- массив тегов
CREATE INDEX idx_tags ON articles USING gin (tags);
SELECT * FROM articles WHERE tags @> ARRAY['go','postgres'];
-- full-text с генерируемой колонкой (PG 12+)
ALTER TABLE articles ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX idx_tsv ON articles USING gin (tsv);
SELECT * FROM articles WHERE tsv @@ websearch_to_tsquery('english', 'golang index');
-- подстрочный LIKE через триграммы
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_name_trgm ON users USING gin (name gin_trgm_ops);
SELECT * FROM users WHERE name ILIKE '%vova%';fastupdate / pending list. Запись в GIN дорогая (нужно обновить много posting list). PG буферизует вставки в «pending list» и вливает их пачкой при vacuum или при достижении gin_pending_list_limit. Плюс — быстрые вставки; минус — поиск должен сканировать pending list, и периодически случается дорогой merge. Для read-heavy таблиц иногда fastupdate=off.
GiST (Generalized Search Tree)#
Каркас для построения деревьев под произвольные предикаты «перекрытия/близости». Внутренние узлы хранят предикат, покрывающий поддерево (например, bounding box). Поиск отсекает поддеревья, чьи предикаты не пересекаются с запросом.
Применения:
- Геоданные (PostGIS):
&&,ST_Contains,ST_DWithin. - Диапазонные типы:
int4range,tstzrangeс&&(пересечение),@>. - kNN-поиск (ближайшие соседи) через оператор расстояния:
CREATE INDEX idx_geom ON places USING gist (location);
-- ближайшие 10 точек к заданной — индекс отдаёт их в порядке расстояния
SELECT id, location <-> point '(40.0, 30.0)' AS dist
FROM places
ORDER BY location <-> point '(40.0, 30.0)'
LIMIT 10;
-- exclusion constraint: запретить пересекающиеся брони одной комнаты
CREATE EXTENSION btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&);GiST lossy: индекс может вернуть кандидатов, которые требуется перепроверить на heap (recheck). Также GiST не гарантирует уникальность ключей (структура несбалансирована по содержимому).
SP-GiST — родственник для разделяемых непересекающихся пространств (quadtree, k-d tree, radix-trie): IP-адреса (inet), точки, префиксы строк. Когда данные естественно разбиваются на непересекающиеся регионы.
BRIN (Block Range Index)#
Не индексирует строки — индексирует диапазоны блоков. Для каждого «range» (по умолчанию 128 страниц) хранит сводку: min/max значения. Размер индекса — килобайты на гигабайтную таблицу.
Работает только при физической корреляции между порядком значений и порядком хранения. Классика — append-only таблица с created_at: новые строки физически идут в конец, поэтому min/max по блокам монотонны.
CREATE INDEX idx_brin_ts ON metrics USING brin (recorded_at) WITH (pages_per_range = 128);
SELECT * FROM metrics WHERE recorded_at >= now() - interval '1 hour';При запросе BRIN исключает range’и, чьи min/max не пересекают условие, и читает только подходящие блоки (с обязательным recheck каждой строки). Если корреляция слабая (данные перемешаны / частые UPDATE), BRIN почти бесполезен — каждый range покрывает весь диапазон значений. Поддерживается brin_summarize_new_values() / автосуммаризация для новых блоков.
| Индекс | Размер | Скорость поиска | Стоимость записи | Когда |
|---|---|---|---|---|
| B-tree | средний | очень высокая | средняя | дефолт, =, range, sort |
| Hash | малый | высокая (=) | средняя | только =, длинные ключи |
| GIN | большой | высокая (поиск в составном) | высокая | jsonb, массивы, FTS, trgm |
| GiST | средний | высокая (overlap/kNN) | средняя-высокая | гео, диапазоны, exclusion |
| BRIN | крошечный | средняя (recheck) | очень низкая | огромные таблицы, корреляция |
Составные (multicolumn) индексы#
Один индекс по нескольким колонкам. Порядок колонок критичен.
Leftmost prefix rule. B-tree эффективно использует только непрерывный левый префикс, причём все колонки до последней использованной должны участвовать по равенству.
CREATE INDEX idx ON t (a, b, c);| Запрос | Использование |
|---|---|
WHERE a = 1 | да (префикс a) |
WHERE a = 1 AND b = 2 | да (a,b) |
WHERE a = 1 AND b = 2 AND c = 3 | да полностью |
WHERE a = 1 AND c = 3 | частично: только a как граница, c как фильтр внутри |
WHERE b = 2 | нет эффективного поиска (b не лидирует) — обычно Seq/Bitmap по другому индексу |
WHERE a = 1 AND b > 5 AND c = 3 | a,b как границы; c уже не сужает дерево (после range) |
Правило проектирования: колонки с равенством — слева, диапазон — последним. После первой range-колонки остальные колонки индекса для поиска бесполезны (но могут давать порядок). По последней range/eq-колонке индекс может обслуживать ORDER BY.
-- equality (status, user_id), затем range/sort по created_at
CREATE INDEX idx ON orders (status, user_id, created_at DESC);
SELECT * FROM orders
WHERE status = 'paid' AND user_id = 7
ORDER BY created_at DESC LIMIT 20; -- index-ordered, без SortPG также умеет skip scan-подобное поведение через несколько Bitmap-индексов, но это не замена правильному порядку колонок.
Partial индексы (WHERE)#
Индекс строится только по подмножеству строк. Меньше размер, дешевле запись, плотнее.
-- индексируем только активные строки — soft delete
CREATE INDEX idx_active_email ON users (email) WHERE deleted_at IS NULL;
SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL; -- использует индекс
-- частичный уникальный индекс: уникальность только среди активных
CREATE UNIQUE INDEX uniq_active_login ON users (login) WHERE deleted_at IS NULL;
-- очередь: индекс только по необработанным
CREATE INDEX idx_pending ON jobs (created_at) WHERE state = 'pending';Важно: планировщик использует partial-индекс только если условие запроса доказуемо влечёт предикат индекса. WHERE deleted_at IS NULL подойдёт, а WHERE deleted_at IS NULL OR x — нет.
Covering / INCLUDE и index-only scan#
Index-only scan — данные читаются из индекса без обращения к heap. Условие: все нужные колонки есть в индексе И строка видима по visibility map.
INCLUDE (PG 11+) добавляет «полезную нагрузку» в листья индекса без участия в ключе (не влияет на сортировку/уникальность, не используется для поиска):
CREATE INDEX idx_cov ON orders (user_id) INCLUDE (amount, status);
SELECT amount, status FROM orders WHERE user_id = 42; -- может стать Index Only ScanVisibility map (VM). PG не хранит видимость прямо в индексе. Чтобы пропустить обращение в heap, страница heap должна быть помечена «all-visible» в VM. VM обновляет VACUUM. Поэтому на таблице с активной записью без vacuum index-only scan вырождается в обычный (видно в EXPLAIN: Heap Fetches: N). Высокий Heap Fetches — сигнал, что нужен vacuum/autovacuum-тюнинг.
EXPLAIN (ANALYZE) SELECT amount FROM orders WHERE user_id = 42;
-- Index Only Scan ... Heap Fetches: 0 <- хорошоINCLUDE vs ключ: класть колонку в ключ нужно, если по ней идёт поиск/сортировка; в INCLUDE — если она нужна только для возврата. INCLUDE дешевле (нет порядка), но раздувает листья.
Expression (functional) индексы#
Индекс по результату выражения. Запрос должен использовать точно то же выражение.
-- регистронезависимый поиск
CREATE INDEX idx_lower_email ON users (lower(email));
SELECT * FROM users WHERE lower(email) = lower($1); -- использует индекс
-- по полю jsonb
CREATE INDEX idx_country ON profiles ((data ->> 'country'));
SELECT * FROM profiles WHERE data ->> 'country' = 'RU';
-- по дате из timestamp (но осторожно с time zone и immutability)
CREATE INDEX idx_day ON events ((created_at::date));Выражение должно быть IMMUTABLE. now(), current_date, нестабильные касты к timestamptz нельзя. Статистика собирается по выражению как по виртуальной колонке (ANALYZE это делает автоматически для expression-индекса) — это бонус для оценки селективности нестандартных предикатов.
Когда индекс НЕ используется#
- Низкая селективность. Если условие отбирает большую долю таблицы (часто > ~5-10%), Seq Scan дешевле случайных чтений. Пример:
WHERE is_active = true, когда 95% активны. Это правильное решение планировщика, а не баг. - Функция/каст над колонкой.
WHERE lower(email) = ...не использует индекс поemail(нужен expression-индекс).WHERE date(created_at) = '...'ломает индекс поcreated_at— переписывай на rangecreated_at >= d AND created_at < d+1. - Type mismatch / неявный каст. Колонка
varchar, а сравнение с числом; илиbigint-колонка сравнивается сtext-параметром. Каст применяется к колонке → индекс не годится. В Go это часто результат биндинга не того типа. ORпо разным колонкам.WHERE a = 1 OR b = 2может не дать индексного плана по одному индексу; решение — два индекса +BitmapOr, либо переписать черезUNION.- Leading wildcard.
LIKE '%abc'илиLIKE '%abc%'не использует обычный B-tree (нет префикса) — нуженpg_trgmGIN/GiST. - Устаревшая статистика. После массовой загрузки без
ANALYZEпланировщик ошибается в оценке строк. ЛечитсяANALYZE table;и autovacuum-настройками. - NULL-семантика. Стандартный B-tree индексирует NULL, но некоторые операции (
<>,NOT IN) плохо ложатся на индекс. - Маленькая таблица. Seq Scan дешевле — нормально.
-- плохо: каст ломает индекс по created_at
WHERE created_at::date = '2026-06-14'
-- хорошо: sargable range
WHERE created_at >= '2026-06-14' AND created_at < '2026-06-15'// type mismatch в Go: колонка bigint, а сюда прилетает строка
// плохо — драйвер пошлёт text, PG кастует колонку
pool.Query(ctx, `SELECT * FROM t WHERE id = $1`, "42")
// хорошо — числовой тип
pool.Query(ctx, `SELECT * FROM t WHERE id = $1`, int64(42))Диагностика: EXPLAIN (ANALYZE, BUFFERS) — сравни rows (оценка) с actual rows. Большое расхождение = проблема со статистикой/предикатом.
Стоимость на запись#
Индексы ускоряют чтение ценой записи и обслуживания.
Write amplification. Каждый INSERT обновляет heap и каждый индекс. Каждый DELETE помечает версию мёртвой (индексные записи остаются до vacuum). UPDATE в MVCC = новая версия строки → новые TID → запись во все индексы (а не только в те, чьи колонки изменились).
HOT (Heap-Only Tuple) update. Оптимизация: если изменяемые колонки не входят ни в один индекс и на странице есть место, новая версия пишется на ту же страницу и связывается цепочкой; индексы НЕ трогаются. Это резко снижает write amplification и bloat.
- Условие HOT: ни одна обновляемая колонка не индексирована (включая expression/partial/INCLUDE-ключи — но INCLUDE-колонки в современных версиях не блокируют HOT так же, как ключевые; проверяй версию).
- Дать HOT место помогает
fillfactor.
-- оставить 20% свободного места на странице под будущие HOT-обновления
ALTER TABLE orders SET (fillfactor = 80);
-- для часто обновляемых таблиц 70-90; для append-only оставляй 100 (дефолт)Fillfactor для индексов. B-tree-индексы тоже имеют fillfactor (дефолт 90). Низкий fillfactor уменьшает расщепления страниц (page splits) при вставках, но увеличивает размер.
Bloat и обслуживание. Мёртвые версии и удалённые индексные записи накапливаются. Лечение:
VACUUM— освобождает место под reuse, чистит индексы, обновляет visibility map (нужна для index-only scan).ANALYZE— обновляет статистику.REINDEX CONCURRENTLY(PG 12+) — перестроить раздутый индекс без долгой блокировки.CREATE INDEX CONCURRENTLY— построить индекс без блокировки записи (дольше, два прохода, может оставить INVALID индекс при сбое).
CREATE INDEX CONCURRENTLY idx ON big_table (col); -- не блокирует writes
REINDEX INDEX CONCURRENTLY idx; -- де-bloat без эксклюзивной блокировки
-- проверка INVALID индексов после сбоя CONCURRENTLY:
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;Стоимость лишних индексов: замедляют запись, едят место и кэш, нагружают autovacuum, могут увести планировщик в худший план. Ищи неиспользуемые:
SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexrelid NOT IN (SELECT conindid FROM pg_constraint)
ORDER BY pg_relation_size(indexrelid) DESC;Подводные камни / gotchas#
date(col) = .../col::date = ...убивает индекс поcol. Всегда переписывай на полуинтервал>= ... AND < ....- Type mismatch из Go-кода. Передавай параметры точным типом (
int64, неstring). Неявный каст применяется к колонке и отключает индекс. - Порядок колонок в составном индексе.
(a, b)не помогает запросуWHERE b = ?. Не плоди индексы «на всякий случай» — проектируй под реальные запросы. - Index-only scan не работает без vacuum.
Heap Fetchesвысокий → VM устарела. Это частая причина «почему covering index не ускоряет». - GIN на write-heavy таблице может стать узким местом из-за стоимости вставки и pending-list merge. Меряй
gin_pending_list_limit. CREATE INDEXбезCONCURRENTLYберётSHARElock — блокирует запись в таблицу на всё время сборки. В проде почти всегдаCONCURRENTLY.CREATE INDEX CONCURRENTLYнельзя в транзакции и при сбое оставляет невалидный индекс, который надо дропнуть и пересоздать.- HOT ломается, если обновляемую колонку добавили в индекс (даже partial/expression). Внезапный рост bloat после добавления индекса — типичный симптом.
LIKE '%x%'требуетpg_trgm, а не обычный B-tree.text_pattern_opsпомогает только префиксу.- NULLs в составном UNIQUE. До PG 15
NULLсчитались различными → несколько строк с NULL проходят уникальность. PG 15+ —UNIQUE NULLS NOT DISTINCT. - BRIN на перемешанных данных бесполезен — нужна физическая корреляция. После
UPDATE-heavy нагрузки корреляция деградирует. - Дублирующие индексы.
(a)лишний при наличии(a, b)для поиска поa, но НЕ лишний, если по(a)нужен меньший index-only scan. Проверяй осознанно. - Локаль collation. Смена collation (например, обновление glibc) может сделать B-tree-индексы по text «битыми» по сортировке → нужен REINDEX.
Вопросы на собеседовании#
В: Почему UPDATE одной неиндексированной колонки может всё равно записать во все индексы, и как этого избежать?
О: MVCC создаёт новую версию строки с новым TID; по умолчанию все индексы должны указать на новый TID. Избежать помогает HOT-update: если ни одна изменяемая колонка не индексирована и на странице есть место (регулируется fillfactor), новая версия пишется на ту же страницу цепочкой, индексы не трогаются.
В: В чём разница между Index Scan, Index Only Scan и Bitmap Heap Scan и когда планировщик выбирает каждый? О: Index Scan — обход индекса + случайный поход в heap за каждой строкой (хорош при высокой селективности, малом числе строк). Index Only Scan — данные целиком из индекса при all-visible странице (VM). Bitmap — строит битмап TID, сортирует по блокам, читает heap последовательно; хорош для средней селективности и комбинирования индексов через BitmapAnd/Or.
В: Дан индекс (a, b, c). Какие из запросов его используют: WHERE b=1; WHERE a=1 AND c=3; WHERE a=1 AND b>2 AND c=3?
О: b=1 — нет (b не лидирует). a=1 AND c=3 — да, но только a как граница поиска, c фильтруется внутри. a=1 AND b>2 AND c=3 — a и b ограничивают дерево, но после range по b колонка c уже не сужает поиск (только фильтр/порядок).
В: Когда выберешь GIN, а когда GiST?
О: GIN — когда значение содержит много искомых элементов и нагрузка read-heavy: jsonb, массивы, full-text, trgm. Быстрее ищет, но дороже на запись. GiST — для предикатов перекрытия/близости: геометрия, диапазоны, kNN (ORDER BY <->), exclusion constraints; дешевле на запись, lossy, поддерживает расстояние.
В: Что нужно, чтобы INCLUDE-индекс реально дал index-only scan?
О: Все возвращаемые колонки должны быть в индексе (ключ или INCLUDE), и страница heap должна быть all-visible в visibility map, которую обновляет VACUUM. Иначе будут Heap Fetches > 0 и сканирование выродится в обычный index scan.
В: Почему запрос WHERE created_at::date = current_date медленный и как починить?
О: Каст к date применяется к колонке — индекс по created_at неприменим, выражение к тому же non-immutable. Переписать на sargable range: created_at >= current_date AND created_at < current_date + 1, тогда B-tree используется.
В: Когда BRIN лучше B-tree и почему он такой маленький? О: BRIN хранит только min/max на диапазон блоков, а не запись на строку — отсюда крошечный размер. Лучше B-tree на огромных таблицах с физической корреляцией (append-only по времени), где допустимы recheck и не нужны точечные/уникальные поиски. При перемешанных данных бесполезен.
В: Как безопасно добавить индекс на большую боевую таблицу и потом бороться с его bloat?
О: CREATE INDEX CONCURRENTLY (вне транзакции, не блокирует запись; при сбое дропнуть INVALID и повторить). Для де-bloat — REINDEX INDEX CONCURRENTLY. Следить за pg_stat_user_indexes.idx_scan, чтобы выявлять неиспользуемые индексы.
В: Почему партиальный уникальный индекс часто лучше обычного UNIQUE при soft delete?
О: CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL обеспечивает уникальность только среди живых строк, позволяя переиспользовать «занятые» значения после удаления, и при этом индекс компактнее и дешевле.
На что копают на senior+#
- Чтение
EXPLAIN (ANALYZE, BUFFERS)вживую: отличить оценку от факта, увидетьRows Removed by Filter,Heap Fetches,Bitmap, понять почему выбран Seq Scan (правильно ли это). - Глубокое понимание MVCC ↔ индексы: TID на версию, write amplification, HOT, роль visibility map в index-only scan, причины bloat и стратегия vacuum/autovacuum (
autovacuum_vacuum_scale_factor, cost limits). - Проектирование составного индекса под конкретный набор запросов: equality-слева/range-справа, обслуживание
ORDER BY ... LIMITбез Sort, выбор между ключом и INCLUDE. - Статистика и оценка кардинальности:
pg_stats,n_distinct,most_common_vals, расширенная статистикаCREATE STATISTICSдля коррелированных колонок (функциональные зависимости, ndistinct). - Sargability: умение превращать несаргируемые предикаты (функции, касты, leading wildcard, OR) в индексируемые; знание
pg_trgmдля подстрок. - Эксплуатация:
CREATE/REINDEX CONCURRENTLY, lock-семантика DDL, миграции без даунтайма, борьба с дублирующими/неиспользуемыми индексами, влияние collation/glibc на корректность B-tree. - Trade-off запись vs чтение: fillfactor, fastupdate для GIN, выбор класса операторов (
jsonb_path_opsvsjsonb_ops,text_pattern_ops), partial-индексы для сужения write-стоимости. - Понимание, почему «добавить индекс» не всегда решение: иногда нужно переписать запрос, нормализовать тип параметра в Go, или сменить план через статистику.