Модуль: Базы данных · Уровень: 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-treeHash
Операторы=, <, >, 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_trgmLIKE '%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 = 3a,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, без Sort

PG также умеет 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 Scan

Visibility 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-индекса) — это бонус для оценки селективности нестандартных предикатов.


Когда индекс НЕ используется#

  1. Низкая селективность. Если условие отбирает большую долю таблицы (часто > ~5-10%), Seq Scan дешевле случайных чтений. Пример: WHERE is_active = true, когда 95% активны. Это правильное решение планировщика, а не баг.
  2. Функция/каст над колонкой. WHERE lower(email) = ... не использует индекс по email (нужен expression-индекс). WHERE date(created_at) = '...' ломает индекс по created_at — переписывай на range created_at >= d AND created_at < d+1.
  3. Type mismatch / неявный каст. Колонка varchar, а сравнение с числом; или bigint-колонка сравнивается с text-параметром. Каст применяется к колонке → индекс не годится. В Go это часто результат биндинга не того типа.
  4. OR по разным колонкам. WHERE a = 1 OR b = 2 может не дать индексного плана по одному индексу; решение — два индекса + BitmapOr, либо переписать через UNION.
  5. Leading wildcard. LIKE '%abc' или LIKE '%abc%' не использует обычный B-tree (нет префикса) — нужен pg_trgm GIN/GiST.
  6. Устаревшая статистика. После массовой загрузки без ANALYZE планировщик ошибается в оценке строк. Лечится ANALYZE table; и autovacuum-настройками.
  7. NULL-семантика. Стандартный B-tree индексирует NULL, но некоторые операции (<> , NOT IN) плохо ложатся на индекс.
  8. Маленькая таблица. 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 берёт SHARE lock — блокирует запись в таблицу на всё время сборки. В проде почти всегда 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=3a и 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_ops vs jsonb_ops, text_pattern_ops), partial-индексы для сужения write-стоимости.
  • Понимание, почему «добавить индекс» не всегда решение: иногда нужно переписать запрос, нормализовать тип параметра в Go, или сменить план через статистику.