6.4Индексы
Научись оценивать индекс как отдельную структуру с ценой чтения, записи и хранения. Разбери B-tree, составные и покрывающие индексы и ситуации, когда планировщик выбирает последовательное чтение, хотя индекс существует.
Индекс - это оглавление, а не вторая копия таблицы
Чтобы найти в книге слово «MVCC», можно листать её подряд с первой страницы. А можно открыть
предметный указатель в конце, найти там «MVCC - стр. 214» и сразу перейти. Указатель занимает
место, его печатают вместе с книгой и переделывают при каждой правке текста - зато поиск
сжимается с «прочитать 500 страниц» до «прочитать одну». Индекс в базе устроен так же:
это отдельная структура, где значения колонки лежат по порядку, а рядом с каждым
значением записан адрес строки в таблице (в PostgreSQL это ctid).
Аналогия держится почти до конца и ломается в одном месте: указатель в книге печатают один раз,
а индекс в базе живёт вместе с данными. Каждый INSERT и каждый UPDATE
индексируемой колонки обязан дописать в него запись, и её потом придётся чистить вакуумом.
Поэтому «повесить индекс на всякий случай» совсем не бесплатно.
Выгоден индекс только при высокой селективности условия, то есть когда под него попадает малая доля строк. Если под условие попадает 30 % таблицы, то сходить в индекс, а потом за каждой найденной строкой отдельно читать случайную страницу выйдет дороже, чем прочитать всю таблицу подряд. Планировщик это считает и сам выбирает второе. Так объясняется большинство ситуаций «индекс есть, а база его не использует».
Зачем нужна именно древовидная структура
Без индекса база ищет по условию через Seq Scan: читает таблицу целиком, страницу за страницей, и на каждой строке проверяет условие. На 100 млн строк по 200 байт это 20 ГБ чтения с диска на каждый запрос - даже если в ответе одна строка.
Почему не бинарное дерево поиска, ведь у него та же сложность O(log n)? Потому что O(log n) считает сравнения, а платим мы за обращения к диску, и каждое читает целую страницу в 8 КБ. У бинарного дерева на 100 млн элементов высота ~27, а каждый узел лежит на отдельной странице: 27 случайных чтений. В страницу B-tree помещается несколько сотен ключей, поэтому дерево выходит широким и низким, всего 3–4 уровня.
Это B+-дерево: значения хранятся только в листьях, внутренние узлы содержат
разделители. Листья связаны в двусвязный список, поэтому BETWEEN,
>, < и ORDER BY обслуживаются одним проходом.
Дерево сбалансировано по определению: все листья на одной глубине. Внутри работает алгоритм
Lehman-Yao: операции не блокируют всё дерево, вставки идут конкурентно. С PostgreSQL 13 есть
дедупликация: повторяющиеся значения хранятся один раз со списком ctid, и индексы
по колонкам с низкой уникальностью резко худеют. Индексная запись не хранит информацию
о видимости, поэтому после спуска по дереву обычно всё равно приходится заглянуть в heap,
и обойтись без этого может только index-only scan через visibility map.
Типы индексов в PostgreSQL
| Тип | Структура | Операторы | Под что | Когда не подходит |
|---|---|---|---|---|
| B-tree | сбалансированное дерево | =, <, >, BETWEEN, IN, LIKE 'abc%', IS NULL, сортировка | дефолт для скалярных типов: числа, даты, строки | массивы, JSON, полнотекст, геометрия |
| Hash | хеш-таблица | только = | очень длинные ключи при поиске строго по равенству | любые диапазоны и сортировка; почти всегда проигрывает B-tree |
| GIN | инвертированный индекс | @>, ?, &&, @@ | jsonb, массивы, полнотекстовый поиск, pg_trgm для LIKE '%x%' | частые точечные обновления: запись дороже (смягчается fastupdate) |
| GiST | обобщённое сбалансированное дерево | &&, <->, @> | геометрия и PostGIS, диапазоны (tsrange), поиск ближайших (KNN), EXCLUDE-констрейнты | простое равенство по скаляру |
| SP-GiST | несбалансированные разбиения: quad-tree, k-d tree, radix | как GiST | данные с естественной иерархией: точки, IP-префиксы (inet), телефоны, текстовые префиксы | равномерно «плоские» данные |
| BRIN | сводки по диапазонам блоков (min/max) | <, >, BETWEEN | огромные таблицы с физически упорядоченными данными: логи и события по created_at | если порядок вставки не совпадает с порядком значений - бесполезен |
BRIN хранит не значения, а для каждой группы страниц (по умолчанию 128) минимум и максимум.
Поэтому индекс на таблицу в 500 ГБ занимает единицы мегабайт - против десятков гигабайт
у B-tree. При запросе он отбрасывает целые диапазоны блоков, где искомого
значения быть не может. Но условие жёсткое: физический порядок строк должен коррелировать со
значением колонки. Для таблицы событий, куда пишут строго по времени, это выполняется
идеально. Для колонки status, разбросанной по всей таблице, BRIN бесполезен.
Корреляцию показывает pg_stats.correlation: если она близка к 1 или -1,
BRIN сработает.
-- jsonb: два класса операторов GIN
CREATE INDEX ON docs USING gin (data); -- полный набор: @>, ?, ?|, ?&, @?, @@
CREATE INDEX ON docs USING gin (data jsonb_path_ops); -- @>, @? и @@; заметно компактнее и быстрее
SELECT * FROM docs WHERE data @> '{"type": "invoice"}';
-- Полнотекстовый поиск
CREATE INDEX ON articles USING gin (to_tsvector('russian', body));
SELECT * FROM articles WHERE to_tsvector('russian', body) @@ plainto_tsquery('russian', 'база данных');
-- Поиск подстроки в середине: обычный индекс не поможет, нужен триграммный
CREATE EXTENSION pg_trgm;
CREATE INDEX ON users USING gin (email gin_trgm_ops);
SELECT * FROM users WHERE email LIKE '%gmail%';
-- Диапазоны без пересечений: GiST + EXCLUDE
CREATE EXTENSION btree_gist; -- иначе GiST не поймёт room_id WITH = для int
CREATE TABLE bookings (
room_id int,
period tsrange,
EXCLUDE USING gist (room_id WITH =, period WITH &&)
);
-- BRIN на таблицу логов: индекс на гигабайты данных весит мегабайты
CREATE INDEX events_created_brin ON events USING brin (created_at) WITH (pages_per_range = 64);
Составной индекс и правило левого префикса
Составной (он же многоколоночный) индекс (a, b, c) хранится как B-tree,
отсортированное сначала по a, затем внутри одинаковых a по b
и, наконец, внутри одинаковых пар по c. Так устроен телефонный справочник: сначала
город, потом улица, потом дом. Всех жителей Арбата по нему не найти, если не знаешь города, -
придётся листать справочник целиком.
Левым префиксом называют первые N колонок индекса, взятые подряд слева, без пропусков.
У (a, b, c) их три: (a), (a, b) и
(a, b, c). А вот (b), (c) и (a, c)
префиксами не считаются. Правило левого префикса говорит: индекс годится для поиска, только если
условия запроса покрывают какой-нибудь его левый префикс. Причина та же, что со
справочником: записи отсортированы в первую очередь по a, и без
a область поиска в отсортированном списке не сузить.
(a, b, c) обслуживает запросы по
(a), (a, b) и (a, b, c), но искать по
(b) или (c) отдельно не помогает. Поэтому три отдельных индекса и один
составной ведут себя совершенно по-разному.- Сначала колонки из условий равенства (
=,IN), потом колонки из диапазонов (>,BETWEEN), потом колонки изORDER BY. Причина: после первого диапазонного условия дальнейшие колонки индекса уже не сужают поиск, а только фильтруют. - Среди равенств первыми идут более селективные, если запросы разные; если запрос всегда один и тот же, порядок среди равенств почти не важен.
- Учитывай направление сортировки: для
ORDER BY a, b DESCнужен индекс(a, b DESC), иначе получишь дополнительную Sort. - Один составной индекс обычно лучше трёх отдельных: PostgreSQL умеет объединять несколько индексов через BitmapAnd, но это дороже одного прохода.
-- Запрос
SELECT * FROM orders
WHERE customer_id = $1 AND status = 'paid' AND created_at >= $2
ORDER BY created_at DESC
LIMIT 20;
-- Правильный индекс: равенства первыми, диапазон + сортировка последними
CREATE INDEX orders_cust_status_created_idx
ON orders (customer_id, status, created_at DESC);
-- Такой индекс отдаёт сразу 20 строк в нужном порядке: ни Sort, ни лишних чтений.
Покрывающие индексы и index-only scan
Обычный поиск по индексу идёт в два шага: найти в индексе адреса подходящих строк, а потом сходить по этим адресам в саму таблицу и прочитать строки. Второй шаг рассыпается на случайные чтения страниц и часто обходится в разы дороже первого.
Покрывающий индекс содержит все колонки,
нужные конкретному запросу: и те, что в WHERE, и те, что в SELECT.
Второй шаг тогда не нужен, ответ целиком лежит в индексе. Так и работает
index-only scan.
Но в PostgreSQL index-only scan срабатывает не всегда: в индексной
записи нет xmin/xmax, и по ней не понять, видна ли строка
этой транзакции. Спасает та самая карта видимости из главы про MVCC: если в её бите
сказано «в этой странице таблицы все версии видны всем», проверять нечего и в таблицу идти
не нужно. Если бит сброшен (страницу давно не вакуумировали) - придётся идти. Выходит, что
index-only scan работает ровно настолько, насколько успевает autovacuum.
EXPLAIN ANALYZE его выдаёт строка Heap Fetches: 0.
Большое число там означает, что visibility map устарела и нужен VACUUM.-- INCLUDE: колонка лежит только в листьях, в дереве не участвует.
-- Не раздувает внутренние узлы и не влияет на уникальность.
CREATE INDEX orders_cust_idx ON orders (customer_id) INCLUDE (status, total);
SELECT status, total FROM orders WHERE customer_id = 42; -- Index Only Scan
-- Частичный индекс: индексируем только то, что реально ищут
CREATE INDEX orders_new_idx ON orders (created_at) WHERE status = 'new';
-- Таблица 200 млн строк, статус new у 5 тысяч → индекс на 5 тысяч записей вместо 200 млн
-- Функциональный индекс: под запрос с функцией над колонкой
CREATE INDEX users_lower_email_idx ON users (LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'a@b.c'; -- попадёт
SELECT * FROM users WHERE email = 'a@b.c'; -- не попадёт, нужен обычный индекс
-- Уникальность только среди живых записей: UNIQUE-констрейнтом не выразить, частичным индексом - да
CREATE UNIQUE INDEX users_email_active_uniq ON users (email) WHERE deleted_at IS NULL;
Когда индекс не используется
| Ситуация | Почему | Что делать |
|---|---|---|
WHERE LOWER(email) = ..., WHERE date(ts) = ... | индекс хранит значения колонки, а не результат функции | функциональный индекс либо переписать условие в диапазон по колонке |
WHERE id::text = '5', сравнение int с bigint-параметром | неявное приведение типа = функция над колонкой | привести параметр к типу колонки, а не наоборот |
LIKE '%abc%' | B-tree ищет по префиксу, а тут его нет | pg_trgm + GIN, либо полнотекстовый поиск |
LIKE 'abc%' в базе не с C-локалью | порядок сортировки не совпадает с побайтовым | индекс с text_pattern_ops |
Низкая селективность (status = 'active' у 80 % строк) | Seq Scan дешевле: случайные чтения дороже последовательных | это правильное поведение; при необходимости - частичный индекс на редкое значение |
| Маленькая таблица | вся таблица помещается в пару страниц | ничего, всё в порядке |
| Устаревшая статистика | планировщик оценивает по pg_statistic | ANALYZE, поднять default_statistics_target |
OR по разным колонкам | один индекс не покрывает оба условия | переписать через UNION ALL или полагаться на BitmapOr |
ORDER BY a, b DESC при индексе (a, b) | направление сортировки не совпало | индекс (a, b DESC) |
| Индекс только что создан | нет статистики | ANALYZE после CREATE INDEX |
Каждый индекс держит вторую копию данных, которую надо поддерживать в согласованном
состоянии на каждой записи. Цена такая: (1) INSERT и
DELETE обновляют все индексы таблицы; (2) UPDATE, задевший индексируемую колонку (кроме колонок только под BRIN), ломает HOT-оптимизацию и добавляет записи во все индексы;
(3) растёт объём WAL и, соответственно, трафик репликации; (4) индексы занимают диск и место
в кэше, вытесняя оттуда полезные данные; (5) VACUUM обходит каждый индекс. На практике
таблица с 12 индексами пишется в разы медленнее той же таблицы с тремя. Правильнее сказать
так: индексы разменивают скорость записи на скорость чтения, и под каждый индекс
должен найтись конкретный запрос.
-- Неиспользуемые индексы: idx_scan = 0 за всё время наблюдения
SELECT s.relname AS table, s.indexrelname AS index,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size,
s.idx_scan
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
AND NOT i.indisunique -- уникальные не трогаем: они держат констрейнт
AND NOT i.indisprimary
ORDER BY pg_relation_size(s.indexrelid) DESC;
-- Дублирующие индексы: одинаковый набор колонок (префиксы этот запрос не ловит)
SELECT indrelid::regclass, array_agg(indexrelid::regclass)
FROM pg_index
GROUP BY indrelid, indkey
HAVING count(*) > 1;
-- Создаём индекс без блокировки записи (на проде только так)
CREATE INDEX CONCURRENTLY orders_customer_id_idx ON orders (customer_id);
-- Проверить, что не остался невалидным после сбоя:
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
- Индекс создаётся под запрос, а не «на всякий случай для колонки».
- Перед созданием смотришь
EXPLAIN, после создания сноваEXPLAINплюсANALYZEтаблицы. - На проде только
CREATE INDEX CONCURRENTLYи только вне транзакционного блока. - Составной индекс
(a, b)делает индекс(a)лишним, его можно удалить. - Раз в квартал проверяй
pg_stat_user_indexesнаidx_scan = 0. - На горячей пишущей таблице лишний индекс стоит дороже, чем кажется: он бьёт и по WAL, и по репликации.
Вопросы
11Что физически лежит в индексе
В листьях B-tree лежат пары «ключ → ctid», где ctid хранит пару
(номер страницы, номер слота внутри страницы), то есть физический адрес версии строки.
Внутренние узлы содержат только разделители-ключи и ссылки на дочерние страницы. Вдобавок
листья связаны в двусвязный список, поэтому индекс умеет отдавать данные
уже отсортированными и обслуживать диапазонные условия BETWEEN,
>, < одним проходом вправо по листьям.
Арифметика, которую хотят услышать
Страница 8 КБ, ключ bigint + ctid ≈ 16 байт с накладными расходами, и на
страницу влезает порядка 400 записей. Три уровня дают 400³ ≈ 64 млн ключей, четыре - 25 млрд.
Значит, чтобы найти конкретное значение в таблице на 100 млн строк, хватит 4 чтений страниц,
причём верхние уровни почти всегда уже в shared_buffers, то есть реальных обращений к диску
обычно 1–2. Против 20 ГБ последовательного чтения при Seq Scan.
Почему бинарное дерево проигрывает
- Ветвление 2 против ~400, отсюда высота 27 против 4.
- Узлы бинарного дерева разбросаны по произвольным местам, отсюда случайные чтения, самые дорогие для диска.
- B-tree сбалансировано по построению: все листья на одном уровне, худший случай равен среднему.
У обычного BST при вставке возрастающих ключей (а это типичный
id) дерево вырождается в список.
Что происходит при вставке
Значение попадает в нужный лист; если лист переполнен, случается split: страница делится пополам, разделитель поднимается в родителя, при переполнении родителя split идёт вверх, и в пределе растёт высота дерева. Отсюда и цена записи: вставка в индекс не сводится к «дописать в конец», это поиск + возможная перестройка страниц + запись в WAL.
Монотонный ключ (bigserial, время) всегда попадает в самый правый лист:
дерево растёт «в край», split случается редко и трогает одну страницу, а горячих страниц
в памяти всего несколько. Случайный UUIDv4 попадает в случайный лист, поэтому рвёт
страницы по всему индексу сразу: split-ов на порядок больше, в буферах приходится держать
весь индекс целиком, и каждый разрыв страницы вдобавок тянет за собой full-page write
в WAL. Плюс 16 байт против 8 у bigint - и этот размер повторяется в каждом
вторичном индексе.
Лечится это UUIDv7: первые 48 бит в нём занимает время в миллисекундах, поэтому такие
идентификаторы сортируются по времени и ложатся в правый край не хуже
bigserial. Главное свойство UUID остаётся: их генерирует клиент, без
похода в базу. В PostgreSQL 18 есть встроенная функция uuidv7(); в
Go с версии 1.27 генератор лежит прямо в стандартной библиотеке - пакет
uuid по RFC 9562: uuid.NewV7(), uuid.NewV4(),
uuid.New(), uuid.Parse, а тип uuid.UUID
объявлен как [16]byte. До 1.27 для этого брали внешний
github.com/google/uuid.
«В PostgreSQL это B-tree по алгоритму Lehman–Yao: у страниц есть right-link, и читатели идут по дереву без блокировки всего пути, пока писатель делает split. С Postgres 13 у B-tree есть дедупликация повторяющихся ключей: она заметно уменьшает индексы по неуникальным колонкам и работает даже в уникальных индексах, где сдерживает раздувание от мёртвых версий одной и той же строки.»
Разбор по типам
- B-tree берёт всё, что имеет порядок:
=,<,>,BETWEEN,IN,IS NULL, префиксныйLIKE 'abc%', а такжеORDER BYиMIN/MAXбез сортировки. Уникальность и первичные ключи умеет только он. - Hash понимает только
=: ни сортировки, ни диапазонов, ни уникальности. С PG 10 он пишется в WAL (до этого не переживал крэш и не реплицировался), но выигрыш перед B-tree мал, и берут его редко - разве что для очень длинных строк, где хеш заметно компактнее. - GIN нужен, когда в одном значении много элементов: массивы
(
@>,&&),jsonb(@>,?), полнотекстовый поиск (tsvector @@ tsquery), подстроки черезpg_trgm. Внутри B-tree по элементам, а в листьях сжатые списки ctid. Читается быстро, пишется медленно; это смягчают настройкиfastupdateиgin_pending_list_limit. - GiST - обобщённое дерево поиска для данных, где «ключом» служит область: геометрия PostGIS,
диапазоны
tsrange/int4rangeс оператором пересечения&&, ближайшие соседи (ORDER BY point <-> point), а такжеEXCLUDE-констрейнты (например, «брони не пересекаются»). - SP-GiST делит пространство (quad-tree, k-d tree, radix): точки, IP-сети
inet, префиксный поиск по строкам. Хорош там, где данные неравномерны. - BRIN хранит min/max по диапазону страниц (по умолчанию 128),
и индекс на терабайтную таблицу занимает мегабайты. Работает, только если физический порядок
строк коррелирует со значением колонки. Классический случай:
created_atв append-only логах и метриках.
Как выбирать на собеседовании
Вопрос закрывает такой ответ: «сначала спрашиваю, какой оператор в
WHERE. Оператор определяет класс индекса: под равенство и диапазон B-tree,
под «содержит» GIN, под «пересекается» / «ближайший» GiST, под «строки идут по возрастанию
и таблица огромная» BRIN». Проверить, что оператор реально поддерживается, можно через
pg_amop или просто по EXPLAIN.
jsonb с jsonb_path_ops заметно меньше и быстрее дефолтного
jsonb_ops, но не поддерживает проверку наличия ключа (?, ?|, ?&): работают только @>, @? и @@. А BRIN на таблице, куда
пишут в случайном порядке (или после массового UPDATE, разбросавшего строки),
бесполезен: диапазоны страниц перекрываются, и приходится читать почти всё.
Корреляцию видно в pg_stats.correlation.
Почему не B-tree
Запрос «найди всё в радиусе 2 км» или «какие полигоны пересекают этот прямоугольник» не
сводится к сравнению по одной оси. Отсортировать точки по x не помогает: близкие по обеим
координатам точки окажутся в разных концах индекса. Нужна структура, которая группирует объекты
по пространственной близости.
Как работает GiST на геометрии
В узлах хранятся ограничивающие прямоугольники (MBR, bounding box) дочерних объектов.
Поиск спускается только в те ветки, чей прямоугольник пересекается с искомой областью.
Получается R-tree, реализованное поверх обобщённого GiST. Результат приблизительный по bbox, поэтому
PostGIS делает двухфазный поиск: индекс отдаёт кандидатов по bbox, затем точный предикат
(ST_Intersects, ST_DWithin) отсеивает лишнее.
CREATE EXTENSION postgis;
ALTER TABLE places ADD COLUMN geom geography(Point, 4326);
CREATE INDEX places_geom_idx ON places USING GIST (geom);
-- Всё в радиусе 2 км: ST_DWithin умеет пользоваться индексом
SELECT id, name FROM places
WHERE ST_DWithin(geom, ST_MakePoint($1, $2)::geography, 2000);
-- Ближайшие 10 (KNN, оператор расстояния идёт прямо в индекс)
SELECT id FROM places ORDER BY geom <-> ST_MakePoint($1, $2)::geography LIMIT 10;
Варианты
- SP-GiST (quad-tree) на чистых точках бывает быстрее и компактнее GiST.
- BRIN подойдёт гигантским таблицам, если данные физически сгруппированы по географии (например, загружены по регионам).
- Геохеш / H3 в отдельной колонке + обычный B-tree обходятся без PostGIS: координату кодируют в строку/число так, чтобы у близких точек был общий префикс, и ищут по префиксу. Работает, но на границах ячеек теряет близкие точки, их добирают из соседних ячеек.
geometry считает на плоскости (быстро, но расстояния в градусах или в проекции),
geography на сфероиде (медленнее, зато ST_DWithin сразу в метрах).
Для «радиус в километрах по всей стране» берут geography; для локальных задач хватит
geometry в подходящей метрической проекции.
(a, b, c),
поэтому эффективно ищет только по левому префиксу: a, a+b,
a+b+c. Порядок колонок: сначала равенства, потом диапазон/сортировка.Почему это следует из устройства
Ключи в индексе отсортированы сначала по a, внутри одинаковых a -
по b, внутри одинаковых b - по c. Как телефонный
справочник по «фамилия, имя, отчество»: всех Ивановых найти легко, а всех Петровичей -
только полным перебором.
Запрос при индексе (a, b, c) | Что будет |
|---|---|
WHERE a = 1 | полноценный Index Scan |
WHERE a = 1 AND b = 2 | полноценный, ещё уже диапазон |
WHERE a = 1 AND b = 2 AND c = 3 | идеальный случай |
WHERE a = 1 AND c = 3 | по a - по индексу, c проверяется фильтром на найденных строках |
WHERE b = 2 | обычно Seq Scan; изредка Index Only Scan с полным проходом индекса, если он сильно меньше таблицы |
WHERE a > 10 AND b = 2 | по индексу отработает только a: после диапазона порядок по b теряется |
ORDER BY a, b | сортировка бесплатна |
ORDER BY b, a | придётся сортировать |
Правило порядка колонок
- Сначала колонки с равенством (
=,IN): они сужают поиск до непрерывного участка индекса. - Затем колонка диапазона (
>,BETWEEN). Полезной она бывает только одна и только последней среди «поисковых». - Затем колонки сортировки, в том же направлении, что в
ORDER BY. - Селективность внутри группы равенств вторична: она влияет на размер индекса и на то, сколько других запросов он покроет, но не на то, применим ли он вообще.
«Равенства → диапазон → сортировка». Если помнить только это правило, 80 % задач на
составные индексы решаются с листа. И второе: индекс (a, b) делает индекс
(a) избыточным - отдельный индекс по первой колонке можно удалить,
сэкономив на записи.
Совет «ставь самую селективную колонку первой» популярен, но неточен. Если самая селективная колонка участвует в запросе диапазоном, а менее селективная - равенством, правильный порядок обратный. Проверять надо планом, а не эвристикой.
INCLUDE добавляет колонки только
в листья индекса: они доступны для чтения, но не участвуют в поиске, сортировке и уникальности.Почему индексу всё-таки обычно нужна таблица
В индексной записи нет информации о видимости: там нет xmin/xmax.
Индекс не знает, видит ли текущая транзакция конкретную версию строки, поэтому обычный Index Scan
обязан сходить в heap и проверить заголовок кортежа. Обойти это помогает visibility map -
битовая карта, где взведённый бит означает «все версии на этой странице видны всем транзакциям».
Если бит взведён, идти в heap не нужно.
Условия index-only scan
- Все колонки из
SELECT,WHERE,ORDER BYесть в индексе. - Свежий VACUUM: visibility map актуальна.
- В плане это видно как
Heap Fetches: 0. Большое число здесь означает, что формально index-only scan выбран, а фактически мы всё равно ходим в таблицу.
INCLUDE против добавления колонки в ключ
-- Вариант 1: колонка в ключе участвует в поиске и сортировке, но раздувает внутренние узлы
CREATE INDEX i1 ON orders (customer_id, total);
-- Вариант 2: с INCLUDE total лежит только в листьях
CREATE INDEX i2 ON orders (customer_id) INCLUDE (total);
-- Для уникальных индексов разница такая:
CREATE UNIQUE INDEX u ON users (email) INCLUDE (name);
-- уникальность по email, а name просто «едет» в листе.
-- Если бы написали (email, name), уникальной была бы пара, а это другая семантика.
INCLUDE (с PG 11) выгоден, когда колонка нужна только для чтения: она не увеличивает
внутренние узлы дерева, значит высота и ветвление не страдают, а типы могут быть даже такими,
для которых нет операторов сравнения B-tree.
Типичный кейс: агрегат по большой таблице вроде SELECT sum(total) FROM orders WHERE
customer_id = 42. С обычным индексом это 5000 случайных чтений heap, с покрывающим -
пара страниц индекса. На проде это выливается в миллисекунды против секунд.
WHERE
(меньше размер, быстрее запись); функциональный индексирует результат выражения, что позволяет
попасть в индекс запросам вида WHERE lower(email) = ....Частичный индекс
-- Мягкое удаление: 99 % запросов ходят только за живыми записями
CREATE INDEX users_active_idx ON users (created_at) WHERE deleted_at IS NULL;
-- Очередь: строк в статусе pending всегда мало, а таблица огромная
CREATE INDEX jobs_pending_idx ON jobs (run_at) WHERE status = 'pending';
-- Уникальность только среди живых: UNIQUE-констрейнтом не выразить, частичным индексом - да
CREATE UNIQUE INDEX users_email_uniq ON users (email) WHERE deleted_at IS NULL;
Планировщик использует частичный индекс, только если может доказать, что предикат запроса
влечёт предикат индекса. То есть WHERE status = 'pending' попадёт, а
WHERE status = $1 с параметром, как правило, нет: на момент планирования
значение неизвестно (спасает generic vs custom plan, но полагаться на это нельзя).
Функциональный индекс
CREATE INDEX users_lower_email_idx ON users (lower(email));
SELECT * FROM users WHERE lower(email) = lower($1); -- попадёт
CREATE INDEX events_day_idx ON events ((created_at::date));
CREATE INDEX orders_meta_client_idx ON orders ((meta->>'client_id'));
Есть ограничение: выражение должно быть IMMUTABLE. Поэтому
CREATE INDEX ... (created_at AT TIME ZONE 'UTC') пройдёт, а
(now() - created_at) - нет: индекс, который меняется со временем, смысла не имеет.
Частичный + функциональный: CREATE INDEX ON users (lower(email)) WHERE deleted_at IS
NULL;. И ещё приём: вместо created_at::date = '2024-01-01' лучше писать
created_at >= '2024-01-01' AND created_at < '2024-01-02' - тогда хватит
обычного индекса по колонке, без функционального.
ON CONFLICT ON CONSTRAINT; индекс гибче - умеет
частичность, выражения и CONCURRENTLY.Что происходит при создании констрейнта
ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);
-- PostgreSQL молча создаёт индекс users_email_key и привязывает его к констрейнту.
-- DROP INDEX users_email_key; -- ошибка: индекс принадлежит констрейнту
| UNIQUE constraint | UNIQUE index | |
|---|---|---|
Частичный (WHERE) | нельзя | можно |
| По выражению | нельзя | можно |
| Цель для FOREIGN KEY | да | да (достаточно уникального индекса) |
ON CONFLICT ON CONSTRAINT name | да | нет, только ON CONFLICT (columns) |
| Создание без блокировки | нет (но можно в два шага) | CREATE UNIQUE INDEX CONCURRENTLY |
Отложенная проверка DEFERRABLE | да | нет |
Приём для прода
-- Добавить уникальность на живую таблицу без длинной блокировки:
CREATE UNIQUE INDEX CONCURRENTLY users_email_uniq ON users (email);
ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE USING INDEX users_email_uniq;
-- Второй шаг берёт короткую блокировку и просто «усыновляет» готовый индекс.
По стандарту NULL не равен NULL, поэтому уникальный индекс
разрешает сколько угодно строк с NULL в колонке. Классический баг мягкого удаления:
UNIQUE (email, deleted_at) не мешает вставить двух живых пользователей с одним
email, если у обоих deleted_at IS NULL. Лечится частичным уникальным индексом
или, с PG 15, синтаксисом UNIQUE NULLS NOT DISTINCT.
LIKE '%x%', не левый префикс), (2) планировщик считает Seq Scan дешевле
(низкая селективность, маленькая таблица), (3) он ошибается из-за устаревшей статистики.Условие не подходит структуре
- Функция над колонкой:
WHERE lower(email) = 'a@b.c',WHERE date(created_at) = '2024-01-01'. Индекс хранит значения колонки, а не результат функции. Лечится функциональным индексом или переписыванием условия в диапазон. - Неявный каст: колонка
varchar, а параметр приходит числом; илиWHERE id::text = $1. Для индекса каст ничем не отличается от функции. Правильно приводить параметр к типу колонки, а не наоборот. LIKE '%abc%': B-tree ищет по префиксу, а его тут нет. Нуженpg_trgm+ GIN. ДажеLIKE 'abc%'в базе не с C-локалью не попадёт в индекс безtext_pattern_ops.- Не левый префикс составного индекса (см. вопрос про составные индексы).
ORпо разным колонкам: одним индексом его не покрыть; иногда выручает BitmapOr по двум индексам, иногда переписывание вUNION ALL.ORDER BY a ASC, b DESCпри индексе(a, b): направления не совпали.
Индекс подходит, но он не выгоден
Если под условие попадает существенная доля таблицы (эмпирически больше 5–20 %), Index Scan проигрывает: он делает случайные чтения heap-страниц, а Seq Scan читает подряд, что на порядок дешевле, да ещё и пачками. Планировщик тут поступает правильно, и на собеседовании ждут ответа «здесь Seq Scan оптимален», а не «надо заставить индекс». Есть и промежуточный вариант, Bitmap Heap Scan: PostgreSQL собирает ctid в битовую карту, сортирует по номерам страниц и читает heap условно последовательно.
Планировщик ошибается
Оценки берутся из pg_statistic, который наполняет ANALYZE. После массовой
загрузки, после создания индекса, после смены распределения данных статистика врёт, и в плане
rows= и actual rows= расходятся на порядки. Лечение:
ANALYZE table, поднять default_statistics_target (по умолчанию 100)
для конкретной колонки, добавить CREATE STATISTICS для коррелированных колонок
(город и страна, товар и категория).
Проверить гипотезу «индекс есть, но не берётся из-за цены» можно так:
SET enable_seqscan = off; и снова EXPLAIN ANALYZE. Если с индексом
реально быстрее - дело в оценках стоимости: обычно виноват завышенный
random_page_cost (на SSD его снижают с 4.0 до 1.1) или заниженный
effective_cache_size. В прод так, разумеется, не катят, это только диагностика.
Конкретная цена
- INSERT / DELETE трогают все индексы таблицы: поиск места + возможный split страницы + запись в WAL по каждому индексу.
- UPDATE, задевший индексируемую колонку (кроме колонок только под BRIN), ломает HOT: вместо «новая версия в той же странице и индексы не трогаем» получаем записи во все индексы. Один лишний индекс по «горячей» колонке может удвоить стоимость обновления.
- WAL растёт → растёт трафик репликации, дольше идёт восстановление, больше нагрузки на диск.
- Место: индексы часто занимают больше самой таблицы. И они конкурируют с данными за shared_buffers и страничный кэш ОС - то есть вытесняют оттуда полезное.
- VACUUM обходит каждый индекс, чтобы вычистить ссылки на мёртвые версии; чем больше индексов, тем дольше и тяжелее autovacuum.
- Планировщик перебирает больше вариантов, и время планирования растёт (заметно на OLTP с тысячами простых запросов в секунду).
Как это звучит на собесе
«Индексы разменивают скорость записи на скорость чтения. Под каждый индекс должен быть конкретный запрос из прода, а не гипотеза. На горячей пишущей таблице я сначала считаю, сколько мы теряем на записи, и только потом добавляю». И частая ошибка рядом: добавили индекс, но не удалили тот, что стал из-за него избыточным.
«Поставим индексы на все колонки, участвующие в WHERE». На деле планировщик обычно берёт один индекс на таблицу в запросе (BitmapAnd бывает исключением, и он не бесплатен), поэтому пять одноколоночных индексов проигрывают одному правильному составному, а стоят впятеро дороже на записи.
pg_stat_user_indexes.idx_scan = 0 за длительный период
наблюдения - кандидат на удаление; плюс проверка на дубли по indkey и на индексы,
являющиеся префиксом других.-- 1. Ни разу не использованные, отсортированные по занимаемому месту
SELECT s.relname AS tbl,
s.indexrelname AS idx,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS sz,
s.idx_scan
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
AND NOT i.indisunique -- уникальные держат бизнес-инвариант
AND NOT i.indisprimary
ORDER BY pg_relation_size(s.indexrelid) DESC;
-- 2. Полные дубликаты: одинаковый набор колонок в одном порядке
SELECT indrelid::regclass AS tbl, array_agg(indexrelid::regclass) AS dupes
FROM pg_index GROUP BY indrelid, indkey, indpred, indexprs HAVING count(*) > 1;
-- 3. Когда сбросили статистику: без этого idx_scan = 0 ничего не значит
SELECT stats_reset FROM pg_stat_database WHERE datname = current_database();
Что обязательно оговорить
- Статистика копится с момента последнего сброса и только на этом узле. На реплике запросы другие: индекс, «неиспользуемый» на мастере, может активно работать на реплике для отчётов. Проверять надо на всех узлах.
- Есть редкие, но критичные запросы: месячная выгрузка, ночной пересчёт, ручной поиск в поддержке. Наблюдать нужно хотя бы полный бизнес-цикл - месяц.
- Уникальные индексы и индексы под внешними ключами не удалять: они держат ограничения
целостности, даже если
idx_scan = 0. Кстати, колонка FK без индекса сама по себе беда:DELETEв родительской таблице будет делать Seq Scan по дочерней. - Безопасного приёма вместо
DROPв PostgreSQL штатно нет: сделать индекс невидимым для планировщика нельзя (в MySQL для этого естьALTER TABLE ... ALTER INDEX ... INVISIBLE). Поэтому на практике индекс переименовывают и держат неделю, чтобы быстро вернуть, либо снимают DDL заранее.
Недостающие индексы ищут по pg_stat_user_tables: подозрительны таблицы, где
seq_scan велик, а seq_tup_read / seq_scan даёт тысячи строк за скан;
плюс pg_stat_statements по суммарному времени. Дальше смотрят EXPLAIN
конкретного запроса, а не гадают.
CREATE INDEX берёт блокировку
SHARE, которая на всё время построения останавливает запись в таблицу.
CONCURRENTLY строит индекс в два прохода, не мешая записи, ценой большей длительности
и риска остаться в состоянии INVALID.Что происходит без CONCURRENTLY
Блокировка SHARE конфликтует с ROW EXCLUSIVE, который берут
INSERT/UPDATE/DELETE. На таблице в 500 ГБ индекс
строится десятки минут - и всё это время запись в таблицу стоит. Хуже того, ожидающие
запросы выстраиваются в очередь за нашей блокировкой и блокируют даже чтения, которые
сами по себе конфликта не имели бы.
Как работает CONCURRENTLY
- Первый проход: строит индекс по снапшоту, параллельно записи продолжаются.
- Ждёт завершения всех транзакций, начавшихся до первого прохода.
- Второй проход: добирает строки, изменившиеся за время первого прохода.
- Ждёт транзакции, которые могли не видеть индекс, и помечает его валидным.
Ограничения, о которых спрашивают
- Нельзя внутри транзакционного блока, так что миграция должна уметь выполнять такой шаг
вне транзакции (в goose это
-- +goose NO TRANSACTION; golang-migrate сам транзакцию не открывает, но несколько операторов из одного файла PostgreSQL выполнит одной неявной транзакцией, поэтому такой индекс кладут в отдельный файл, включают режимx-multi-statement, где операторы уходят по одному, или запускают руками). - Идёт в 2–3 раза дольше и сильнее нагружает диск.
- При сбое остаётся невалидный индекс: планировщик его не берёт, но на каждой
записи он обновляется, то есть только вредит. Искать так:
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;, лечитьDROP INDEX CONCURRENTLYи повторной попыткой. - Долгая транзакция блокирует завершение: если кто-то держит открытую транзакцию,
CONCURRENTLYбудет ждать её вечно. Перед запуском стоит проверитьpg_stat_activity. - Уникальный индекс при конфликте данных упадёт на втором проходе, когда всё время уже потрачено.
На проде: CREATE INDEX CONCURRENTLY + DROP INDEX CONCURRENTLY,
вне транзакции, с последующей проверкой indisvalid и ANALYZE таблицы.
Так же работает REINDEX CONCURRENTLY (с PG 12), которым лечат раздутые индексы.
Что стоит запомнить
- B-tree полезен для равенства, диапазона и упорядочивания, потому что уменьшает чтение страниц при подходящем условии.
- В составном индексе важны порядок колонок и доступный левый префикс.
- Индекс ускоряет часть чтений ценой места и дополнительной работы при записи.
Частые ошибки
- Индекс не обязан использоваться: при низкой селективности последовательный скан может быть дешевле.
- Порядок колонок в составном индексе не взаимозаменяем; оцени реальные предикаты и сортировки.
- Не добавляй индекс по каждому полю без оценки нагрузки на INSERT/UPDATE и занимаемого места.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Какой индекс подходит запросу
Есть индекс (tenant_id, created_at). Для каких из трёх запросов он может помочь через левый префикс?
-- A
WHERE tenant_id = 7 AND created_at > now() - interval '1 day'
-- B
WHERE tenant_id = 7
-- C
WHERE created_at > now() - interval '1 day'
Ответ и разбор
Индекс естественно подходит A и B: оба используют первый столбец, а A ещё ограничивает диапазон второго. Для C нет условия по левому префиксу tenant_id, поэтому обычный поиск по индексу может потребовать просмотреть большую часть дерева; планировщик часто выберет другой индекс или Seq Scan. Проверяй фактический план и распределение данных.
Код-ревью
2. Найди лишний индекс
Таблица events имеет индекс (tenant_id, created_at). Разработчик добавил отдельный B-tree на tenant_id, ссылаясь только на запрос с WHERE tenant_id = ?. Что проверить перед тем, как оставить новый индекс?
CREATE INDEX events_tenant_created_idx
ON events (tenant_id, created_at);
CREATE INDEX events_tenant_idx ON events (tenant_id);
Ответ и разбор
Составной B-tree уже поддерживает поиск по левому префиксу tenant_id, поэтому отдельный индекс может дублировать его. Сравни планы и нагрузку, проверь порядок сортировки, размер и покрывающие свойства. Отдельный индекс оправдан, если он существенно меньше или имеет другие параметры/условия и это подтверждено измерениями.
Применение
3. Подбери индекс под ленту
Запрос выбирает последние 50 событий одного tenant: WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT 50. Предложи индекс и объясни, почему он подходит.
Ответ и разбор
Кандидат: B-tree по (tenant_id, created_at DESC). Равенство ограничит tenant, затем индекс даст строки в нужном порядке и позволит остановиться после 50 записей. Проверь план на реальных данных и учти цену индекса на вставках; PostgreSQL может читать B-tree в обратном направлении, поэтому явный DESC нужен не всегда для одного столбца, но важен при смешанных направлениях составного порядка.
Применение
4. Реши, нужен ли индекс
Фильтр по status возвращает 70% строк таблицы. Индекс есть, но план показывает Seq Scan. Назови, почему это может быть разумно и что проверить.
Ответ и разбор
Для 70% строк последовательное чтение часто дешевле, чем проход по индексу с множеством случайных обращений к страницам таблицы. Проверь актуальность статистики, число строк, размер таблицы и кэш; сравни фактические времена и buffers. Не включай enable_seqscan как постоянное исправление: он маскирует выбор планировщика, не снижая реальную стоимость.