Тема 10

Индексы

Индексы

В предыдущих темах мы изучали реляционную модель, логическое и физическое проектирование таблиц. Но до сих пор данные читались последовательным перебором: чтобы найти одну книгу по названию или один заказ по номеру, СУБД была вынуждена прочитать всю таблицу с диска страницу за страницей. В учебной базе с двумя тысячами строк это занимает миллисекунды, но в промышленной базе с миллионами записей последовательное сканирование превращает запрос в секундное ожидание и блокирует дисковую подсистему.

Эта тема посвящена индексам — вспомогательным структурам данных, которые связывают значения столбцов с физическими адресами строк таблицы (TID — Tuple Identifier). Мы разберём, как устроено классическое B-дерево, почему оно одинаково быстро находит единичные строки и диапазоны, как работают составные, частичные и покрывающие индексы, а также изучим специализированные типы индексов в PostgreSQL: GIN, GiST, BRIN и Hash.

Зачем нужны индексы

Последовательное чтение против прямого доступа

Пусть таблица заказов orders масштаба scale=50 содержит 1 000 000 строк и занимает около 450 МБ (примерно 25 000 страниц по 8 КиБ). Если мы выполняем простой запрос:

SELECT * FROM bookstore.orders WHERE customer_id = 12345;

Без индекса сервер выполняет последовательное сканирование (Seq Scan) 5: процесс чтения считывает с диска в буферный кэш все 25 000 страниц, проверяя каждую запись на соответствие предикату customer_id = 12345. Если искомых заказов всего три штуки, 99,99 % прочитанных данных оказываются бесполезными.

Индекс представляет собой упорядоченный список пар (ключ, TID). Зная customer_id = 12345, сервер за 3–4 операции чтения переходит по ветвям дерева к нужной листовой странице индекса, находит три указателя на строки (номер страницы и смещение внутри страницы) и считывает ровно те страницы таблицы, где эти строки физически находятся. Число дисковых обращений сокращается с 25 000 до 6–7.

Понятие селективности

Всегда ли индекс выгоднее последовательного сканирования? Нет. Эффективность индекса зависит от селективности предиката (англ. selectivity) — доли строк таблицы, удовлетворяющих условию 23.

  • Если предикат выбирает 0,01 % строк (высокая селективность), индекс даёт колоссальный выигрыш.
  • Если же предикат выбирает 40 % строк (низкая селективность), обращение по индексу порождает 400 000 случайных чтений страниц таблицы. Каждое случайное чтение страницы (random_page_cost) на шпиндельных дисках и даже на SSD обходится планировщику дороже последовательного чтения (seq_page_cost), при котором ядро ОС использует упреждающее чтение (readahead). В этом случае планировщик PostgreSQL сознательно отказывается от индекса и выбирает последовательное чтение всей таблицы 1.

B-дерево: фундаментальная структура хранения

По умолчанию в PostgreSQL команда CREATE INDEX создаёт B-дерево (точнее, его модификацию B^+-дерево с алгоритмом Lehman & Yao 46).

Устройство B-дерева в PostgreSQL

B-дерево состоит из страниц размером 8 КиБ, организованных в сбалансированную иерархию 16:

  1. Метастраница (Meta Page): блок 0, содержащий версию реализации, указатель на текущий корневой блок и статистику дерева.
  2. Корневой узел (Root Page): вершина дерева, содержащая пары «ключ — номер дочернего блока».
  3. Внутренние узлы (Internal Pages): промежуточные уровни маршрутизации поиска.
  4. Листовые узлы (Leaf Pages): нижний уровень дерева, хранящий пары (ключ, TID). Все листовые страницы связаны между собой двусвязным списком через указатели btpo_prev и btpo_next.

Благодаря двусвязному списку листьев B-дерево одинаково эффективно обслуживает операторы равенства =, неравенства <, <=, >, >=, проверку принадлежности диапазону BETWEEN и упорядочивание ORDER BY.

Расчёт высоты дерева

Ключевое достоинство B-дерева — огромный коэффициент ветвления (англ. fanout). На одной странице 8 КиБ помещается от 200 до 400 ключей с указателями 1.

  • При коэффициенте ветвления B = 300:
    • Уровень 1 (корень): 300 записей.
    • Уровень 2: 300^2 = 90\,000 записей.
    • Уровень 3: 300^3 = 27\,000\,000 записей.
    • Уровень 4: 300^4 = 8\,100\,000\,000 записей (8 миллиардов!).

Для таблицы в 1 миллион строк глубина дерева составляет всего 2–3 уровня (tree_level). Это означает, что доступ к любой строке гарантированно требует не более 3–4 чтений страниц.

Внутреннюю структуру B-дерева можно исследовать через стандартное расширение pageinspect:

SELECT tree_level, internal_pages, leaf_pages
FROM pgstatindex('bookstore.orders_pkey');
 tree_level | internal_pages | leaf_pages
------------+----------------+------------
          2 |             15 |       2740

Префиксный поиск и классы операторов

B-дерево по текстовому столбцу с типовой локалью использует сортировку по правилам национального алфавита (collation). В условиях вида WHERE email LIKE 'ivan%' планировщик часто не может использовать стандартный индекс, так как порядок символов в ICU-локали может отличаться от строгого побайтового сравнения.

Для поддержки быстрого поиска по префиксу шаблонных строк LIKE 'abc%' индекс создаётся со специальным классом операторов text_pattern_ops 5:

CREATE INDEX customer_email_prefix_idx ON bookstore.customer (email text_pattern_ops);

С этим классом операторов B-дерево упорядочивает байты по кодам ASCII/UTF-8, трансформируя условие LIKE 'ivan%' в диапазонное сканирование email >= 'ivan' AND email < 'ivao'.

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

Правило левого префикса в составных индексах

Индекс может быть построен по нескольким столбцам:

CREATE INDEX orders_cust_created_idx ON bookstore.orders (customer_id, created_at);

В таком индексе записи отсортированы строго лексикографически: сначала по первому столбцу customer_id, а строки с одинаковым customer_id — по второму столбцу created_at.

Порядок столбцов в составном индексе
В составном индексе (customer_id, created_at) данные упорядочены сначала по покупателю, а внутри одного покупателя — по дате заказа; поиск только по дате не может использовать дерево эффективно

Отсюда следует фундаментальное правило левого префикса 2:

  • Запрос WHERE customer_id = 10 AND created_at >= '2024-01-01' использует индекс целиком;
  • Запрос WHERE customer_id = 10 использует дерево для поиска диапазона покупателя;
  • Запрос WHERE created_at >= '2024-01-01' не может использовать индекс эффективно, так как даты распределены по всем покупателям и не образуют непрерывного диапазона.

Покрывающие индексы (INCLUDE) и Index Only Scan

Обычное индексное сканирование (Index Scan) сначала находит TID в индексе, а затем обращается к таблице («куче» — heap), чтобы извлечь остальные столбцы запроса. Каждое обращение к куче требует чтения страницы.

Если все столбцы, запрошенные клиентом, уже содержатся в самом индексе, сервер может выполнить сканирование только индекса (Index Only Scan) 15. Но если добавить столбец в индекс обычным образом (CREATE INDEX ON orders (customer_id, status)), дерево будет сортировать данные и по status, тратя процессорное время на сравнение строк и расщепление страниц.

Конструкция INCLUDE добавляет неключевые столбцы только в листовые страницы дерева без участия в сортировке:

CREATE INDEX orders_cust_status_inc_idx ON bookstore.orders (customer_id) INCLUDE (status);

Запрос SELECT status FROM bookstore.orders WHERE customer_id = 42 считывает только индекс.

Тонкость: из-за механизма MVCC (многоверсионности, тема 12) индекс не хранит номера транзакций xmin/xmax. Чтобы убедиться, что найденная строка видна текущему снимку, PostgreSQL обращается к карте видимости (Visibility Map, VM) таблицы. Если бит страницы в карте видимости равен 1 (все строки страницы зафиксированы и видны всем), обращение к куче исключается полностью (Heap Fetches: 0) 1.

Специализированные индексы

Частичные индексы (Partial Indexes)

Если запрос фильтрует строки по редкому значению флага, нет смысла индексировать миллионы неинтересных строк. Частичный индекс содержит предикат WHERE:

CREATE INDEX orders_unprocessed_idx ON bookstore.orders (created_at)
WHERE status IN ('pending', 'processing');

Если незавершённых заказов в интернет-магазине 0,5 %, частичный индекс займёт в 200 раз меньше места в оперативной памяти и не будет замедлять вставки завершённых заказов.

Индексы по выражениям

Если приложение часто ищет данные без учёта регистра, условие WHERE lower(email) = 'test@example.com' при обычном индексе по email приведёт к Seq Scan, так как функция lower() скрывает значение ключа от планировщика. Решение — индекс по выражению:

CREATE INDEX customer_lower_email_idx ON bookstore.customer (lower(email));

Другие методы доступа: GIN, GiST, BRIN, Hash

PostgreSQL поддерживает подключаемую архитектуру методов доступа к индексам 15:

ТипАрхитектураОсновное применение
B-treeСбалансированное деревоРавенство, диапазоны, сортировка чисел, дат, строк
HashХеш-таблица с бакетамиБыстрый поиск только по равенству =, компактнее B-tree
GINОбобщённый инвертированный индексПолнотекстовый поиск, массивы, документы JSONB (@>), поиск подстрок с pg_trgm
GiSTДерево сбалансированных предикатовГеометрия PostGIS, временные интервалы (tsrange), ограничения исключения EXCLUDE
BRINИндекс диапазонов блоковОгромные таблицы (>100 ГБ), где данные упорядочены физически (даты логов, автоинкремент)

Инвертированный индекс GIN

В B-дереве строка индекса содержит (значение, TID). В GIN (Generalized Inverted Index) 7 индексируются элементы, входящие в состав составных значений (слова текста, элементы массива или ключи/значения JSONB). GIN строит список TID для каждого отдельного элемента:

CREATE INDEX book_attrs_gin_idx ON bookstore.book USING gin (attrs);

Запрос SELECT * FROM bookstore.book WHERE attrs @> '{"binding": "hard"}'; мгновенно находит книги по инвертированному списку ключа binding. А расширение pg_trgm позволяет с помощью GIN ускорять поиск произвольных подстрок LIKE '%слово%' за счёт индексирования 3-буквенных сочетаний (триграмм).

Блочный индекс BRIN

В аналитических таблицах и журналах событий строки вставляются монотонно по времени. Значит, на страницах в начале диска лежат старые даты, а в конце — свежие. BRIN (Block Range Index) хранит только минимальное и максимальное значение столбца для диапазона страниц (по умолчанию 128 блоков = 1 МБ).

  • Размер BRIN-индекса для таблицы на 100 млн строк составляет считанные сотни килобайт!
  • При поиске WHERE created_at >= '2024-06-01' BRIN позволяет мгновенно отбросить 95 % страниц диска, читая только блоки с подходящими экстремумами.

Эксплуатация индексов и пагинация

Цена индексов и HOT-обновления

Каждый созданный индекс ускоряет чтение, но замедляет INSERT, UPDATE и DELETE, так как СУБД обязана синхронно обновить все деревья индексов.

Для смягчения этой проблемы в PostgreSQL реализован механизм HOT (Heap-Only Tuples) 1: если при операции UPDATE значения индексируемых столбцов не изменились, а на текущей странице таблицы есть свободное место, новая версия строки создаётся на той же странице без добавления записей в индексы. Старая и новая версии связываются цепочкой прямо внутри страницы.

Обслуживание: CONCURRENTLY и разрастание

Команда CREATE INDEX требует эксклюзивной блокировки SHARE, разрешающей чтение, но блокирующей любые операции записи INSERT/UPDATE/DELETE. На рабочей базе с миллионами строк создание индекса может длиться минуты, парализуя сервис.

Для изменения структуры без простоя применяется конкурентное создание 5:

CREATE INDEX CONCURRENTLY idx_orders_date ON bookstore.orders (created_at);

В режиме CONCURRENTLY сервер сканирует таблицу в два прохода, не блокируя пишущие транзакции. Если при построении произошёл сбой, индекс помечается как INVALID и требует удаления либо перестроения утилитой REINDEX CONCURRENTLY.

Пагинация по ключу (Keyset) против OFFSET

При отображении каталогов в веб-приложениях традиционно используется смещение LIMIT 20 OFFSET 100000.

Пагинация по ключу против OFFSET
При OFFSET база перебирает и отбрасывает все предшествующие строки; keyset-пагинация прыгает по индексу сразу к началу нужной страницы

Конструкция OFFSET 100000 вынуждает сервер прочитать по индексу 100 000 строк, отбросить их и вернуть только следующие 20. На 5000-й странице сайта запрос начинает зависать на секунды.

Промышленный стандарт решения этой проблемы — пагинация по ключу (keyset pagination / seek method) 2:

-- Быстрый переход к следующей странице:
SELECT order_id, created_at, status
FROM bookstore.orders
WHERE (created_at, order_id) < ('2024-05-15 14:30:00+03', 451200)
ORDER BY created_at DESC, order_id DESC
LIMIT 20;

Благодаря составному индексу (created_at, order_id) планировщик не перебирает 100 000 строк, а делает точечный спуск по дереву прямо к первому элементу следующей страницы, гарантируя одинаковое время отклика (1–2 мс) как на первой, так и на миллионной странице.

Итоги темы

Индексы — главный инструмент обеспечения производительности реляционных баз данных. Классическое B-дерево обеспечивает логарифмическую сложность O(\log N) операций поиска, вставки и диапазонного чтения. Составные индексы подчиняются правилу левого префикса, а конструкция INCLUDE реализует Index Only Scan, исключая обращения к страницам кучи при актуальной карте видимости. Для специализированных сценариев PostgreSQL предоставляет инвертированные индексы GIN для JSONB и текстового поиска, а также компактные зонные индексы BRIN для упорядоченных во времени журналов.

В следующей теме мы изучим, как планировщик запросов на основе стоимостной модели решает, когда именно использовать эти индексы, как читать планы EXPLAIN (ANALYZE, BUFFERS) и оптимизировать сложные соединения.

Литература

  1. Рогов Е. В. PostgreSQL 16 изнутри. — М.: ДМК Пресс, 2024.
  2. Winand M. SQL Performance Explained. — 2012. https://use-the-index-luke.com/
  3. Silberschatz A., Korth H. F., Sudarshan S. Database System Concepts. 7th ed. — McGraw-Hill, 2019.
  4. Lehman P. L., Yao S. B. Efficient Locking for Concurrent Operations on B-Trees. — ACM Transactions on Database Systems, 1981, Vol. 6, No. 4, С. 650–670.
  5. PostgreSQL Global Development Group. PostgreSQL 16 Documentation. Chapter 11. Indexes. — 2024. https://www.postgresql.org/docs/16/indexes.html
  6. PostgreSQL Global Development Group. PostgreSQL 16 Documentation. Chapter 64. B-Tree Indexes. — 2024. https://www.postgresql.org/docs/16/btree.html
  7. Bartunov O., Sigaev T. Generalized Inverted Index for PostgreSQL. — 2006.