Оптимизация SQL-запросов: индексы и план
Вопросы про производительность отделяют «умею писать запросы» от «понимаю, как работает база». Спросят, почему запрос с индексом всё равно тормозит и что ты увидишь в плане. Это не экзотика для администраторов баз данных: аналитик, положивший прод тяжёлой выгрузкой, есть в биографии почти каждой команды.
Типовые формулировки: «индекс есть, а медленно - почему?», «как читаешь EXPLAIN?», «нужен ли индекс на эту колонку?».
// Достаточно разобраться в трёх вещах: как устроено дерево индекса, что его выключает и как читать план запроса. Дальше идут детали конкретной базы, которых от аналитика никто не ждёт.
B-tree: почему поиск занимает три шага
B-tree - дерево, в узлах которого лежат отсортированные ключи, а в листьях ссылки на строки таблицы. Каждый узел содержит не одно значение, а сотни, и делит диапазон на сотни частей.
Отсюда и скорость. На каждом спуске отбрасывается почти всё: узел на 256 ключей за один шаг сужает поиск в 256 раз. Три уровня накрывают миллионы строк, поэтому до нужного места добираешься за три-четыре обращения к диску вместо миллиона.
Второе полезное свойство спрятано в листьях: они связаны в цепочку по порядку. Найдя первое значение диапазона, база просто идёт по цепочке дальше и отдаёт все заказы между двумя датами подряд, без возврата к корню. Поэтому B-tree одинаково хорош и для точного равенства, и для диапазона.
// Составной индекс по (a, b) - это дерево, отсортированное сначала по a, потом внутри одинаковых a по b. Он работает для фильтра по a и для фильтра по a вместе с b, но по одному b - нет: значения b разбросаны по всему дереву. Порядок колонок в индексе - не косметика.
Что выключает индекс
Индекс хранит значения самой колонки. Как только колонка оказывается внутри функции, дерево становится бесполезным: в нём лежат значения created_at, а сравнить нужно значения date(created_at), и таких в индексе просто нет. База сдаётся и читает таблицу целиком.
Лечится двумя способами. Переписать условие диапазоном - created_at >= '2026-01-01' AND created_at < '2026-01-02' вместо date(created_at) = '2026-01-01'; результат тот же, а индекс работает. Или построить функциональный индекс, то есть индекс по самому выражению date(created_at) - тогда в дереве окажутся ровно те значения, которые сравниваются.
Тот же механизм объясняет поведение поиска по строке. LIKE 'abc%' индексом пользуется: известно начало, а дерево отсортировано именно с начала строки. LIKE '%abc' - нет: в дереве нет порядка по хвостам, искать негде. Для поиска по подстроке нужен другой инструмент - триграммный индекс или индекс по перевёрнутой строке.
// Есть и обратная ситуация, которую принимают за поломку. Если под фильтр попадает заметная часть таблицы, планировщик сознательно откажется от индекса и прочитает всё подряд - последовательное чтение половины таблицы дешевле, чем полмиллиона прыжков по ссылкам. На колонке с тремя разными значениями индекс не поможет никогда.
- селективность
- какая доля строк проходит фильтр. Чем меньше, тем полезнее индекс; на неселективной колонке он лишний
Как читать план запроса
EXPLAIN показывает, что база СОБИРАЕТСЯ делать, EXPLAIN ANALYZE - что она реально сделала и за сколько. Читается снизу вверх и изнутри наружу: самые вложенные узлы выполняются первыми.
Красных флагов три, и все узнаются с одного взгляда. Seq scan (последовательное чтение таблицы целиком) на большой таблице под узким фильтром - индекса нет или он выключен. Nested loop на миллионах строк - для каждой строки одной таблицы база пробегает вторую, и это квадратичная работа. И расхождение между rows estimated и actual в разы: планировщик считал по устаревшей статистике и выбрал план под несуществующие данные, лечится командой ANALYZE.
Покрывающий индекс - тот, в котором есть все колонки запроса, в ключе или в INCLUDE. Тогда база отвечает прямо из индекса, ни разу не заглянув в таблицу; в плане это видно как index-only scan. Привычка писать SELECT * ломает это начисто: тянутся все колонки, а в индексе их нет.
// И про замеры. Первый прогон греет кеш, поэтому сравнивать надо времена повторных прогонов, а решения принимать по actual rows в плане, а не по ощущению «вроде быстрее стало».
Пагинация, которая не деградирует
OFFSET 100000 звучит как «начни со стотысячной строки», а работает как «прочитай сто тысяч строк и выброси их». Первая страница летает, сотая ползёт, тысячная кладёт базу - деградация линейная по номеру страницы.
Keyset-пагинация решает это сменой вопроса. Вместо «дай сто тысяч первых и отбрось» спрашиваем «дай следующие двадцать после вот этого ключа»: WHERE id > last_seen_id ORDER BY id LIMIT 20. Индекс сразу прыгает в нужное место дерева, и сотая страница стоит ровно столько же, сколько первая.
// Цена честная: нельзя перепрыгнуть на страницу номер 500, можно только идти вперёд и назад. Для бесконечной ленты и выгрузок это ровно то, что нужно; для нумерованной пагинации в админке - нет. И индексы вообще не бесплатны: каждый замедляет вставку и обновление и занимает место, поэтому их ставят под реальные запросы, а не на всякий случай.
- keyset-пагинация
- страница «после последнего увиденного ключа» вместо счёта от начала. Её же зовут курсорной
Как отвечать: «Запрос фильтрует по индексированной дате, но тормозит. Почему?»
Иду по причинам в порядке частоты. Первое - функция вокруг колонки: условие вида date(created_at) = вчера индексом не пользуется, потому что в индексе лежат значения самой колонки, а не результат функции. Переписываю диапазоном, от полуночи до полуночи. Второе - смотрю EXPLAIN ANALYZE: если оценка строк расходится с фактической в разы, статистика устарела и план выбран под несуществующие данные, лечится командой ANALYZE. Третье - селективность: если под фильтр попадает заметная доля таблицы, планировщик сознательно уходит в последовательное чтение, и он прав, индекс тут не поможет. Тогда вопрос уже не к индексу, а к объёму выборки, и решается либо сужением фильтра, либо покрывающим индексом.
Три реальные причины в порядке частоты, к каждой - способ проверки и лечение. Это диагностический маршрут вместо гадания «наверное, индекс сломался».
На чём валят
- −Функция вокруг индексированной колонки в WHERE: индекс выключился молча, ошибки нет.
- −SELECT * на широкой таблице - сломан index-only scan, читаются лишние байты.
- −«Добавим индекс» на колонку с тремя значениями: планировщик его проигнорирует, а записи станут медленнее.
- −Глубокая пагинация через OFFSET: сотая страница читает и выбрасывает всё, что было до неё.
- −Мерить по первому прогону и не смотреть actual rows в плане: кеш и статистика останутся за кадром.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 30, остальные разбираются в тренажёре.
- Почему SELECT * в проде считается плохой практикой?A)Это вопрос стиля оформления кода запросаB)SELECT * технически несовместим с использованием вида JOIN в запросеC)СУБД напрямую запрещает символ * внутри транзакцийD)Тянет лишнее, ломает index-only scan и рвётся при смене схемы
показать ответ и разбор
+D)Тянет лишнее, ломает index-only scan и рвётся при смене схемы// разбор: Index-only scan возможен, когда все нужные колонки есть в индексе — SELECT * гарантированно ходит в heap. Широкие таблицы с TEXT/JSONB превращают «лёгкий» запрос в перекачку мегабайтов. Плюс хрупкость: INSERT ... SELECT * и маппинг по позициям тихо ломаются при миграциях. В аналитических колоночных БД (ClickHouse) цена * ещё выше.
- Составной индекс (a, b). Каким запросам он поможет, а какому — практически нет?A)Составной индекс помогает запросу с условием на a или на b — порядок колонок в нём не важенB)Поможет a=? и a=? AND b=?; почти нет — b=? без a (нужен левый префикс)C)Поможет запросу WHERE a=? AND b=? одновременно, иначе бесполезенD)Составные индексы не применяются для одиночных условий вообще
показать ответ и разбор
+B)Поможет a=? и a=? AND b=?; почти нет — b=? без a (нужен левый префикс)// разбор: B-tree по (a, b) отсортирован сначала по a: поиск по префиксу работает, «перепрыгнуть» a для условия только на b нельзя (index skip scan в Postgres нет). Порядок колонок в индексе — решение: равенства слева, диапазон — последним. Отдельный индекс по b — если такой фильтр реально частый.
- На колонку gender (два значения) повесили B-tree индекс, но запросы WHERE gender='M' его игнорируют. Почему?A)Индекс попросту сломан, и его нужно пересоздать — после REINDEX планировщик наконец начнёт им нормально пользоватьсяB)B-tree не индексирует строковые колонки, лишь числовые идентификаторы и целые ключиC)Низкая селективность: условие отбирает ~половину таблицы, и seq scan дешевле random-доступа по индексуD)Индекс просто не используется, пока в таблице меньше миллиона строк — это жёсткий встроенный порог самого планировщика
показать ответ и разбор
+C)Низкая селективность: условие отбирает ~половину таблицы, и seq scan дешевле random-доступа по индексу// разбор: Планировщик выбирает индекс, когда тот отсекает МАЛУЮ долю строк. Условие по колонке с двумя значениями отбирает примерно половину таблицы, и последовательное сканирование (seq scan) оказывается дешевле миллионов случайных обращений по индексу с чтением строк из кучи — поэтому индекс закономерно игнорируется. Это не поломка и не про тип колонки; низкоселективные одиночные индексы обычно бесполезны (иногда помогают частичные или составные).
- Что такое покрывающий (covering) индекс и чем он ускоряет запрос?A)Индекс, содержащий все нужные запросу колонки — ответ берётся из индекса без обращения к таблице (index-only scan)B)Индекс, который покрывает сразу все колонки таблицы вообще без исключения и тем самым фактически заменяет собой саму таблицуC)Индекс, автоматически создаваемый СУБД на каждую колонку из списка SELECT при самом первом запуске запроса к этой таблицеD)Индекс, который обязательно должен быть уникальным по своим значениям, иначе покрывающим он считаться не может
показать ответ и разбор
+A)Индекс, содержащий все нужные запросу колонки — ответ берётся из индекса без обращения к таблице (index-only scan)// разбор: Покрывающий индекс включает все колонки, нужные запросу (в ключе или через INCLUDE), поэтому СУБД отвечает прямо из индекса, не заходя в таблицу за строками — index-only scan, экономящий случайные обращения в кучу и заметно ускоряющий чтение. Он не обязан покрывать ВСЕ колонки таблицы и не должен быть уникальным; создаётся осознанно под частый запрос, а не сам собой на каждый SELECT.
- WHERE name LIKE '%иван%' не использует индекс на name, а WHERE name LIKE 'иван%' — использует. Почему?A)LIKE несовместим с индексами: оба запроса идут через полное сканирование таблицыB)Никакой разницы нет: оба варианта LIKE одинаково хорошо используют обычный B-tree индекс, построенный на колонке nameC)Первый запрос даже быстрее второго, потому что он ищет подстроку сразу по всей длине значения колонки name разомD)Ведущий '%' не даёт B-tree опереться на префикс — совпадение может быть где угодно; для '%...%' нужен триграммный индекс
показать ответ и разбор
+D)Ведущий '%' не даёт B-tree опереться на префикс — совпадение может быть где угодно; для '%...%' нужен триграммный индекс// разбор: B-tree упорядочен по префиксу значения, поэтому шаблон 'иван%' (известное начало) позволяет сузить диапазон и использовать индекс. Шаблон '%иван%' начинается с wildcard — совпадение может быть в любом месте строки, префиксом воспользоваться нельзя, и обычный индекс бесполезен (seq scan). Для поиска подстроки с ведущим '%' нужны специальные индексы: триграммный (pg_trgm GIN) или полнотекстовый. Первый запрос как раз медленнее, а не быстрее.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.