Производительность SQL
Планы EXPLAIN, индексы, sargable-предикаты, стоимость fan-out, колоночное хранение и стратегии join.
10 вопросов
JuniorТеорияОчень частоЧто такое индекс в базе данных и когда добавление индекса может навредить производительности?
Что такое индекс в базе данных и когда добавление индекса может навредить производительности?
Индекс — отсортированное B-tree, отображающее значения столбца в положения строк, поэтому планировщик находит строки без полного сканирования. Но каждый индекс обновляется при любой записи и занимает диск: на столбце низкой селективности или частой записи он добавляет издержки, не ускоряя чтение.
Типичные ошибки
- ✗Думать, что чем больше индексов, тем лучше, даже при частых записях
- ✗Забывать, что каждый индекс поддерживается при любой вставке и обновлении
- ✗Строить индекс на столбце низкой селективности в надежде на ускорение
Уточняющие вопросы
- →Как покрывающий индекс избегает лишнего обращения к heap?
- →Почему индекс на булевом столбце почти бесполезен?
MiddleКодОчень частоJoin создал дублирующие строки, и кто-то добавил SELECT DISTINCT — почему это дорого?
Join создал дублирующие строки, и кто-то добавил SELECT DISTINCT — почему это дорого?
DISTINCT сортирует или хэширует каждый столбец разросшегося результата, убирая дубли — дорого на широких fan-out-строках, и лишь маскирует ошибку. Дубли идут от join «один-ко-многим»; чините причину через WHERE EXISTS (...) (semi-join) или сверните до одной строки на ключ.
Типичные ошибки
- ✗Думать, что DISTINCT — дешёвый потоковый флаг, а не сортировка или хэш
- ✗Считать, что LEFT JOIN даст одну строку на заказ вопреки fan-out
- ✗Затыкать дубли через DISTINCT вместо устранения fan-out от join
Уточняющие вопросы
- →Как EXPLAIN покажет DISTINCT как узел Sort или HashAggregate?
- →Когда EXISTS быстрее пред-агрегации дочерней таблицы?
SeniorДебаггингЧастоОдинаковый SQL быстр в dev, но медленный в prod — это устаревшая статистика, перекос данных или parameter sniffing?
Одинаковый SQL быстр в dev, но медленный в prod — это устаревшая статистика, перекос данных или parameter sniffing?
План оценил 5 строк, но Index Scan вернул 3,8 млн, и планировщик выбрал Nested Loop, выгодный лишь на нескольких строках — признак устаревшей статистики. Запустите ANALYZE, чтобы оценка стала верной и он выбрал hash join. Перекос данных — когда статистика свежа, но одно значение доминирует.
Типичные ошибки
- ✗Называть это parameter sniffing, когда Postgres перепланирует в каждой сессии
- ✗Списывать всё замедление на перекос данных вопреки разрыву оценки
- ✗Форсировать Seq Scan вместо обновления статистики
Уточняющие вопросы
- →Как подтвердить перекос данных, если ANALYZE не помог?
- →Когда generic plan в PL/pgSQL даёт эффект вроде sniffing в Postgres?
MiddleДебаггингИногдаЗапрос по 300 млн строк отваливается по таймауту — как через EXPLAIN ANALYZE найти виновника?
Запрос по 300 млн строк отваливается по таймауту — как через EXPLAIN ANALYZE найти виновника?
Читают снизу вверх узел, чьи фактические время и строки доминируют. Здесь Seq Scan по 300 млн events оценён в 1200 строк, а вернул 300 млн — огромный разрыв оценки и факта из-за устаревшей статистики и отсутствия индекса на country. Фикс: ANALYZE events и индекс на фильтруемом столбце.
Типичные ошибки
- ✗Поднимать statement_timeout вместо починки плана
- ✗Винить алгоритм hash-join, а не оценку и отсутствие индекса
- ✗Читать Rows Removed by Filter как признак сломанного фильтра
Уточняющие вопросы
- →Как строка BUFFERS подтверждает, что узкое место — Seq Scan?
- →Почему плохая оценка строк вводит в заблуждение выбор алгоритма join?
MiddleКодИногдаЗаставьте WHERE DATE(created_at) = '2026-01-01' использовать индекс по created_at
Заставьте WHERE DATE(created_at) = '2026-01-01' использовать индекс по created_at
Обёртка столбца в DATE() делает предикат не-sargable — функция вычисляется на каждой строке, поэтому индекс по created_at бесполезен и планировщик делает Seq Scan. Перепишите его полуоткрытым диапазоном по «голому» created_at >= '2026-01-01' AND created_at < '2026-01-02'.
Типичные ошибки
- ✗Приводить константу вместо снятия функции со столбца
- ✗Винить список SELECT, а не не-sargable предикат
- ✗Форсировать enable_seqscan off вместо того, чтобы сделать предикат sargable
Уточняющие вопросы
- →Когда индекс по выражению DATE(created_at) — лучший фикс?
- →Почему полуоткрытый диапазон надёжнее приведения к date для timestamp?
SeniorДизайнИногдаSpark-задача соединяет фактовую таблицу в 300 млн строк с пятью маленькими измерениями (каждое меньше 100 тыс. строк) по их ключам. Работает медленно, и большая часть времени уходит на shuffle данных по сети. Можно broadcast'ить маленькую таблицу на все executor'ы (broadcast join) или перераспределить обе стороны по ключу (shuffle join). Фактовая таблица огромна; каждое измерение крохотное и свободно помещается в память executor'а. Какая стратегия избегает перемещения большой таблицы по сети и каков главный риск, если broadcast'ить таблицу, которая на деле не влезает в память?
Spark-задача соединяет фактовую таблицу в 300 млн строк с пятью маленькими измерениями (каждое меньше 100 тыс. строк) по их ключам. Работает медленно, и большая часть времени уходит на shuffle данных по сети. Можно broadcast'ить маленькую таблицу на все executor'ы (broadcast join) или перераспределить обе стороны по ключу (shuffle join). Фактовая таблица огромна; каждое измерение крохотное и свободно помещается в память executor'а. Какая стратегия избегает перемещения большой таблицы по сети и каков главный риск, если broadcast'ить таблицу, которая на деле не влезает в память?
Broadcast'ите маленькие измерения: каждое копируется на все executor'ы, поэтому фактовая таблица в 300 млн строк остаётся на месте и не шаффлится — join идёт локально. Shuffle join перераспределил бы обе стороны по ключу, таща огромную таблицу по сети. Broadcast таблицы, не влезающей в память, грозит OOM или сбросом.
Типичные ошибки
- ✗Broadcast'ить огромную фактовую таблицу вместо маленьких измерений
- ✗Считать, что shuffle всегда дешевле репликации маленькой таблицы
- ✗Игнорировать, что слишком большой broadcast вызывает OOM или сброс
Уточняющие вопросы
- →Как spark.sql.autoBroadcastJoinThreshold решает это автоматически?
- →Когда перекошенный ключ join ломает обе стратегии?
JuniorТеорияРедкоЧитая план EXPLAIN впервые, на что вы смотрите в первую очередь?
Читая план EXPLAIN впервые, на что вы смотрите в первую очередь?
План читают снизу вверх — листовые узлы идут первыми. Смотрят, какой скан получает каждая таблица (Seq Scan против Index Scan), и оценку rows; плохая оценка в листе вводит в заблуждение узлы выше. EXPLAIN ANALYZE добавляет фактические строки, показывая ошибку оценки.
Типичные ошибки
- ✗Читать план сверху вниз вместо снизу вверх
- ✗Принимать стоимость верхней строки за миллисекунды, а не абстрактную оценку
- ✗Игнорировать оценки строк, которые определяют каждый узел выше
Уточняющие вопросы
- →О чём говорит большой разрыв между оценочными и фактическими строками?
- →Почему стоимость верхнего узла накопительная, а не своя?
MiddleТеорияРедкоКогда планировщик прав, выбирая последовательное сканирование вместо индексного?
Когда планировщик прав, выбирая последовательное сканирование вместо индексного?
Когда предикат совпадает с большой долей строк, Seq Scan дешевле: последовательное чтение страниц выигрывает у множества случайных обращений к индексу плюс выборок из heap. Планировщик взвешивает оценку строк против стоимости страниц — на широком фильтре сканировать всё выгоднее индекса.
Типичные ошибки
- ✗Считать, что индексный скан всегда быстрее последовательного
- ✗Выбирать тип скана по размеру таблицы, а не селективности предиката
- ✗Забывать про стоимость случайных обращений и выборок из heap у индексного скана
Уточняющие вопросы
- →Как параметр random_page_cost смещает этот выбор?
- →Когда bitmap heap scan оказывается между этими двумя?
SeniorПроизводительностьРедкоВ колоночном хранилище (ClickHouse, BigQuery) почему SELECT * дорог, а полное сканирование одного столбца дёшево?
В колоночном хранилище (ClickHouse, BigQuery) почему SELECT * дорог, а полное сканирование одного столбца дёшево?
Колоночное хранилище кладёт каждый столбец подряд, поэтому запрос читает только названные столбцы — полный скан одного столбца всё равно затрагивает малую долю байтов таблицы. SELECT * вынуждает читать сегмент каждого столбца, кратно увеличивая I/O и убивая поколоночное сжатие.
Типичные ошибки
- ✗Считать SELECT * таким же дешёвым, как в строковом хранилище
- ✗Думать, что скорость колоночного — от кэша, а не от отсечения столбцов
- ✗Верить, что полный скан столбца дорог из-за разбросанных значений
Уточняющие вопросы
- →Как поколоночное сжатие усиливает штраф от SELECT *?
- →Почему колоночному хранилищу всё же полезны ключи сортировки?
SeniorПроизводительностьРедкоПочему фильтр по не-партиционному столбцу сканирует все партиции, даже при partition pruning?
Почему фильтр по не-партиционному столбцу сканирует все партиции, даже при partition pruning?
Pruning отсекает партиции лишь когда предикат нацелен на ключ партиционирования — планировщик сопоставляет значение конкретным партициям и игнорирует остальные. Фильтр по любому другому столбцу нельзя сопоставить, поэтому сканируется каждая; pushdown затем лишь фильтрует строки внутри партиции.
Типичные ошибки
- ✗Считать, что pruning работает по любому столбцу WHERE, а не только по ключу
- ✗Путать predicate pushdown с partition pruning
- ✗Ждать, что индекс включит pruning по не-ключевому столбцу
Уточняющие вопросы
- →Как выражение над ключом вроде date_trunc ломает pruning?
- →Когда планировщик может отсечь партиции по условию join на ключе?