Материалы / Технические статьи / Почему Hash Join долго не отдаёт первую строку
Статья

Почему Hash Join долго не отдаёт первую строку

Разница между временем до первой строки и полным временем запроса, цена построения хеша и роль work_mem при Hash Join.

— Макс, странный случай, — Лена подошла к его столу без ноутбука, с одной лишь распечаткой плана. — Запрос выполняется две секунды. Это быстро. Но страница в интерфейсе «подвисает» на все две секунды, прежде чем показать первые 20 строк. В плане — обычный Hash Join. Я думала, он — наш друг.

Макс отставил кружку.

— Ты привыкла, что Hash Join — это мощная фабрика, которая перемалывает миллионы строк. Это так. Но прежде чем фабрика выпустит первый товар, ей нужен справочник, — он взял листок. — Hash Join не может отдать строку наверх, пока не прочитает строящий вход и не подготовит по нему хеш-таблицу.

— То есть… все эти две секунды он не ищет, а готовится к поиску? — медленно проговорила Лена.

— Именно. Он строит свой «справочник». Для отчёта, который должен обработать весь набор, такая подготовка часто оправданна. Но для интерфейса, которому нужна первая страница прямо сейчас, высокая стартовая стоимость может быть заметна. Это разница между time-to-first-row и time-to-total. В стоимости плана есть стартовая и полная составляющие, а LIMIT влияет на выбор пути, но итог всё равно зависит от оценок статистики и остальных узлов плана.

Лена нахмурилась.

— А какая альтернатива? Nested Loop? Но он же «тупой» и перебирает всё для каждой строки!

— Не совсем, — Макс начертил схему. — Nested Loop с хорошим индексом по ключу соединения — это не перебор. Это снайперская стрельба. Он берёт одну строку из внешней таблицы и мгновенно, по индексу, находит соответствующую во внутренней. И сразу же отдаёт результат наверх. Он может вернуть первую строку за миллисекунды. Да, на обработку миллиона строк он потратит гораздо больше времени, чем Hash Join. Но для пагинации он бесценен.

— Получается, Hash Join — это спринтер, который долго разминается, а Nested Loop — марафонец, который стартует сразу?

— Хорошая аналогия. Но есть и вторая ловушка Hash Join. Он требует, чтобы хеш-таблица поместилась в оперативную память (work_mem). Если планировщик ошибся в оценке числа строк, и хеш-таблица не влезает в RAM, начинаются «проливы на диск».

— А где это видно?

— В плане у узла Hash смотри на Batches. Значение больше единицы означает, что хеш пришлось разбить на пакеты; часть работы может уйти во временные файлы. В EXPLAIN (ANALYZE, BUFFERS) это также видно по статистике временного ввода-вывода. Насколько это замедлит запрос, зависит от объёма данных и хранилища.

Лена посмотрела на свой план. Там не было проливов, но она поняла главное.

— Получается, Hash Join — не только блокирующий, но и чувствительный к памяти инструмент. А Nested Loop, который мы привыкли считать «плохим», в мире интерактивных интерфейсов может быть настоящим спасением.

— Да. Важно видеть не просто узлы плана, а компромиссы, которые за ними стоят, — кивнул Макс.

Что запомнить

  • Цена запуска: Hash Join не вернёт первую строку, пока не подготовит строящий вход. Для запросов с LIMIT это может быть важнее полного времени выполнения.

  • Зависимость от work_mem: если хеш не помещается в выделенную память, число Batches растёт и может появиться временный ввод-вывод. Проверяйте это через EXPLAIN (ANALYZE, BUFFERS).

  • Nested Loop с индексом — отличная «стриминговая» альтернатива для быстрого старта, но без индекса по ключу соединения он сильно деградирует.

  • Merge Join — ещё один потоковый способ соединения. Он тоже может быстро отдавать первые строки, если оба входа уже отсортированы (например, после Index Scan).

  • Блокирующий Sort — главный враг быстрого старта. Если в плане есть Sort перед LIMIT, польза от «стриминговых» соединений теряется. Решение — индекс, поддерживающий нужный ORDER BY, или keyset-пагинация.

Hash Join — это не серебряная пуля. Это тяжёлая артиллерия, которую нужно применять с умом.