B-tree индексы, Seq Scan и covering

О лекции

B-tree — индекс по умолчанию в PostgreSQL. Seq Scan не ошибка: на малой таблице или при низкой селективности он дешевле. Covering-индекс с INCLUDE даёт шанс на Index Only Scan.

PostgreSQL postgres собеседование

Индекс — отдельная структура рядом с таблицей. Он помогает найти нужные строки быстрее, чем читать всё подряд. Цель не «чтобы в плане был Index Scan», а чтобы запрос стоил дешевле.

Seq Scan — прочитать таблицу с начала до конца. Как листать книгу без оглавления. На маленькой книге это нормально. На толстой — уже нет.

B-tree — индекс по умолчанию в PostgreSQL. Точнее, это B+tree: сами ключи лежат в листьях, внутренние узлы только направляют поиск. Лист — страница около 8 КБ. На большой таблице высота дерева обычно 3–4 уровня. Поиск по ключу — несколько чтений страниц, а не проход по всей куче.

Пример. Таблица users на 80 строк. WHERE id = 7. Планировщик почти наверняка выберет Seq Scan: прочитать 80 строк подряд дешевле, чем спускаться по дереву и прыгать в таблицу.

B-tree хорошо закрывает равенство, диапазон, ORDER BY по префиксу ключа и IS NULL. Hash-индекс умеет только равенство и на практике нужен редко. GiST, GIN, BRIN — другие семейства: геометрия, полнотекст, JSONB, широкие append-only таблицы. С B-tree начинают.

Как устроен B-tree

Корень и внутренние страницы говорят, куда идти. В листьях лежат ключи и указатели на строки в куче. Указатель называется ctid: номер страницы и слот. Листья связаны между собой. Поэтому диапазон created_at > $1 после первой позиции читается почти подряд по индексу.

Дальше часто нужен прыжок в кучу за остальными колонками. Эти прыжки — случайный I/O. Если условие слабое и строк много, Index Scan читает почти всю таблицу, но ещё и вразброс. Тогда Seq Scan выигрывает: один последовательный проход.

Пример. Индекс по status, а status = 'active' у 80% строк. Индекс найдёт почти все строки и будет прыгать в кучу. Дешевле прочитать таблицу с начала до конца.

Когда планировщик выбирает Seq Scan

Планировщик считает стоимость. Если Seq Scan дешевле — он его возьмёт. Это не баг и не доказательство, что «индекса нет».

  • Таблица крошечная: один проход страниц дешевле, чем спуск по дереву плюс обращения в кучу.
  • Предикат слабо селективен: условие отсекает мало строк. Селективность — доля строк, которая подходит под условие. Мало строк — индекс любит. Много — Seq Scan.
  • Нет подходящего индекса: функция на колонке, неявный cast типов, LIKE '%foo' с ведущим вайлдкардом.
  • Статистика врёт: ANALYZE давно не бегал. Планировщик думает, что вернётся 10 строк, а вернётся миллион.

Оценка селективности живёт в pg_stats: MCV (самые частые значения), гистограммы, n_distinct. Без свежего ANALYZE лучший индекс могут не взять.

Covering и Index Only Scan

Covering-индекс покрывает запрос: все нужные колонки уже есть в индексе. Тогда возможен Index Only Scan — ответ из индекса, без похода в кучу за каждой строкой. Колонки могут быть ключом или лежать в INCLUDE.

Ещё нужна visibility map (карта видимости). Если страница кучи помечена all-visible, Postgres знает: все версии на странице видны всем и в кучу ходить не надо. После свежих UPDATE карта ещё не прогрета. Узел в плане всё равно называется Index Only Scan, но появляются Heap Fetches — прыжки в кучу. Выигрыш тает.

Пример. Запрос SELECT status, total FROM orders WHERE user_id = 42. Индекс (user_id) INCLUDE (status, total) покрывает и фильтр, и колонки SELECT. Обычный индекс только по user_id всё равно потащит status и total из кучи.

INCLUDE кладёт колонки в лист, но не в порядок ключа. Индекс остаётся узким для поиска и при этом покрывает SELECT. Цена как у любого индекса: шире лист — больше места, медленнее INSERT и UPDATE этих колонок.

CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);
CREATE INDEX idx_orders_user_cov ON orders (user_id) INCLUDE (status, total);

-- WHERE lower(email) = 'a@b.c' — обычный btree по email не возьмётся
CREATE INDEX idx_users_email_lower ON users (lower(email));

EXPLAIN (ANALYZE, BUFFERS)
SELECT status, total
FROM orders
WHERE user_id = 42;

Обычный составной индекс, покрывающий индекс с INCLUDE и функциональный индекс под lower(email).

Первый индекс закрывает фильтр по пользователю и сортировку по времени. Второй позволяет не таскать status и total из кучи. Третий нужен потому, что выражение в WHERE и выражение в индексе должны совпасть. B-tree по email не знает про lower(email).

Цена индекса на запись

Каждый лишний индекс ускоряет часть SELECT и замедляет INSERT, UPDATE, DELETE и VACUUM: нужно поддерживать дополнительные листья. Случайный UUID как первичный ключ размазывает вставки по всему дереву — page splits и bloat (раздувание файла дырами). Монотонный ключ (bigserial, ULID с временем) пишет в «правый» лист и ведёт себя предсказуемее. Горячая правая страница — отдельный спор про конкуренцию.

Типичная ошибка

Считать Seq Scan доказательством, что «индекса нет». Планировщик берёт seq scan, когда это дешевле. Другая крайность — накрыть таблицу десятью индексами «на всякий SELECT» и удивиться, что запись и autovacuum стали медленными.

На собеседовании

Seq Scan — прочитать таблицу с начала до конца. Планировщик берёт его, когда это дешевле, а не когда индекса нет. Дальше — селективность, random I/O против sequential, Index Only Scan и visibility map. Если останется время — UUID как PK и цена лишнего индекса на запись.