Типы индексов: B-tree, Hash, GiN, GiST, BRIN

Индекс — отдельная структура данных, которая помогает СУБД быстро находить строки, не просматривая всю таблицу; у PostgreSQL таких структур несколько, и под разные задачи нужны разные.

Индекс — вспомогательная структура, которая хранит значения столбца (или выражения) в удобном для поиска виде вместе со ссылками на строки таблицы. Тип индекса определяет, какие запросы он умеет ускорять.

Без индекса любой поиск по условию — это полный перебор таблицы (последовательное сканирование). На тысяче строк это незаметно, на десятках миллионов — секунды ожидания. Индекс превращает перебор в адресный доступ: вместо «прочитать всё и отфильтровать» СУБД сразу прыгает к нужным строкам. Но универсального индекса нет: B-tree отлично ищет по диапазону чисел, но бесполезен для запроса «у каких документов в JSONB есть ключ tags». PostgreSQL предлагает целое семейство типов, и этот урок — карта, какой из них под что брать.

Зачем это на практике

Неправильный тип индекса — это либо индекс, которым планировщик никогда не воспользуется (потраченное место и замедление записи впустую), либо отсутствие ускорения там, где оно критично. Запрос с фильтром по массиву, по JSONB-полю или полнотекстовый поиск B-tree-индекс не ускорит вообще — нужен GIN. Поиск ближайших точек на карте — это GiST. А на таблице логов в сотни гигабайт обычный B-tree займёт десятки гигабайт, тогда как BRIN решит ту же задачу, заняв единицы мегабайт. Выбор типа — это первое архитектурное решение об индексировании, и оно сильно влияет и на скорость, и на размер базы.

B-tree: универсальный выбор по умолчанию

Когда вы пишете CREATE INDEX без указания типа, PostgreSQL создаёт B-tree. Это сбалансированное дерево, в котором значения хранятся в отсортированном порядке. Оно поддерживает операторы сравнения =, <, <=, >, >= и BETWEEN, а ещё помогает с ORDER BY и LIKE 'префикс%'.

-- B-tree создаётся по умолчанию; обе строки эквивалентны
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_email ON users USING btree (email);

B-tree подходит для подавляющего большинства колонок: первичные и внешние ключи, даты, числа, короткие строки. Логика простая: если запрос фильтрует столбец операторами сравнения или сортирует по нему — нужен B-tree. Покажем на исполнимом примере саму идею ускорения. SQLite в песочнице тоже строит B-tree, так что эффект тот же.

CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, age INTEGER);
INSERT INTO users (email, age) VALUES
  ('[email protected]', 31), ('[email protected]', 24),
  ('[email protected]', 42), ('[email protected]', 19), ('[email protected]', 35);

CREATE INDEX idx_users_age ON users (age);

-- запрос по диапазону: B-tree находит строки, не читая всю таблицу
SELECT email, age FROM users WHERE age BETWEEN 25 AND 40 ORDER BY age;

Вывод:

[email protected]|31
[email protected]|35

На пяти строках выигрыша нет, но на миллионах именно B-tree по age отделяет мгновенный ответ от полного сканирования.

GIN: массивы, JSONB и полнотекст

GIN (Generalized Inverted Index, обобщённый инвертированный индекс) предназначен для значений, которые содержат множество элементов: массивы, документы JSONB, словари полнотекстового поиска. Идея «инвертированного» индекса — как алфавитный указатель в конце книги: для каждого элемента (слова, тега, ключа) хранится список строк, где он встречается.

Берите GIN, когда условие звучит как «содержит»: массив содержит значение, JSONB содержит ключ или пару, документ содержит слово.

-- массив тегов: ищем статьи, у которых в tags есть 'sql'
CREATE INDEX idx_articles_tags ON articles USING gin (tags);
SELECT id FROM articles WHERE tags @> ARRAY['sql'];

-- JSONB: ищем заказы, где в data есть ключ/значение
CREATE INDEX idx_orders_data ON orders USING gin (data);
SELECT id FROM orders WHERE data @> '{"status": "paid"}';

-- полнотекстовый поиск по статье
CREATE INDEX idx_docs_fts ON docs USING gin (to_tsvector('russian', body));
SELECT id FROM docs WHERE to_tsvector('russian', body) @@ to_tsquery('russian', 'индекс');

Операторы @> (содержит) и @@ (совпадение полнотекста) — то, ради чего GIN существует. B-tree по столбцу tags такие запросы не ускорит вообще: он умеет сравнивать массивы целиком, но не искать вхождение элемента.

GiST: геометрия, диапазоны и близость

GiST (Generalized Search Tree) — каркас для индексирования данных, которые не выстраиваются в одну прямую линию: точки и фигуры на плоскости, диапазоны, IP-сети. Он отвечает на вопросы «пересекается ли», «содержится ли», «что ближе всего».

-- геоданные (расширение PostGIS): найти объекты рядом с точкой
CREATE INDEX idx_places_geom ON places USING gist (geom);

-- диапазоны времени: найти бронирования, пересекающие интервал
CREATE INDEX idx_booking_period ON bookings USING gist (period);
SELECT id FROM bookings WHERE period && tsrange('2026-06-01', '2026-06-07');

Классический случай GiST — поиск ближайших объектов (ORDER BY geom <-> point(...)) и проверка пересечения диапазонов оператором &&. Для геоданных и интервальных типов это незаменимо.

BRIN: огромные естественно отсортированные таблицы

BRIN (Block Range Index, индекс по диапазонам блоков) — крошечный индекс для очень больших таблиц, где данные физически лежат примерно по порядку. Вместо ссылки на каждую строку BRIN хранит для каждого блока страниц лишь сводку: минимальное и максимальное значение в этом блоке. Поэтому он занимает в сотни раз меньше места, чем B-tree.

-- таблица логов на сотни миллионов строк, растёт по времени
CREATE INDEX idx_events_ts ON events USING brin (created_at);

-- запрос по диапазону дат: BRIN отсекает блоки, чьи min/max вне диапазона
SELECT count(*) FROM events
WHERE created_at >= '2026-06-01' AND created_at < '2026-06-08';

BRIN блистает, когда есть корреляция между физическим порядком строк и значением столбца: события пишутся по возрастанию created_at, поэтому соседние блоки содержат близкие даты. Если же данные перемешаны (например, случайный UUID), BRIN бесполезен — у каждого блока диапазон min/max окажется «от и до», и отсечь ничего не получится.

Hash: только равенство

Hash-индекс хранит хеши значений и поддерживает единственный оператор — =. Он не помогает ни сортировке, ни диапазонам, ни LIKE. Теоретически на точном поиске он может быть чуть компактнее и быстрее B-tree, но на практике выигрыш мал, а B-tree умеет и равенство тоже. Поэтому Hash берут редко и осознанно — когда столбец участвует исключительно в проверках на равенство и значения длинные.

-- оправдано только если по token всегда ищут строго через =
CREATE INDEX idx_sessions_token ON sessions USING hash (token);
SELECT user_id FROM sessions WHERE token = 'a1b2c3d4e5';

Как это работает под капотом

Все индексы PostgreSQL — это отдельные файлы на диске, физически не связанные с порядком строк в таблице (исключение по смыслу — BRIN, который как раз опирается на этот порядок). Поиск по B-tree — это спуск от корня к листу по отсортированным ключам за число шагов, пропорциональное логарифму размера таблицы: вот почему даже на миллиардах строк хватает считанных обращений к диску. GIN внутри хранит для каждого «элемента» (слова, ключа JSONB) упорядоченный список идентификаторов строк, и запрос «содержит X» — это просто чтение готового списка. BRIN не указывает на строки вовсе: по сводкам блоков он быстро отсекает заведомо ненужные диапазоны страниц, а внутри оставшихся уже идёт обычное чтение. Платой за любой индекс служит замедление записи: каждый INSERT, UPDATE и DELETE должен обновить ещё и все индексы таблицы, плюс они занимают место на диске.

Частые ошибки

  • B-tree на массив или JSONB. Запрос «содержит элемент» он не ускорит. Для операторов @>, ?, @@ нужен GIN, иначе планировщик честно сделает полное сканирование.
  • BRIN на перемешанные данные. Без корреляции между физическим порядком и значением сводки блоков ничего не отсекают — индекс есть, толку ноль.
  • Hash «для скорости». В большинстве случаев B-tree не хуже и универсальнее; отдельный Hash почти никогда не оправдан.
  • Индексы на всё подряд. Лишний индекс замедляет каждую запись и съедает диск. Индексируйте столбцы, которые реально встречаются в WHERE, JOIN и ORDER BY.

Итоги

  • B-tree — выбор по умолчанию: равенство, диапазоны (<, >, BETWEEN), сортировка, LIKE 'префикс%'.
  • GIN — для «составных» значений: массивы, JSONB, полнотекст; операторы «содержит» @> и @@.
  • GiST — для геометрии, диапазонов и поиска близости: пересечение &&, ближайшие объекты.
  • BRIN — крошечный индекс для огромных таблиц с естественным порядком (логи по времени).
  • Hash — только оператор =; на практике почти всегда проигрывает B-tree и берётся редко.
  • Любой индекс ускоряет чтение, но замедляет запись и занимает место — индексируйте осознанно.
Проверьте себя
1. Какой тип индекса PostgreSQL создаётся по умолчанию командой CREATE INDEX без указания типа и подходит для сравнений (<, >, BETWEEN) и сортировки?
AHash
BB-tree
CGIN
DBRIN
2. Нужно быстро искать строки, где массив tags содержит элемент 'sql' (оператор @>), а также делать полнотекстовый поиск. Какой индекс выбрать?
AB-tree — он универсален и ускорит любой запрос
BGIN — он создан для массивов, JSONB и полнотекста
CHash — он быстрее всех на любых данных
DBRIN — он лучший для текстовых столбцов
3. Для таблицы логов на сотни миллионов строк, которая физически растёт по возрастанию created_at, какой индекс даст ускорение по диапазону дат при минимальном размере на диске?
AB-tree — он самый компактный для больших таблиц
BGiST — он создан для временных столбцов
CBRIN — он хранит лишь min/max по диапазонам блоков и крошечный
DHash — он идеален для диапазонов дат