uz
DF
февраля 10, 2026
Обновлено февраля 12, 2026

Индексы PostgreSQL: типы, оптимизация и примеры запросов — руководство для разработчиков

PostgreSQL

Введение: зачем разработчику разбираться в индексах PostgreSQL

Если вы уже интуитивно понимаете, что индекс ускоряет чтение и замедляет запись, то в PostgreSQL быстро возникает следующий слой вопросов: почему один и тот же индекс иногда даёт ускорение в разы, а иногда вообще не используется; какие реальные издержки вы платите за каждый CREATE INDEX; какие типы индексов есть в Postgres и когда выбирать B-tree, GIN, GiST или BRIN.

Эта статья — для разработчиков, которым важно понимать внутреннюю механику индексов, trade-off’ы и набор практических приёмов, чтобы индексация соответствовала вашему реальному профилю запросов (OLTP, API, аналитика, поиск, события).

Как PostgreSQL хранит данные: heap, страницы и ctid

Таблица в PostgreSQL физически хранится как heap (куча кортежей/строк), разбитая на страницы (обычно 8KB). У каждой строки есть физический адрес — системное поле ctid в формате (номер_страницы, смещение_внутри_страницы). Важно: ctid — это не бизнес-идентификатор, он может меняться (например, при UPDATE), но он помогает понять, как именно индекс «приводит» вас к строкам.

Быстрая демонстрация (только для понимания, не как «паттерн на прод»):

CREATE TABLE foo (id int, name text);

INSERT INTO foo VALUES (1, 'Ronaldo'), (2, 'Romario');

SELECT ctid, * FROM foo;

В упрощённом виде индекс хранит структуру «ключ → ссылка на строки (TID/ctid)», чтобы не читать всю таблицу подряд. Но в реальности он ещё влияет на план выполнения, на стоимость записи и на обслуживание MVCC-версий строк.

Почему планировщик иногда игнорирует индекс и выбирает Seq Scan

Индекс не обязателен к использованию: PostgreSQL выбирает план, который считает дешевле по стоимости (cost). Есть несколько типовых причин, почему индекс может быть проигнорирован:

  • Низкая селективность: запрос возвращает слишком большой процент строк, и последовательное чтение оказывается дешевле.
  • Статистика неточная: planner ошибается в оценках кардинальности (особенно если данные сильно скошены или статистика устарела).
  • Выражение в WHERE не соответствует индексу: например, WHERE lower(email)=… без индекса на lower(email).
  • Сильная конкуренция за cache: чтение через индекс приводит к множеству случайных обращений к страницам (random I/O).
  • Параллельный Seq Scan может «побить» индекс на больших выборках.

Проверять реальность важно через анализ плана с фактическим выполнением и буферами:

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM foo WHERE name = 'Ronaldo';

Смотрите не только на “Index Scan/Seq Scan”, но и на фактические строки, время и количество прочитанных страниц (BUFFERS).

Цена индексов: что вы платите за ускорение чтения

Индекс — это ресурсный контракт. Он ускоряет чтение, но требует оплаты в нескольких валютах:

  • Диск: индекс занимает место (иногда сопоставимо с таблицей, иногда больше).
  • Запись: INSERT/UPDATE/DELETE дорожают — нужно поддерживать структуру индекса.
  • VACUUM/maintenance: индексы тоже обслуживаются (reindex, bloat, анализ).
  • Планирование запросов: больше индексов — больше вариантов, сложнее выбор плана.
  • Кеш: страницы индексов конкурируют за память с данными таблицы.

Практическая мысль: индекс — не «всё индексируем», а «индексируем то, что реально ускоряет ключевые запросы и окупается по стоимости записи».

Основные типы индексов в PostgreSQL и когда какой выбирать

1) B-tree (по умолчанию)
Самый универсальный индекс: равенство, диапазоны, сортировка, join’ы. Идеален для большинства OLTP-запросов и типичных фильтров по датам, id, статусам, внешним ключам.
Примеры запросов, где B-tree почти всегда уместен:

-- точный поиск
SELECT * FROM users WHERE email = 'a@b.com';

-- диапазон по времени
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';

-- быстрый top-N с сортировкой
SELECT * FROM logs
ORDER BY created_at DESC
LIMIT 100;

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

2) Hash
Нишевый вариант для равенства (=) по одному столбцу. Не поддерживает диапазоны и сортировку. Иногда может быть выгоден на длинных ключах (например, очень длинные строки), но чаще B-tree остаётся дефолтным выбором.

3) GIN (Generalized Inverted Index)
GIN нужен, когда в одном поле «много элементов», а вы ищете по элементам: jsonb, массивы, полнотекст (tsvector), триграммы (через расширение). Это один из самых практичных “must know” типов индексов для современных приложений.
Типовые кейсы:

  • Поиск по ключам/значениям в jsonb
  • Поиск по элементам массива
  • Полнотекстовый поиск
  • LIKE/ILIKE по подстроке через pg_trgm

4) GiST и SP-GiST
Подходят для пространственных структур, диапазонов, специфичных типов и операторов. Часто используются в геоданных, range-типах, некоторых сценариях полнотекста/триграмм и нестандартных сравнений.

5) BRIN (Block Range Index)
BRIN — экономичный по месту индекс для огромных таблиц, особенно когда данные записываются «по порядку» и их значения хорошо совпадают с физическим расположением строк (например, по времени вставки). Он хранит агрегированную информацию по диапазонам страниц и помогает быстро исключать блоки, где точно нет нужных значений.

Продвинутые техники индексации, которые дают максимум эффекта

Частичные индексы (partial index)
Индексируете только подмножество строк через WHERE. Это уменьшает размер индекса и ускоряет запросы к «горячим» данным.

-- индексируем только активные записи
CREATE INDEX sessions_active_idx ON sessions (user_id)
WHERE revoked_at IS NULL;

-- индексируем только редкие статусы (если TODO = 90% строк)
CREATE INDEX tasks_not_todo_idx ON tasks (status)
WHERE status <> 'TODO';

Индексы на выражениях (expression indexes)
Когда в WHERE участвует функция или вычисляемое выражение, индекс по исходному столбцу часто бесполезен — тогда имеет смысл создать индекс именно на это выражение, чтобы планировщик мог его использовать.

-- запрос
SELECT * FROM customers WHERE lower(email) = 'john@doe.com';

-- индекс под запрос
CREATE INDEX customers_lower_email_idx ON customers (lower(email));

Покрывающие индексы (INCLUDE) и Index Only Scan
Когда запрос выбирает только те столбцы, которые уже присутствуют в индексе, PostgreSQL может обойтись одним индексом: выполнить Index Only Scan и не ходить в heap-таблицу за данными. Для этого удобно добавлять «покрывающие» колонки через INCLUDE: они хранятся в листовых страницах индекса, но не участвуют в порядке ключа.

-- ключи поиска: (a, b), а поле c нужно только для выдачи результата
CREATE INDEX bar_ab_cover_idx ON bar (a, b) INCLUDE (c);

-- запрос, который потенциально может стать index-only (зависит от видимости страниц)
SELECT c FROM bar WHERE a = 10 AND b = 20;

Bitmap-сканы и комбинация индексов
Postgres умеет объединять несколько индексов для одного запроса (Bitmap Index Scan + Bitmap Heap Scan), особенно когда условия независимы. Иногда это лучше, чем создавать «слишком широкий» составной индекс под каждую комбинацию условий.

Практические примеры: какие индексы ставить под типовые задачи

1) API и фильтры по tenant + времени
Часто в мультиарендных системах фильтр идёт по tenant_id и диапазону по времени. Составной B-tree обычно оправдан.

CREATE INDEX events_tenant_time_idx ON events (tenant_id, created_at);

SELECT * FROM events
WHERE tenant_id = 42
AND created_at >= now() - interval '7 days'
ORDER BY created_at DESC
LIMIT 200;

2) “Только активные” записи
Partial index уменьшит размер индекса и стоимость записи в архив/историю.

CREATE INDEX users_active_idx ON users (last_seen_at)
WHERE deleted_at IS NULL;

3) Поиск по JSONB
Если вы храните документы/настройки/метаданные, GIN по jsonb — базовый инструмент.

-- payload должен быть jsonb
CREATE INDEX documents_payload_gin_idx
ON documents
USING GIN (payload);

-- пример запроса по ключу/значению
SELECT * FROM documents
WHERE payload @> '{"role":"admin"}';

4) Поиск по подстроке (LIKE/ILIKE) без якоря слева
Это типичная задача для каталогов, тикетов, логов, поисковых строк. Решается pg_trgm + GIN.

CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX products_title_trgm_idx
ON products
USING GIN (title gin_trgm_ops);

SELECT * FROM products
WHERE title ILIKE '%iphone%';

5) Огромные логи/события (append-only)
BRIN часто даёт выгодный компромисс: маленький индекс и заметный выигрыш при фильтрах по времени.

CREATE INDEX logs_ts_brin_idx ON logs USING BRIN (ts);

SELECT * FROM logs
WHERE ts >= '2026-02-01' AND ts < '2026-02-02';

Вывод

Индексы в PostgreSQL — это управляемая оптимизация: вы обмениваете часть ресурсов (диск, стоимость записи, обслуживание) на ускорение чтения. Универсальный выбор по умолчанию — B-tree, но для jsonb/массивов/полнотекста и “поиска внутри поля” чаще нужен GIN, для очень больших коррелированных таблиц — BRIN, а для геометрии/диапазонов и специальных операторов — GiST/SP-GiST.

Главная привычка, которая делает индексы «правильными»: не ставить их по ощущениям, а проверять планы выполнения и фактические затраты через EXPLAIN (ANALYZE, BUFFERS), держать статистику актуальной и выбирать минимальный индекс, который покрывает ваши ключевые запросы без избыточного урона по записи и поддержке.

Чек-лист: как понять, что индекс нужен (и какой)

  • 1) Зафиксируйте “проблемный” запрос
    Берите реальный запрос из приложения (ORM/SQL), а не упрощённый аналог. Важно, чтобы условия WHERE/ORDER BY/JOIN были такими же, как в проде.
  • 2) Проверьте план выполнения до изменений
    Снимите план с фактами и буферами — это ваша точка “до”.

    EXPLAIN (ANALYZE, BUFFERS)
    SELECT ...
  • 3) Оцените селективность
    Если запрос возвращает “почти всё” (условно десятки процентов таблицы и больше), индекс может не помочь — Seq Scan может быть логичнее.
    Если запрос возвращает небольшую долю строк (особенно при LIMIT) — индекс почти всегда кандидат.
  • 4) Проверьте, есть ли сортировка + LIMIT
    Частый кейс “лента/лог/тикеты”: ORDER BY + LIMIT. Индекс под сортировку часто даёт максимальный эффект.

    -- пример: лента событий
    SELECT * FROM events
    WHERE tenant_id = 42
    ORDER BY created_at DESC
    LIMIT 100;
  • 5) Убедитесь, что условие совпадает с тем, что вы индексируете
    Если в WHERE есть функция/каст/выражение — индекс по “голой” колонке может не сработать. Тогда нужен индекс на выражение.

    -- запрос
    SELECT * FROM customers WHERE lower(email) = 'john@doe.com';-- индекс под запрос
    CREATE INDEX customers_lower_email_idx ON customers (lower(email));
  • 6) Выберите тип индекса по задаче
    • B-tree — равенство, диапазоны, сортировка, JOIN (дефолт для большинства OLTP)
    • GIN — jsonb/массивы/полнотекст/триграммы (поиск “внутри поля”)
    • BRIN — огромные таблицы, данные “по порядку” (например, по времени вставки)
    • GiST/SP-GiST — специфические типы и операторы (гео/диапазоны/и т.п.)
  • 7) Подумайте про “точечные” индексы вместо “всего подряд”
    Часто лучше сделать частичный или покрывающий индекс, чем раздувать общий.

    -- частичный индекс (только активные строки)
    CREATE INDEX sessions_active_idx ON sessions (user_id)
    WHERE revoked_at IS NULL;-- покрывающий индекс (чтобы чаще получать Index Only Scan)
    CREATE INDEX bar_ab_cover_idx ON bar (a, b) INCLUDE (c);
  • 8) Создайте индекс безопасно для прод-нагрузки
    Если база под активной записью, обычно используют CONCURRENTLY (создание дольше, но меньше блокировок на запись).

    CREATE INDEX CONCURRENTLY events_tenant_time_idx
    ON events (tenant_id, created_at);
  • 9) Сравните “после” тем же способом
    Снова снимите EXPLAIN (ANALYZE, BUFFERS) и сравните:
    • время выполнения
    • фактические строки (actual rows)
    • BUFFERS: shared hit/read (сколько читали из кеша и с диска)
    • тип скана: Index / Bitmap / Index Only
  • 10) Проверьте, что индекс реально используется со временем
    Убедитесь, что индекс не “мертвый груз”. Статистику использования смотрят через pg_stat_*
    -- смотрим, какие индексы реально сканируются
    SELECT relname AS table,
    indexrelname AS index,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch
    FROM pg_stat_user_indexes
    ORDER BY idx_scan DESC;
  • 11) Следите за побочными эффектами
    После добавления индекса проверьте:
    • не выросло ли заметно время INSERT/UPDATE
    • не раздулся ли диск
    • не появились ли “лишние” индексы, которые дублируют друг друга
  • 12) Удаляйте/пересматривайте лишние индексы
    Если индекс не используется и не нужен для ограничений/уникальности — это постоянная плата за запись и место.

Как выбрать порядок колонок в составном индексе

Порядок колонок в составном индексе важнее, чем кажется: PostgreSQL эффективнее всего использует индекс, когда условия в запросе “начинаются” с первых колонок индекса (принцип левого префикса). Поэтому первую позицию обычно отдавайте тому, что почти всегда присутствует в WHERE и лучше всего сужает выборку. Дальше ставят колонку, по которой идёт диапазон (например, дата/время), а затем — поля, которые помогают ORDER BY или делают выборку ещё точнее. Типовой рабочий пример для мультиарендных систем: (tenant_id, created_at) — сначала фиксируем арендатора, затем быстро выбираем период или последние события. При этом низкокардинальные поля вроде status часто не стоит ставить первыми: если у статуса всего несколько значений, такой “первый ключ” плохо фильтрует строки и индекс может использоваться хуже. Если же частый запрос выглядит как “WHERE status = ‘error’ AND created_at > …”, то разумнее сделать отдельный частичный индекс под редкий статус, чем тащить status в начало общего составного индекса.

Диагностика по EXPLAIN: как читать план на уровне “что искать глазами”

Первое правило: смотрите не на «красивый план», а на факты. Используйте EXPLAIN (ANALYZE, BUFFERS) и сравнивайте оценку планировщика с реальностью: сколько строк он ожидал (rows) и сколько вернуло фактически (actual rows). Если расхождение в разы, оптимизатор может выбирать не тот путь — обычно это сигнал, что статистика устарела или распределение данных нестандартное.

Дальше быстро пробегитесь глазами по типу доступа к данным. Seq Scan означает, что таблица читается целиком (иногда это нормально на больших выборках или при параллельном скане). Index Scan — точечный проход по индексу с обращением к таблице за строками. Bitmap Index Scan + Bitmap Heap Scan — компромисс: индекс собирает «карту» подходящих строк, а затем таблица читается пачками страниц, что выгоднее при выборке множества строк и снижает random I/O. Index Only Scan — лучший сценарий для чтения: данные берутся из индекса без походов в heap (если видимость строк позволяет).

Следующий ориентир — BUFFERS. shared hit показывает, сколько блоков нашлось в памяти, shared read — сколько пришлось читать с диска. Если shared read большой, проблема часто в I/O, и индекс может давать слабый эффект просто потому, что данные не помещаются в кеш или чтение слишком случайное. Параллельно смотрите на «время» в узлах плана: иногда индекс используется, но узкое место не в поиске строк, а в сортировке (Sort), агрегации (Aggregate) или джойне (Nested Loop / Hash Join). Тогда индекс «не лечит» причину — нужно менять запрос, порядок условий, добавлять индекс под ORDER BY/LIMIT, либо пересматривать стратегию join’ов.

Мини-шаблон для проверки перед и после индекса выглядит так:

EXPLAIN (ANALYZE, BUFFERS)
SELECT ...

После добавления индекса ваша цель — увидеть более подходящий тип скана (Index/Bitmap/Index Only), уменьшение фактического времени, и (в идеале) падение shared read при сопоставимом количестве returned rows.

FAQ

  • 1) Почему PostgreSQL игнорирует индекс и делает Seq Scan?
    Обычно из-за низкой селективности (возвращаете слишком много строк), из-за более выгодной стоимости последовательного чтения (иногда параллельного), из-за несовпадения выражения в WHERE с тем, что индексировано, или из-за устаревшей/неудачной статистики.
  • 2) Как понять, используется ли индекс на самом деле?
    Запускайте EXPLAIN (ANALYZE, BUFFERS): если в плане появляются Index Scan, Bitmap Index Scan или Index Only Scan, значит оптимизатор действительно задействовал индекс. Параметр BUFFERS дополнительно покажет, сколько блоков было прочитано из кеша и сколько пришлось взять с диска.
  • 3) Что такое Index Only Scan и почему он иногда всё равно читает таблицу?Index-only scan возможен, когда PostgreSQL уверен в видимости строк (visibility map). Если страница не отмечена как all-visible, системе всё равно нужно проверить видимость в heap. Регулярный VACUUM повышает шанс получить index-only scan.
  • 4) Когда использовать INCLUDE, а когда делать широкий составной индекс?
    INCLUDE полезен, когда поле нужно только для выдачи результата (SELECT), но не должно участвовать в ключе индекса. Это помогает покрывать запросы, не раздувая ключ и не ухудшая поиск/сортировку по главным колонкам.
  • 5) Что выбрать для JSON: B-tree или GIN?
    Для поиска внутри jsonb обычно нужен GIN. B-tree не предназначен для ускорения операторов “содержит/включает” по структурам JSON.
  • 6) Почему запрос с LOWER(col) не использует индекс по col?
    Потому что индекс по col не эквивалентен выражению lower(col). Нужен expression index на lower(col), либо переписывание запросов/хранение нормализованного значения в отдельной колонке.
  • 7) Когда BRIN лучше, чем партиционирование?
    BRIN стоит попробовать на очень больших таблицах с хорошей корреляцией значений с физическим порядком строк (например, логи по времени). Он проще и дешевле в сопровождении, чем партиционирование, хотя решает более узкую задачу.
  • 8) Hash-индекс действительно быстрее B-tree для равенства?
    Иногда — да, но он поддерживает только “=” по одному столбцу и имеет ограничения. На практике чаще начинают с B-tree и переходят к hash только при подтверждённой выгоде на конкретной нагрузке.

Источник-референс: https://dlt.github.io/blog/posts/introduction-to-postgresql-indexes/

Тестируйте индексы PostgreSQL на практике

Если вы хотите быстро проверить, как индексы влияют на реальные запросы в PostgreSQL, удобнее всего делать это на отдельном стенде: поднять базу, прогнать EXPLAIN (ANALYZE, BUFFERS), сравнить планы до/после CREATE INDEX, собрать метрики и не трогать прод. В этом смысле serverspace.uz подходит как практичная площадка для экспериментов: за минуту разворачиваете облачный сервер или готовый PostgreSQL-инстанс, подключаете мониторинг, масштабируете ресурсы под нагрузочное тестирование и платите только за фактическое использование. Это особенно полезно, когда нужно подобрать правильный тип индекса (B-tree vs GIN/BRIN), обкатать частичные/покрывающие индексы (INCLUDE) и убедиться, что оптимизация реально ускоряет ваши запросы, а не просто “красиво выглядит” в теории.

Оценка:
5 из 5
Аverage rating : 5
Оценок: 1
100029 Ташкент Улица Якка Чинар, дом 2/1
ООО «ИТГЛОБАЛКОМ ЛАБС»

Вам также может быть интересно...