← назад к разделу

Два одинаковых с виду запроса: один отвечает мгновенно, другой — висит и кладёт стенд. Разница почти всегда в одном слове: индекс. Понимать индексы «на пальцах» полезно каждому, кто пишет запросы: это объясняет и медленный поиск в приложении, и то, почему ваш собственный невинный SELECT вдруг стал проблемой.

без индекса — перебор всех строк нужная строка 2 млн строк — и все прочитаны с индексом — сразу к нужной индекс по created_at нужная строка но индекс окупается, только если сужает выборку

Без индекса база читает все строки подряд; индекс ведёт прямо к нужным — пока их немного относительно таблицы.

Обязательно

Полный перебор: как база ищет без индекса

Выполняя SELECT * FROM orders WHERE customer_id = 'cus-01', база по умолчанию делает ровно то, что делали бы вы с бумажной книгой без оглавления: читает всё подряд и откладывает подходящее. Это называется полным перебором (sequential scan). На тысяче строк он незаметен, на ста миллионах — это минуты работы дисков.

Важное следствие: на маленьком тестовом стенде всё летает всегда. Медленные запросы — болезнь больших объёмов, поэтому проблемы производительности не видны на стенде с сотней записей и внезапно расцветают на проде.

Индекс — оглавление таблицыспросят на собеседовании

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

Есть индекс по customer_id — и база идёт к нужным строкам через него, почти не замечая роста таблицы: от удвоения строк поиск прибавляет один шаг, а не вдвое работы. Слово «почти» тут не для красоты: если под условие подходит заметная доля таблицы, прыгать по ссылкам дороже, чем прочитать всё подряд, и база сознательно вернётся к перебору — про этот случай чуть ниже. Индекса нет — перебор всегда.

Почему бы не проиндексировать всё? У индекса есть цена: он занимает место и замедляет запись — каждый INSERT и UPDATE должен обновить не только таблицу, но и все её индексы. Поэтому индексы заводят точечно. На первичном ключе индекс есть всегда — его база создаёт сама. А вот под внешний ключ PostgreSQL индекс не создаёт, хотя многие уверены в обратном: его заводят руками, и именно его отсутствие даёт медленный JOIN и долгое удаление родительской строки — чтобы выяснить, ссылается ли кто-нибудь на удаляемую строку, база каждый раз перебирает дочернюю таблицу целиком. Остальное — поля частого поиска — по решению разработчиков.

Цену лишнего индекса полезно представлять в числах. Индекс — отдельная структура, которую база обновляет вместе со строкой, поэтому INSERT в таблицу с пятью индексами делает шесть записей вместо одной, а UPDATE колонки, входящей в индекс, ещё и убирает из него старую запись. Плюс место: индекс по текстовой колонке нередко занимает столько же, сколько сама колонка, и таблица вместе с индексами на диске бывает вдвое-втрое больше «чистых» данных.

Отсюда вывод, который редко произносят вслух: лишний индекс — это дефект, а не запас. Индекс, которым не пользуется ни один запрос, только замедляет запись и занимает место. Найти такие можно: в PostgreSQL счётчики обращений лежат в pg_stat_user_indexes, и idx_scan = 0 на живой базе означает «за всё время никто им не воспользовался».

Составной индекс и правило левого префиксаспросят на собеседовании

В песочнице лежит индекс orders (customer_id, created_at DESC), и он работает в половине примеров курса. Индекс по двум колонкам устроен как телефонная книга: сначала по фамилии, внутри фамилии по имени. Поэтому он помогает запросу «заказы покупателя за период» (WHERE customer_id = ? AND created_at > ?) и запросу «все заказы покупателя, свежие первыми» (WHERE customer_id = ? ORDER BY created_at DESC): база находит покупателя и читает его заказы подряд, уже в нужном порядке.

Правило левого префикса говорит, чего он не умеет. Искать только по created_at через этот индекс нельзя: это как искать в телефонной книге по имени без фамилии. Порядок колонок в индексе задаёт, какие запросы он обслуживает: первой ставят колонку, по которой ищут с равенством всегда, второй ту, по которой ограничивают диапазон или сортируют. Два индекса по одной колонке каждый не заменяют один составной: базе придётся склеивать результаты, а сортировку она сделает сама.

Когда индекс есть, но не работаетспросят на собеседовании

Коварный класс ситуаций: индекс существует, а запрос всё равно перебирает всю таблицу, и причины три.

Первая: над столбцом стоит функция. WHERE lower(email) = 'anna@...' не использует индекс по email, потому что сравнивается не email, а lower(email), для базы это другое выражение, и в оглавлении его нет. Тот же эффект у арифметики: WHERE total_amount * 100 > 5000. Лечится либо индексом по выражению, либо переносом функции на другую сторону сравнения.

Вторая: LIKE с процентом в начале. LIKE 'anna%' индексом пользуется, как поиск по первым буквам в алфавитном указателе, а LIKE '%@gmail.com' нет: по окончанию слова оглавление не работает, и это отдельная задача со своими инструментами.

Третья не ошибка вовсе: слабая избирательность. WHERE status = 'PAID', когда оплачены 90 % заказов, читает всю таблицу, потому что прыгать по ссылкам на почти каждую строку дольше, чем прочитать её подряд. База сама выбирает перебор, и это правильное решение.

EXPLAIN: спросить базу, как она будет искатьспросят на собеседовании

Гадать не нужно — базу можно спросить. Добавьте перед запросом слово EXPLAIN:

живой пример

EXPLAIN SELECT * FROM orders WHERE customer_id = 'cus-01';
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Вместо данных база покажет план: как она собирается выполнять запрос. Читать планы глубоко — отдельное искусство, но первый уровень доступен сразу: увидели Seq Scan (последовательный перебор) на большой таблице — вот причина тормозов; Index Scan — работает индекс. Этого достаточно, чтобы разговор с разработчиком начался не с «что-то тормозит», а с «поиск заказов по номеру телефона идёт полным перебором, таблица 40 миллионов строк».

Seq Scan on orders (cost=0.00..1.29 rows=1 width=120) как ищет по какой таблице во что обойдётся сколько строк ждёт размер строки

Строка плана читается по частям, и всё решает первая: Seq Scan значит перебор всей таблицы, Index Scan значит поиск через оглавление.

Два предупреждения, чтобы первый опыт не сбил с толку.

Первое: сам по себе EXPLAIN запрос не выполняет. Он показывает план и оценку — сколько строк база ожидает найти. Оценка бывает мимо: на нашей песочнице этот самый запрос даёт rows=1, а заказов у cus-01 семь. Настоящие числа печатает EXPLAIN (ANALYZE, BUFFERS) — он запрос выполняет и рядом с оценкой показывает факт: rows=1 … (actual … rows=7). С изменяющими запросами тут осторожнее: ANALYZE у UPDATE или DELETE данные и правда изменит.

Второе: «на маленькой таблице индексы не нужны» — не закон, а привычка ожидания. В песочнице двадцать три заказа, а EXPLAIN SELECT * FROM orders WHERE customer_id = 'cus-01' показывает Index Scan using orders_customer_created: индекс по (customer_id, created_at) в схеме есть, и база решила, что через него дешевле. План выбирает она сама и каждый раз заново — ваше дело не угадать его, а прочитать.

Какие индексы уже есть и что EXPLAIN ANALYZE знает, чего не знает EXPLAIN

Прежде чем создавать индекс, смотрят, что есть. В psql команда \d orders печатает колонки таблицы и все её индексы с определениями; из любого клиента то же даёт запрос к pg_indexes:

живой пример

SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'orders';
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Обычный EXPLAIN показывает план, который база собирается выполнить, и оценки: сколько строк она ожидает. Это догадка по статистике. EXPLAIN (ANALYZE, BUFFERS) выполняет запрос по-настоящему и рядом с оценкой печатает факт: сколько строк вышло на каждом шаге, сколько времени он занял и сколько страниц прочитано с диска и из кеша.

живой пример

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 'cus-01' ORDER BY created_at DESC LIMIT 20;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Первое, на что смотрят: rows= в оценке против rows= в факте. Если база ожидала 10 строк, а вышло 100 000, она выбрала план для маленькой выдачи, и виновата устаревшая статистика, лечится ANALYZE orders. Второе: Buffers: shared read= против hit=, чтение с диска против кеша; запрос, который «то быстрый, то медленный», обычно упирается сюда. Осторожность одна: ANALYZE выполняет команду, и для UPDATE или DELETE его запускают внутри транзакции с ROLLBACK.

Соединения: почему JOIN больших таблиц медленный

Соединяя две таблицы, база выбирает один из трёх способов, и все три видны в плане по именам.

Nested Loop — вложенный цикл: для каждой строки левой таблицы ищутся пары в правой. Хорош, когда слева строк мало, а справа есть индекс по колонке соединения: тысяча строк слева — тысяча быстрых поисков. Без индекса справа это тысяча полных перечитываний правой таблицы, и именно так выглядит «запрос работает минутами».

Hash Join — по меньшей стороне строится хеш-таблица в памяти, и по ней проверяется каждая строка большей. Обычный выбор для двух больших таблиц без индексов. Смотреть здесь надо на память: если хеш не поместился, база разбивает работу на порции (Batches: 4 в плане вместо 1) и часть пишет на диск, а время растёт скачком.

Merge Join — обе стороны читаются по порядку и сливаются, как два отсортированных списка. Дёшев, когда порядок уже есть, например обе стороны читаются по индексу; иначе перед ним встанет Sort, и он окажется главной ценой запроса.

Что с этим делать. Первое: индекс по колонке соединения — это обычно вложенный цикл вместо хеша и минуты вместо десятков минут; под внешний ключ такой индекс приходится создавать руками. Второе: фильтровать до соединения, а не после — условие, которое база может применить к одной таблице заранее, уменьшает обе стороны. Третье: не соединять лишнее; таблица в FROM, из которой в SELECT ничего не взято, — частая находка в унаследованных отчётах, и убрать её дешевле, чем ускорять.

Где это применяется

Это знание работает в обе стороны. Внутрь — про ваши запросы: тяжёлый SELECT без ограничений по огромной таблице на общем стенде мешает всем; добавьте LIMIT и условия по индексированным полям. Наружу — про продукт: «поиск в приложении отвечает 40 секунд» — это дефект производительности, и после EXPLAIN вы опишете его на уровне причины, а не симптома. А фраза разработчика «там нет индекса» перестанет быть заклинанием — вы понимаете, что она значит и чем грозит.

Где спотыкаются начинающие:

  • Меряют скорость на пустом стенде. Сто строк летают без всяких индексов; выводы о производительности с маленькой базы не переносятся на большую.
  • Считают, что индекс ускоряет всё. Индекс помогает конкретным условиям поиска — и берёт плату замедлением записи. «Добавьте индексов» — не универсальный рецепт.
  • Пишут условия, убивающие индекс, — функции над столбцом, LIKE с процентом в начале — и удивляются перебору.

Что учить дальше. Цикл «SQL с нуля» пройден. Практическое применение в работе тестировщика — в статье SQL для тестировщика. А когда захочется глубины — раздел PostgreSQL: транзакции и изоляция, устройство индексов, EXPLAIN всерьёз.

Дополнительно: при первом чтении можно пропустить

Глубже: поиск по подстроке: триграммы и полнотекстовый индексрасширенное

Статья про строки обещала, что обе дороги описаны здесь. LIKE '%кофе%' обычный индекс не использует: B-дерево ищет по началу значения, а начало здесь неизвестно. Первая дорога, расширение pg_trgm: индекс по триграммам, трёхбуквенным кусочкам слова, отвечает на любое LIKE и ILIKE с процентом с обеих сторон и на поиск с опечатками.

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX ix_products_title_trgm ON products USING gin (title gin_trgm_ops);
SELECT title FROM products WHERE title ILIKE '%кофе%';

Вторая дорога, полнотекстовый поиск: to_tsvector('russian', title) разбирает текст на слова и приводит их к основе, поэтому «кофемолка» и «кофемолки» совпадают, а искать можно по нескольким словам сразу; индекс тоже GIN. Триграммы берут для коротких полей и подстрок (артикул, имя, адрес), полнотекст для описаний и статей, где важны слова, а не буквы. Обе дороги подробно, с весами и подсветкой, разобраны в статье про полнотекстовый поиск.

Коротко

  • Без индекса база читает таблицу целиком (Seq Scan); индекс это отсортированное оглавление, по которому она прыгает к нужным строкам.
  • Индекс не работает, когда колонку завернули в функцию, LIKE начинается с %, тип не совпал или выборка слишком большая.
  • Составной индекс работает слева направо: первой колонка равенства, второй диапазон или сортировка; поиск только по второй индекс не использует.
  • EXPLAIN показывает догадку, EXPLAIN (ANALYZE, BUFFERS) факт: расхождение rows лечит ANALYZE, read против hit объясняет «то быстро, то медленно».
  • Подстроку ищут индексом по триграммам (pg_trgm), слова полнотекстовым поиском.
  • У соединения три плана: вложенный цикл (мало строк слева и индекс справа), хеш (две большие таблицы) и слияние (порядок уже есть); лишний индекс — дефект, а не запас.

Что почитать дальше