Базы данных
Индексы: B-Tree и как они работают
Цели урока
- Понять структуру B+ Tree и почему поиск O(log n)
- Применять prefix rule при создании составных индексов
- Создавать covering index для Index Only Scan
- Диагностировать и устранять index bloat
Запрос без индекса по таблице из 50 миллионов строк - это 8 секунд ожидания. Тот же запрос с правильным индексом - 2 миллисекунды. B-Tree превращает O(n) в O(log n), но только если понимать как его создавать и обслуживать.
- **E-commerce:** индекс (user_id, status) ускоряет загрузку заказов пользователя
- **Analytics:** covering index позволяет считать агрегаты без обращения к heap
- **High-write системы:** fillfactor и мониторинг bloat предотвращают деградацию
- **Отладка:** EXPLAIN ANALYZE показывает какой scan используется и почему
Структура B-Tree
Таблица `orders` с 50 миллионами строк. Запрос `WHERE user_id = 42` без индекса читает все 50 млн строк - полный sequential scan. С индексом B-Tree тот же запрос делает ~25 операций чтения. Разница - от минут до миллисекунд.
**B-Tree (Balanced Tree)** - сбалансированное дерево поиска, где каждый узел хранит несколько ключей и ссылок на дочерние узлы. В PostgreSQL B-Tree - это B+ Tree: все данные только в листьях, внутренние узлы хранят только ключи для навигации.
Листья связаны двусвязным списком - это ключевое свойство B+ Tree. Благодаря ему работают **range-запросы** (`WHERE id BETWEEN 100 AND 200`): находим первый лист, затем идём по цепочке не возвращаясь в корень.
**Порядок B-Tree в PostgreSQL:** страница = 8KB, каждая страница хранит сотни ключей. Высота дерева для 50 млн строк = 3-4 уровня. Значит любой поиск = 3-4 чтения страниц.
Почему листовые страницы B+ Tree связаны в список?
Составные индексы и порядок столбцов
Запрос `WHERE status = 'active' AND created_at > '2024-01-01'` - два условия. Можно создать два отдельных индекса, но PostgreSQL скорее всего выберет один. Лучше создать **составной индекс** `(status, created_at)` - и тогда оба условия используются за одно сканирование.
Ключевое правило составного индекса: **prefix rule**. Индекс `(a, b, c)` работает для запросов по `(a)`, `(a, b)`, `(a, b, c)`. Но запрос только по `(b)` или `(c)` индекс не использует - нет точки входа в дерево.
**Cardinality первой колонки** - важный фактор при выборе порядка. Поле с высокой кардинальностью (много уникальных значений) как первый ключ сильнее фильтрует данные. Но если запрос всегда фильтрует по обоим полям - порядок менее критичен.
**Сколько индексов создавать?** Каждый индекс замедляет INSERT/UPDATE/DELETE (нужно обновлять B-Tree). Правило: индекс создаётся под конкретный медленный запрос, а не "на всякий случай". Смотрите `pg_stat_user_indexes` - неиспользуемые индексы видны там.
Индекс создан на (country, city, street). Какой запрос его использует?
Covering Index
Обычный индекс возвращает heap tid, затем база идёт в основную таблицу за данными - это **heap fetch**. Каждый heap fetch = случайное чтение диска. При большом количестве строк это дорого. **Covering Index** содержит все нужные запросу колонки прямо в индексе, heap fetch не нужен.
В PostgreSQL covering index создаётся через `INCLUDE`. Разница с добавлением колонки в ключ: колонки из INCLUDE не участвуют в сортировке и поиске по индексу, они просто хранятся в листовых страницах как payload.
**Visibility Map:** Index Only Scan работает идеально только если страница помечена как all-visible в visibility map (т.е. все строки видимы всем транзакциям). Иначе PostgreSQL всё равно сходит в heap для проверки видимости. VACUUM обновляет visibility map.
Чем INCLUDE отличается от добавления колонки в ключ индекса?
Index-Only Scan
**Index Scan** vs **Index Only Scan** - принципиальная разница. Index Scan: находим tid в индексе, идём в heap за строкой. Index Only Scan: все нужные данные в индексе, heap не трогаем. При высокой нагрузке Index Only Scan может быть в 10-100 раз быстрее.
**Bitmap Index Scan** - четвёртый тип. Применяется когда запрос возвращает много строк (низкая selectivity, но индекс всё равно быстрее seq scan). PostgreSQL сначала строит bitmap совпавших страниц из индекса, затем читает heap страницы в порядке их физического расположения - снижая случайные I/O.
Запрос SELECT amount FROM orders WHERE order_id = 42. Индекс: PRIMARY KEY (order_id) INCLUDE (amount). Какой scan будет выбран?
Index Bloat и обслуживание
После миллиона UPDATE и DELETE индекс превратился в 8GB при таблице в 2GB. Это **index bloat** - мёртвые версии строк накапливаются в B-Tree страницах, не освобождаясь автоматически. Запросы замедляются, потому что читают страницы с мусором.
Причина: PostgreSQL использует MVCC - старые версии строк не удаляются сразу. VACUUM убирает мёртвые кортежи из heap, но в B-Tree страницы помечаются как доступные для повторного использования - они не возвращаются ОС и не уменьшают файл.
**Fillfactor** - проактивная защита от bloat при UPDATE-heavy таблицах. По умолчанию B-Tree заполняет страницы на 100%. При fillfactor=70 оставляет 30% свободного места - UPDATE может обновить строку на той же странице (HOT update), не создавая новую версию в индексе.
**Автовакуум и индексы:** autovacuum убирает мёртвые кортежи и обновляет visibility map. Если autovacuum не справляется (много writes), индекс bloat растёт. Мониторьте `pg_stat_user_tables.n_dead_tup` - если > 10-20% от live строк, нужна ручная VACUUM.
После REINDEX CONCURRENTLY размер индекса уменьшился с 8GB до 1GB. Что означает это расхождение?
Индексы B-Tree
- B+ Tree: данные в листах, связанных в список для range-запросов
- Высота дерева 3-4 уровня для 50 млн строк - любой поиск за ~4 чтения
- Составной индекс: prefix rule - запрос по (a) и (a,b) работает, по (b) - нет
- INCLUDE создаёт covering index: данные в листах без участия в поиске
- Index Only Scan: heap не нужен если все колонки в индексе
- Index bloat: VACUUM чистит heap, но не сжимает B-Tree страницы - нужен REINDEX
Связанные темы
B-Tree - базовый тип индекса, но не единственный. Для других задач используются специализированные структуры.
- GIN, GiST, BRIN — Специализированные индексы для текста, гео, временных рядов
- Query Optimization — Как планировщик выбирает между индексами
- MVCC — Почему возникает index bloat
Вопросы для размышления
- Как выбрать порядок колонок в составном индексе если запросы используют разные комбинации условий?
- В каких ситуациях Index Only Scan может быть хуже обычного Index Scan?
- Как часто стоит проверять неиспользуемые индексы в production базе?