Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
19.06.2026
Обновлено:
09.07.2026

B-Tree, GIN, BRIN, partial index: выбираем правильный индекс в PostgreSQL

Данил Мануйлов

Добавил индекс - запрос всё равно тормозит. Знакомо? Проблема не в том, что индекс «не работает». Проблема в том, что выбран не тот тип.

В этой статье разберём каждый тип индекса PostgreSQL на реальных примерах: когда B-Tree помогает, когда нужен GIN, BRIN или partial - и как не перегрузить базу лишними структурами.

Как PostgreSQL выбирает индекс - и когда игнорирует

Планировщик PostgreSQL не просто «ищет индекс» - он считает стоимость каждого варианта выполнения запроса. Побеждает самый дешёвый план.

Стоимость vs. селективность

Два ключевых фактора:

  • Селективность - доля строк, которую вернёт условие. Если WHERE status = 'active' возвращает 40% таблицы, индекс, скорее всего, проиграет обычному Seq Scan.
  • Стоимость доступа - Index Scan читает индекс, затем по указателям обращается к heap. Если таблица большая, а условие неселективное, прыжки по heap дороже одного последовательного прохода.

Пороговое значение зависит от random_page_cost и effective_cache_size. На SSD стоит снизить random_page_cost до 1.1–1.5, чтобы планировщик чаще выбирал Index Scan.

Три причины, почему индекс игнорируется

  1. Устаревшая статистика. После массовой загрузки данных вызовите ANALYZE. Планировщик опирается на pg_statistic - если там устаревшие данные, он недооценит количество строк и возьмёт неоптимальный план.
  2. Функция в WHERE. Запрос WHERE lower(email) = 'test@mail.com' не задействует обычный индекс по email. Нужен функциональный: CREATE INDEX ON users (lower(email)).
  3. Несовместимый тип оператора. LIKE '%text%' (поиск в середине строки) обычный B-Tree не индексирует. Нужен триграммный индекс через pg_trgm.

Как читать EXPLAIN (ANALYZE, BUFFERS)

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE created_at > '2026-01-01';

Смотрите на три строки:

Строка в выводе Что означает
Seq Scan / Index Scan Метод доступа
buffers: shared hit=X read=Y X — из кэша, Y — с диска. Много Y → индекс не помещается в shared_buffers
actual rows=N loops=1 Сколько строк реально вернул план

Если видите много read при наличии индекса - индекс слишком большой или данные физически разбросаны по heap. Это сигнал пересмотреть тип индекса.

Авторская ремарка: Я всегда запускаю EXPLAIN (ANALYZE, BUFFERS) перед созданием нового индекса и после - только так понятно, что реально изменилось.

B-Tree: универсальный, но не всесильный

B-Tree - индекс по умолчанию. CREATE INDEX без USING создаёт именно его. Он покрывает большинство задач, но у него есть слепые зоны.

Когда B-Tree - лучший выбор

Используйте B-Tree, если в WHERE стоит один из операторов: =, <, <=, >, >=, BETWEEN, IN, IS NULL, LIKE 'foo%'.

-- Стандартный B-Tree на дату
CREATE INDEX idx_orders_created ON orders (created_at);

-- Уникальный B-Tree
CREATE UNIQUE INDEX idx_users_email ON users (email);

B-Tree также ускоряет ORDER BY - если сортировка совпадает с направлением индекса, PostgreSQL пропустит шаг сортировки целиком.

Составной и покрывающий (INCLUDE) индекс

Составной индекс по (a, b) эффективнее двух отдельных, если фильтрация часто идёт по обоим полям. Правило: в начале ставьте столбец с наивысшей селективностью.

С PostgreSQL 11 появился INCLUDE - добавляет столбцы в листовые узлы индекса без участия в поиске. Это позволяет выполнять Index Only Scan: PostgreSQL берёт все нужные данные прямо из индекса, не обращаясь к heap.

-- Покрывающий индекс: ищем по user_id, возвращаем status без heap
CREATE INDEX idx_orders_cover
ON orders (user_id)
INCLUDE (status, amount);

Когда B-Tree не справляется

  • LIKE '%text%' - поиск в середине строки.
  • jsonb @> '{"role": "admin"}' - проверка вхождения в JSON.
  • tags @> ARRAY['sale'] - поиск по элементу массива.
  • Таблица на 500 млн строк с монотонно растущим timestamp - индекс займёт десятки гигабайт и не поместится в кэш.

Для этих случаев - GIN, BRIN или pg_trgm.

GIN: когда в одной строке много значений

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

GIN для jsonb и массивов

-- GIN на jsonb
CREATE INDEX idx_products_attrs
ON products USING GIN (attributes);

-- Запрос, использующий этот индекс
SELECT * FROM products
WHERE attributes @> '{"color": "black", "size": "L"}';

-- GIN на массив тегов
CREATE INDEX idx_articles_tags
ON articles USING GIN (tags);

SELECT * FROM articles WHERE tags @> ARRAY['postgresql', 'indexes'];

GIN поддерживает операторы @>, <@, &&, = для массивов и jsonb. B-Tree на те же запросы сделает последовательный скан - потому что не умеет «заглядывать» внутрь составного значения.

GIN для полнотекстового поиска

-- Добавляем tsvector-столбец и индекс
ALTER TABLE articles ADD COLUMN search_vec tsvector;
UPDATE articles SET search_vec = to_tsvector('russian', title || ' ' || body);
CREATE INDEX idx_articles_fts
ON articles USING GIN (search_vec);

-- Полнотекстовый запрос
SELECT title FROM articles
WHERE search_vec @@ to_tsquery('russian', 'PostgreSQL & индекс');

Для поиска по произвольным подстрокам (LIKE '%text%') используйте расширение pg_trgm + GIN:

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_users_name_trgm
ON users USING GIN (name gin_trgm_ops);

-- Теперь LIKE '%ivan%' задействует индекс
SELECT * FROM users WHERE name ILIKE '%ivan%';

GIN vs GiST: что выбрать

GiST (Generalized Search Tree) - инфраструктура для нестандартных структур данных: геометрия, геоданные (PostGIS), диапазонные типы (tsrange, int4range).

Критерий GIN GiST
Скорость чтения Быстрее Медленнее
Скорость вставки Медленнее Быстрее
jsonb, массивы
PostGIS, геометрия ❌ (частично)
Диапазонные типы
Поиск «ближайшего соседа»

Для полнотекстового поиска GIN и GiST оба работают - но GIN быстрее на чтение, GiST быстрее обновляется при частых INSERT/UPDATE.

Авторская ремарка: Когда в SaaS-проекте нужна была поддержка фильтрации по jsonb-атрибутам товаров, GIN дал ускорение запросов примерно в 50–80 раз по сравнению с Seq Scan на таблице 10 млн строк. B-Tree на те же поля был бессилен - он просто не поддерживает оператор @>.

Параметр fastupdate: По умолчанию GIN откладывает запись изменений в отдельный список ожидания (gin_pending_list_limit, по умолчанию 4 МБ). Это ускоряет INSERT, но замедляет первый SELECT после массовой загрузки. Если нужна предсказуемая latency - отключите: CREATE INDEX ... WITH (fastupdate = off).

BRIN: индекс для таблиц-гигантов

BRIN (Block Range INdex, индекс зон блоков) работает принципиально иначе чем B-Tree. Он не хранит ссылку на каждую строку - он хранит минимум и максимум значений для группы физически соседних блоков (зоны).

Как работает BRIN изнутри

По умолчанию одна зона = 128 страниц (≈ 1 МБ данных). Для каждой зоны BRIN знает: «здесь значения created_at от 2026-01-01 до 2026-01-31». Запрос за февраль пропустит эту зону целиком - и так для большинства зон таблицы.

Отсюда главное условие: данные должны быть физически упорядочены по индексируемому столбцу. Для таблицы логов, куда строки пишутся в хронологическом порядке, это выполняется автоматически. Для таблицы, в которую вставляют строки в случайном порядке - BRIN бесполезен.

-- BRIN на timestamp в таблице событий
CREATE INDEX idx_events_brin
ON events USING BRIN (created_at)
WITH (pages_per_range = 64); -- уменьшаем зону для точности

Настройка pages_per_range: меньше значение → точнее фильтрация, но больше размер индекса. Начните с 64–128 и замеряйте через EXPLAIN ANALYZE.

BRIN для временных рядов и IoT

Реальный кейс (воспроизведён по материалам Habr OTUS, март 2026): система сбора метрик с тысяч серверов, ~100 млн строк в сутки. B-Tree по (server_id, timestamp) занимал 40 ГБ и не помещался в shared_buffers - каждый запрос шёл на диск. После замены на BRIN по timestamp индекс занял 120 МБ, запросы по временным диапазонам стали выполняться за миллисекунды [web:1].

-- До
CREATE INDEX idx_metrics_btree ON metrics (server_id, timestamp);
-- Размер: ~40 ГБ на 100 млрд строк ⚠️ Предположительно, масштаб зависит от данных

-- После
CREATE INDEX idx_metrics_brin ON metrics USING BRIN (timestamp);
-- Размер: ~120 МБ

Когда BRIN не поможет

  • Таблицы, куда строки вставляются в произвольном порядке (нет физической корреляции).
  • UPDATE-heavy workload: обновления нарушают физический порядок данных.
  • Запросы с высокой точностью фильтрации по узкому диапазону - BRIN может прочитать лишние блоки, и Seq Scan окажется сопоставим по стоимости.

Проверить корреляцию столбца с физическим порядком:

SELECT attname, correlation
FROM pg_stats
WHERE tablename = 'your_table'
ORDER BY correlation DESC;

Если correlation близко к 1.0 или -1.0 - BRIN подойдёт.

Partial index: маленький, но меткий

Частичный (partial) индекс строится только по строкам, которые удовлетворяют условию WHERE. Остальные строки в индекс не попадают совсем.

Как создать partial index

Классический пример - таблица задач, где 95% строк имеет status = 'done' и только 5% - status = 'pending'. Индекс по всей таблице на status почти бесполезен (низкая селективность). Partial index - другое дело:

-- Индексируем только активные задачи
CREATE INDEX idx_tasks_pending
ON tasks (created_at)
WHERE status = 'pending';

-- Запрос использует индекс, потому что условия совпадают
SELECT * FROM tasks
WHERE status = 'pending' AND created_at < NOW() - INTERVAL '1 day';

Результат: индекс охватывает 5% строк → он в 20 раз меньше полного B-Tree. Он почти всегда помещается в кэш и работает быстрее.

Другие типичные случаи:

  • Мягкое удаление: WHERE deleted_at IS NULL
  • Очередь задач: WHERE processed = false
  • Активные пользователи: WHERE last_login > NOW() - INTERVAL '90 days'

Частичный + функциональный: комбо

Можно комбинировать: функциональный индекс по выражению с partial-условием.

-- Регистронезависимый поиск только по активным пользователям
CREATE INDEX idx_users_email_active
ON users (lower(email))
WHERE deleted_at IS NULL;

-- Этот запрос использует индекс
SELECT * FROM users
WHERE lower(email) = 'user@example.com' AND deleted_at IS NULL;

Как найти и удалить лишние индексы

Каждый индекс - это накладные расходы на INSERT/UPDATE/DELETE. Найти неиспользуемые:

SELECT
    schemaname,
    tablename,
    indexname,
    idx_scan,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
    AND indexrelname NOT LIKE 'pg_%'
ORDER BY pg_relation_size(indexrelid) DESC;

Индексы с idx_scan = 0 за последние 2 недели - кандидаты на удаление. Перед удалением: DROP INDEX CONCURRENTLY idx_name - без блокировки таблицы.

Шпаргалка: какой индекс выбрать

Задача Тип данных / оператор Рекомендация
Равенство, диапазон, сортировка любой скалярный, = < > BETWEEN B-Tree (дефолт)
Уникальность любой B-Tree UNIQUE
LIKE 'foo%' (префикс) text B-Tree
LIKE '%foo%' (подстрока) text GIN + pg_trgm
Полнотекстовый поиск tsvector, @@ GIN
jsonb, оператор @> ? jsonb GIN
Массив, оператор @> && array GIN
Иерархия, ltree ltree GIN или GiST
Геоданные, PostGIS geometry, && <-> GiST
Диапазонные типы tsrange, int4range GiST
Только =, очень быстро скалярный Hash (редко нужен)
Таблица-гигант, физический порядок timestamp, id (autoincrement) BRIN
Подмножество строк любой + условие WHERE Partial index
Покрывающий запрос (без heap) скалярный B-Tree + INCLUDE
Функция в WHERE lower(col), date_trunc(...) Функциональный

Bonus: создание индекса в продакшене без блокировок

Обычный CREATE INDEX блокирует запись в таблицу на время построения. На большой таблице это недопустимо. Используйте:

CREATE INDEX CONCURRENTLY idx_orders_user_id
ON orders (user_id);

CONCURRENTLY строит индекс в два прохода - это медленнее, но не блокирует INSERT/UPDATE. Ограничение: нельзя выполнять внутри транзакции и нельзя создавать уникальные индексы одновременно с операциями записи в случае конфликтов.

Если индекс «раздулся» из-за большого числа удалений - перестройте:

REINDEX INDEX CONCURRENTLY idx_orders_user_id;

Альтернативное мнение

Некоторые DBA считают, что лучшая стратегия индексирования - «минимум индексов, максимум партиционирования». Аргумент: в highload-системах партиционирование по диапазону дат уже даёт прунинг партиций (planner пропускает ненужные), а индексы внутри каждой партиции остаются маленькими и всегда помещаются в кэш. Это справедливо для OLAP-нагрузки. Для OLTP, где запросы точечные и нужна предсказуемая latency, правильно подобранный индекс незаменим.

Нетривиальный факт

BRIN на монотонно растущем id (serial, bigserial) работает почти так же хорошо, как на timestamp - потому что id физически коррелирован с порядком вставки строк. Если вы используете UUID v4 вместо serial, BRIN по id окажется бесполезным: UUID v4 случаен, корреляция близка к нулю. UUID v7 (упорядоченный по времени) решает эту проблему и позволяет использовать BRIN.

FAQ

Какой индекс PostgreSQL использовать по умолчанию?

B-Tree - индекс по умолчанию. Подходит для равенства, диапазонов, ORDER BY и уникальности. Если не знаете, какой тип выбрать - берите B-Tree.

Когда нужен GIN индекс?

GIN нужен, когда в одной строке хранится несколько значений: jsonb (оператор @>), массивы (@>, &&), полнотекстовый поиск (tsvector + @@), ltree. Для LIKE-поиска по подстроке - GIN + pg_trgm.

Чем BRIN отличается от B-Tree?

BRIN хранит мин/макс значений для зон блоков, а не запись для каждой строки. Занимает в сотни раз меньше места, но работает только если данные физически упорядочены по индексируемому столбцу (например, timestamp в таблице логов).

Что такое partial index и зачем он нужен?

Partial index строится только по строкам, которые удовлетворяют условию WHERE. CREATE INDEX ... WHERE status = 'active' охватывает только активные записи - индекс меньше, быстрее помещается в кэш, ускоряет именно нужные запросы.

Почему PostgreSQL не использует мой индекс?

Три причины: (1) Низкая селективность - условие возвращает >20–30% строк, Seq Scan дешевле. (2) Устаревшая статистика - запустите ANALYZE. (3) Несовместимый оператор - LIKE '%text%' не работает с B-Tree, нужен pg_trgm.

Как создать индекс в продакшене без блокировки?

Используйте CREATE INDEX CONCURRENTLY. Строит индекс в два прохода, не блокирует INSERT/UPDATE/DELETE. Нельзя выполнять внутри транзакции.

Как найти неиспользуемые индексы PostgreSQL?

Запрос к pg_stat_user_indexes: WHERE idx_scan = 0. Индексы с нулевым числом обращений за 2 недели - кандидаты на удаление через DROP INDEX CONCURRENTLY.

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также