Learning Platform
Урок 14.01 · 16 мин
Начальный
IndexSequential scanB-treeTrade-offQuery optimization

В модуле 2 мы видели EXPLAIN мельком и говорили: «оптимизатор сам выберет, как идти за данными». В этом модуле мы наконец-то поймём, из чего он выбирает. И первая развилка, самая базовая, — между Seq Scan и Index Scan. Чтобы её обсуждать, надо разобраться, что такое индекс и почему без него СУБД делает то, что делает.

Этот модуль закрывает курс на финальной нотой: ты уже умеешь писать корректный SQL. Теперь — будем учиться писать быстрый SQL. Это разные навыки, и большая часть разработчиков спотыкается именно на втором — они пишут запросы, которые работают на тестовых 100 строках и кладут базу на боевых 100 миллионах. Различие между ними часто упирается в один-единственный вопрос: «какой план у этого запроса?». Ответить на него учит EXPLAIN, который мы детально разберём в уроке 5. Но прежде, чем читать планы, надо понимать, какие операции у БД вообще есть в арсенале.

Глубокие internals — страницы B-tree, MVCC, costing — для следующего курса. Здесь — интуиция, которой хватит, чтобы написать осмысленный CREATE INDEX и не закопать таблицу.

Что делает СУБД без индекса

Представь таблицу customers на миллион строк. Ты пишешь:

SELECT * FROM customers WHERE email = '[email protected]';

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

sequential scan
, или Seq Scan в выводе EXPLAIN.

Сложность такой операции — O(N): миллион строк = миллион сравнений. На маленькой таблице (100 строк) это незаметно. На миллионе — десятки миллисекунд. На миллиарде — десятки секунд. Это и есть «база лагает», когда команда вспоминает про индексы.

Seq Scan vs Index Scan на миллионе строк

Без индекса СУБД читает всю таблицу. С индексом — спускается по дереву и находит нужную запись за log(N) шагов.

Seq Scanбез индекса
строка 1email != target
строка 2email != target
......
строка 999 999email != target
строка 1 000 000email == targetНашли — но прочитали миллион строк
итогоO(N) — 1M операций
Index Scanс B-tree индексом
кореньспустились в нужную ветку
веткаспустились в нужный лист
листнашли указатель на строку
heapпрочитали саму строку
итого~20 операций для 1Mlog2(1M) ≈ 20. Реальное B-tree ещё компактнее за счёт высокого fan-out.

Разница между «миллион» и «двадцать» — это и есть та самая магия индексов, на которой держится вся быстрая БД.

Аналогия: алфавитный указатель в книге

Если читать всю книгу подряд в поисках слова «реляция» — это Seq Scan. Если перевернуть на алфавитный указатель в конце, найти «реляция: с. 47, 152, 198» и сразу открыть нужные страницы — это Index Scan. Указатель и есть индекс: отдельная структура данных, упорядоченная по ключу, в которой рядом с ключом лежит «адрес» (страница в книге, физическая позиция строки в таблице).

Ключевые свойства этой аналогии работают и для БД:

  • Указатель — отдельная сущность. В БД индекс — это отдельный объект, со своим местом на диске. Таблица и индекс хранятся раздельно.
  • Указатель упорядочен. Слова в алфавитном порядке, иначе бинарный поиск по указателю невозможен. В B-tree ключи тоже отсортированы.
  • Указатель надо поддерживать. Если в книгу добавили главу — указатель надо переиздать. Если в таблицу добавили строку — индекс надо обновить.

Эти три свойства дают и плюсы, и минусы. Поговорим о минусах честно.

Аналогия работает даже глубже. Возможно, ты видел в учебниках по программированию тематический указатель — отдельно от алфавитного. Это второй индекс на тот же текст, но с другим ключом. Точно так же в БД на одну таблицу можно создать сколько угодно индексов — по разным колонкам, по разным выражениям, по разным комбинациям. Каждый покрывает свой класс запросов. И каждый, разумеется, требует места и обновлений.

Trade-off: индексы стоят денег

Индекс — это не бесплатный «делайте всё быстрее». Это пакт: на чтение становится в сотни раз быстрее, на запись становится дороже. Конкретно:

  • INSERT теперь требует вставки и в таблицу, и в каждый индекс на ней. Если на таблице 5 индексов — каждая вставка стоит 6 операций вместо 1.
  • UPDATE колонки, которая участвует в индексе, — это удаление старого ключа из индекса и вставка нового. Если индекса нет — это просто перезапись строки.
  • DELETE аналогично — надо вычистить ссылки на удалённую строку из всех индексов.
  • Дисковое пространство. Индекс на колонку email (текст) может занимать 10-30% от размера самой таблицы. Пять индексов = таблица потолстела вдвое.

Поэтому правило, к которому мы будем возвращаться весь модуль:

индекс — это инвестиция
, а не «всегда хорошо». На read-heavy таблице — обычно окупается. На write-heavy лог с редкими выборками — может оказаться чистым убытком.

Trade-off индексов: что мы тратим, что выигрываем

Слева — то, что ускоряется. Справа — то, что замедляется. Решение принимать с учётом профиля нагрузки на таблицу.

индексы ускоряютчтение
SELECT по фильтруO(log N) вместо O(N)
JOIN с FKиндекс на join-key
ORDER BY + LIMITtop-N без сортировки
EXISTS / INточечный поиск
индексы замедляютзапись и расходуют место
INSERT+ вставка в каждый индекс
UPDATE+ если колонка индексирована
DELETE+ вычистка из всех индексов
disk10-30% от размера таблицы

Смотрим руками: создаём индекс и видим разницу

Наша вселенная небольшая — customers всего 12 строк, разница между Seq Scan и Index Scan будет физически незаметна. Но PostgreSQL в выводе EXPLAIN всё равно покажет, что именно он выбрал. На такой маленькой таблице оптимизатор почти всегда предпочтёт Seq Scan — даже с индексом! — потому что прочитать 12 строк дешевле, чем спуститься по дереву. И это правильно: для маленьких таблиц индекс не нужен.

Сначала посмотрим, какой план выберет PostgreSQL без индекса по email:

PostgreSQL

В выводе ты увидишь что-то вроде Seq Scan on customers ... Filter: (email = '[email protected]'). Это значит: «открыл таблицу, прочитал все строки, отдал только подходящие». Обрати внимание на оценочные rows и cost — мы вернёмся к ним в уроке про EXPLAIN.

А теперь создадим индекс — стандартный B-tree, по умолчанию PostgreSQL использует именно его — и посмотрим, изменилось ли что-нибудь:

Создаём индекс по email и пробуем тот же запрос. На 12 строках Seq Scan останется — но индекс уже существует.

PostgreSQL

На таблице в 12 строк план, скорее всего, останется Seq Scan — и это разумно. На таблице в миллион строк PostgreSQL точно переключится на Index Scan. В реальной работе ты увидишь оба варианта; в обоих случаях оптимизатор делает то, что считает быстрее.

Ещё одна важная деталь: уже существующий индекс под уникальное ограничение — это тот же B-tree. PostgreSQL автоматически создаёт индекс под каждый UNIQUE и PRIMARY KEY. Проверь:

Посмотрим, какие индексы PostgreSQL создал автоматически:

PostgreSQL

Ты увидишь индексы вида customers_pkey (по PRIMARY KEY) и customers_email_key (по UNIQUE). На колонки id и email индексы уже есть — это не наша работа, это побочный эффект ограничений целостности. Поэтому, когда говорят «не индексируй колонки, на которых уже есть UNIQUE» — это потому, что индекс уже есть, дублировать его бессмысленно.

Когда индекс действительно ускоряет

Дальше в модуле мы будем много говорить про случаи, когда индекс не помогает. Чтобы сразу выработать интуицию, вот короткий каталог сценариев, на которых индекс работает блестяще:

  • Селективный фильтр. «Найди клиента с конкретным email-ом» — выбирает 1 строку из миллиона. Индекс читает 1 страницу heap; Seq Scan читает миллион. Разница в шесть порядков.
  • Точечный JOIN. LEFT JOIN orders o ON c.id = o.customer_id — на каждой строке customers спускаемся в индекс по o.customer_id. Без индекса — Hash Join или Nested Loop с Seq Scan, что на больших таблицах катастрофа.
  • Top-N запрос. ORDER BY created_at DESC LIMIT 10. С индексом по created_at — спускаемся к самому большому ключу, читаем 10 листов, отдаём. Без индекса — сортируем весь миллион.
  • EXISTS / NOT EXISTS. «Есть ли у клиента хоть один заказ» — индексу достаточно найти первое совпадение и остановиться. Это semi-join — он редко доходит до конца индекса.

И наоборот, индекс обычно не нужен, если:

  • Таблица маленькая (десятки-сотни строк).
  • Фильтр оставляет большую долю таблицы (больше 10%).
  • Колонка в фильтре часто меняется или часто содержит NULL.
  • Таблица write-heavy, а соответствующее чтение происходит редко.

В обоих списках слово «обычно» — потому что у любого правила есть исключения, и EXPLAIN ANALYZE всегда последнее слово.

Сценарий «найди клиента по email» — типичный селективный фильтр. На нашей вселенной — Seq Scan, но логика плана видна.

PostgreSQL

Что находится «между строк»: indexkey vs heap

Чуть-чуть про физику. Когда мы говорим «индекс ссылается на строку», что именно лежит в листе индекса? Пара (ключ, TID), где TID —

tuple identifier
: пара «номер страницы heap + позиция строки на странице». Spielable example: в листе индекса по email лежит запись ('[email protected]', (42, 3)) — это значит «строка с этим email лежит на странице 42, позиция 3».

Когда план — Index Scan, PG делает два чтения: одно в индексе (чтобы найти TID), второе в heap (чтобы прочитать саму строку по TID). Когда план — Index Only Scan, второе чтение пропускается, потому что все нужные данные уже есть в индексе. Это очень быстрый план, и мы научимся его добиваться в уроке 4 (через INCLUDE) и уроке 5 (про EXPLAIN).

Удаление и пересоздание индекса

Раз уж разобрались, как индекс создаётся — стоит сказать про обратную операцию. Удалить индекс — это DROP INDEX:

DROP INDEX idx_customers_country;

Это безопасная команда: данные таблицы не трогаются, только удаляется отдельная структура. Запросы продолжат работать — просто медленнее, через Seq Scan. После такой команды стоит запустить EXPLAIN на тех же запросах: если план остался прежним, значит, индекс и так не использовался, и удаление было полезной экономией.

Иногда индекс надо пересоздать (когда подозреваешь bloat — рост индекса из-за UPDATE/DELETE, который ускоряет, если индекс «раздулся»). Делать это надо аккуратно: обычная команда REINDEX блокирует таблицу на чтение/запись. В проде используют REINDEX CONCURRENTLY, который пересоздаёт индекс без блокировок (но дороже по CPU).

Тонкости пересоздания и bloat — это уже территория следующего курса. Сейчас знай главное: создавать и удалять индексы — обычные DDL-операции, которые можно делать в любое время. Главное — не на пиковой нагрузке без CONCURRENTLY.

Карта типов индексов в PostgreSQL

PostgreSQL — необычно щедрый в выборе индексов. Кроме B-tree он умеет:

Типы индексов в PostgreSQL и для чего они

B-tree — рабочая лошадка (равенство, диапазоны, ORDER BY). Остальные — для специальных типов данных.

B-treeпо умолчанию
хорош для=, <, >, BETWEEN, LIKE 'x%', ORDER BY
когда применятьпочти всегда
GINinverted index
хорош дляJSONB, массивы, полнотекст
когда применять?, @>, фулл-текст
GiSTgeneralized search tree
хорош длягеоданные, range types
когда применятьPostGIS, &&, @>
BRINblock range index
хорош дляогромные упорядоченные данные
когда применятьtime-series, logs

В этом курсе мы говорим только про B-tree — он покрывает 90% случаев. Но запомни: если в проде наткнёшься на JSONB-фильтры или полнотекстовый поиск, ответ — не «забей и пиши Seq Scan», а «возьми правильный тип индекса».

Нюансы, которые сразу стоит знать

Несколько вещей, которые мы детально разберём в следующих уроках, но знать про них надо уже сейчас:

  • Индекс не обязан быть только по одной колонке. Можно индексировать сразу несколько колонок (composite index, урок 4), а также выражения вроде LOWER(email) (expression index) — об этом в уроке 3.
  • Оптимизатор не обязан использовать индекс. Даже если индекс есть и колонка в WHERE, PostgreSQL может выбрать Seq Scan, если посчитает его дешевле. Часто это правильное решение.
  • Удалить индекс — это DROP INDEX. Не путать с DROP TABLE. Удаление индекса безопасно — данные таблицы целы, запросы продолжают работать, просто медленнее.

Где живёт статистика «использован ли индекс»

После создания индекса хочется понять — а реально ли его кто-то использует? PostgreSQL ведёт счётчики на каждый индекс в системном view pg_stat_user_indexes:

  • idx_scan — сколько раз индекс был использован для Index Scan (или Bitmap Index Scan).
  • idx_tup_read — сколько раз PG достал ключ из индекса.
  • idx_tup_fetch — сколько раз PG потом достал строку из heap по TID.

В команде такая практика: раз в месяц смотреть idx_scan = 0 индексы и обсуждать — нужны они или их пора DROP INDEX. На большой таблице каждый лишний индекс — это процент производительности INSERT, который можно вернуть.

Статистика использования индексов — кто работает, кто пылится:

PostgreSQL
Проверка знанийKnowledge check
У тебя таблица events на 500 миллионов строк, в которую идёт ~1000 INSERT в секунду из продакшен-сервиса. Запросов на чтение почти нет — только аналитики раз в неделю запускают тяжёлые батчи. Стоит ли создавать B-tree индексы по полям, по которым иногда ищут аналитики?
ОтветAnswer
Скорее нет, чем да. Это write-heavy таблица: 1000 INSERT/сек значит, что каждый новый индекс будет замедлять вставку (нужно ещё и в индекс положить ключ). А чтение происходит редко и большими батчами — для них Seq Scan часто оптимальнее Index Scan, потому что аналитика всё равно прочитает большую часть таблицы. Альтернативы: (1) использовать BRIN — он почти не влияет на запись и хорош на упорядоченных данных; (2) делать индексы только на колумнах самых частых запросов аналитиков; (3) переливать данные в OLAP-хранилище (ClickHouse, BigQuery), где модель чтения другая.

Что мы изучим в следующих уроках

Каркас модуля:

  • Урок 2: B-tree на пальцах — почему log(N), как устроено дерево, что такое диапазонный поиск.
  • Урок 3: Где индекс работает (равенство, диапазон, prefix-LIKE, ORDER BY), где игнорируется (функция на колонке, суффикс-LIKE, type mismatch).
  • Урок 4: Composite индексы — индекс по нескольким колонкам, правило leftmost prefix, Bitmap Heap Scan.
  • Урок 5: EXPLAIN — главный инструмент перфоманс-разбора. Виды узлов, оценки vs факты, чтение плана.
  • Урок 6: Антипаттерны — каталог типичных ошибок и их рефакторинг.

Параллельно с этими темами мы будем писать EXPLAIN снова и снова, чтобы зарегистрировать в моторной памяти выводы PostgreSQL.

Физическое устройство B-tree на диске Как оптимизатор решает, использовать ли индекс

Чек-лист

  • Без индекса СУБД делает Seq Scan — читает таблицу целиком, O(N).
  • С B-tree индексом поиск становится логарифмическим — O(log N).
  • Аналогия: индекс = алфавитный указатель в книге; упорядочен по ключу, рядом с ключом — адрес строки.
  • На маленьких таблицах оптимизатор может игнорировать индекс — это нормально, Seq Scan дешевле.
  • Индексы — это инвестиция: ускоряют чтение, замедляют запись, занимают место.
  • PostgreSQL автоматически создаёт индексы под PRIMARY KEY и UNIQUE.
  • В курсе говорим про B-tree (90% случаев); GIN/GiST/BRIN/Hash — для специальных задач.
  • pg_stat_user_indexes.idx_scan — счётчик использования индекса. idx_scan = 0 за месяц — кандидат на удаление.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Без индекса по колонке email PostgreSQL выполняет запрос `SELECT * FROM customers WHERE email = '[email protected]'`. Какую стратегию он использует и какова её сложность?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6