Learning Platform
Урок 09.03 · 22 мин
Продвинутый
JoinsMerge JoinSortIndex ScanSorted input

Третий и последний классический алгоритм — Merge Join. Его идея украдена у mergesort: если оба входа отсортированы по одному и тому же ключу, можно пройти их синхронно и склеить за O(N + M). Никакой хэш-таблицы, никакой памяти, никакого random access — только два sequential read’а и линейная склейка.

Merge Join — алгоритм, любимый OLAP-нагрузкой и хранилищами данных, и редкость в OLTP. Чтобы понять, когда он выгоден, надо посмотреть на его требования и стоимость в контексте Postgres.

Алгоритм: два указателя

В простейшем виде Merge Join выглядит как «зип» двух отсортированных списков:

i, j = 0, 0
while i < N and j < M:
    if left[i].key < right[j].key:
        i += 1
    elif left[i].key > right[j].key:
        j += 1
    else:  # equal — emit
        emit (left[i], right[j])
        # обработать дубликаты — все right с тем же ключом матчатся с left[i]
        # и все left с тем же ключом — с right[j]
        ...

Сложность — O(N + M), один линейный проход по каждому входу. На двух таблицах по миллиону строк это два миллиона сравнений, в разы меньше, чем NL без индекса, и сопоставимо с Hash Join.

Merge Join: два курсора по отсортированным потокам

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

Left (отсортирован по key)1, 3, 5, 7, 9, 11
Right (отсортирован по key)2, 3, 5, 8, 9, 10
шаг 1left=1, right=2 -> 1 меньше, двигаем left
шаг 2left=3, right=2 -> 2 меньше, двигаем right
шаг 3left=3, right=3 -> emit (3, 3), двигаем оба
шаг 4left=5, right=5 -> emit (5, 5)
шаг 5left=7, right=8 -> 7 меньше, двигаем left
итогоO(N + M) один проход, ноль random access

Главное требование: оба входа должны быть отсортированы

Это требование делает Merge Join одновременно мощным и капризным. Если входы уже отсортированы (через Index Scan по B+tree на join-ключе) — Merge Join практически бесплатен. Если нет — придётся вставлять Sort-узел перед каждым входом, и стоимость сортировки O(N log N) часто перевешивает выигрыш от линейного merge.

Когда выбирает Postgres

Постгрес выбирает Merge Join, когда:

  1. Обе таблицы упорядочены индексом по join-ключу. Тогда merge — это просто Index Scan обеих + проход по итераторам, минимальный CPU и нулевой memory overhead.
  2. Результат всё равно нужно сортировать. Например, запрос с ORDER BY join_key или GROUP BY join_key. Тогда сортировка после Hash Join была бы избыточной — Merge даёт сортированный output бесплатно.
  3. Огромные таблицы, work_mem мал. Hash Join при недостатке памяти переходит в batched (медленный диск); Merge тоже потребует external sort, но он более последовательный — может быть быстрее.
  4. Join-условие — range или strict-equal. Merge Join поддерживает не только =, но и < / <= / >= (range join), что Hash Join не умеет.

Demo: смотрим, как Postgres его выбирает

В Postgres дефолтное поведение для маленьких таблиц — Nested Loop или Hash Join. Чтобы заставить Merge Join, нужно либо сделать таблицы достаточно большими и предоставить отсортированные индексы, либо принудительно выключить остальные алгоритмы.

Заставим Merge Join, отключив остальные. Посмотри на Sort-узлы под Merge Join. Дата-сет инициализируется ~5 секунд.

PostgreSQL

Ожидаем в плане:

Merge Join
  Merge Cond: (c.id = o.customer_id)
  ->  Index Scan using customers_pkey on customers c
  ->  Sort  (Sort Key: o.customer_id)  <-- внешняя сортировка orders
        ->  Seq Scan on orders o

Index Scan на customers_pkey даёт уже отсортированные данные. На orders сортировки по customer_id нет, поэтому Postgres вставляет Sort-узел — и его стоимость может перевесить выигрыш merge. Смотри в EXPLAIN ANALYZE, использовалась ли quicksort или external sort, и какой Memory:/Disk: overhead.

Двусторонний Index Scan: best case Merge Join

Лучший сценарий — когда обе таблицы имеют индекс по join-ключу. Тогда Sort вообще не нужен.

Создадим индекс на orders.customer_id и посмотрим, как Merge Join становится двусторонне-Index Scan:

PostgreSQL

Теперь под Merge Join’ом должно быть два Index Scan — никакого Sort. Это и есть идеальный Merge Join: ноль extra памяти, два sequential reads. Стоимость в разы ниже предыдущего варианта.

External sort: когда не помещается в work_mem

Если Sort требует больше памяти, чем work_mem, Postgres делает external sort (на диске):

External sort для Merge Join

Если входные данные не помещаются в work_mem, Postgres делает k-way external merge sort на диске. Это записать-прочитать всё, что добавляет 2x I/O.

orders (100K строк не помещается в work_mem=64kB)нужен external sort
фаза 1разбиваем на runs по work_mem, сортируем каждый run в RAM, пишем на диск
фаза 2k-way merge runs с диска -> отсортированный поток
overhead2x I/O всей таблицы + временные файлы в pgsql_tmp

В EXPLAIN ANALYZE это видно как:

Sort Method: external merge  Disk: 12000kB

Если видишь external merge в плане — это знак: либо подними work_mem, либо построй индекс по сорт-ключу, чтобы Sort не понадобился.

Range join: уникальное преимущество Merge

Hash Join не умеет range-условия (a.x BETWEEN b.lo AND b.hi). Merge Join — умеет, если обе стороны отсортированы по соответствующему ключу. Это редкий, но мощный паттерн, например, для джойнов с диапазонами времени или версий.

Range Merge Join — соединяем заказы с временными диапазонами. Это то, что Hash Join сделать не может:

PostgreSQL

В таких запросах Hash Join невозможен (нет equality), Nested Loop работает плохо без индекса на range, а Merge Join может пройтись по отсортированным значениям и связать их. Постгрес может выбрать его сам, если статистика согласована.

Cost formula

Из costsize.c, упрощённо:

merge_cost = sort_cost(outer if not sorted) + sort_cost(inner if not sorted)
           + (outer.rows + inner.rows) × cpu_operator_cost
           + IF many duplicates: extra rescans on inner

Главные драйверы:

  • Нужна ли сортировка на одной/обеих сторонах — если да, добавляется sort_cost = N × log N × cpu_operator_cost.
  • Размер обеих таблиц.
  • Дубликаты в join-ключе — если на одной стороне много строк с одинаковым ключом, Merge должен «rewind» (перематывать назад) и проходить их повторно. Это обрабатывается через Materialize под одной из сторон.

Edge case: дубликаты и Materialize

Когда на inner-стороне много строк с одинаковым ключом, классический merge должен возвращаться назад при следующем outer-ключе того же значения. Постгрес обходит это через Materialize на inner: кэширует «текущую группу» одинаковых ключей в памяти и переигрывает её, когда нужно.

В EXPLAIN это выглядит так:

Merge Join
  ->  Sort outer ...
  ->  Materialize
        ->  Sort inner ...

Materialize тут — индикатор: возможны дубликаты по join-ключу. Если дубликатов мало, расход небольшой; если много (n_distinct низкий) — Merge Join может стать медленнее Hash Join.

Проверка знанийKnowledge check
У тебя две таблицы по 50M строк, соединяются по UUID-полю, и на обоих есть B-tree индекс по этому UUID. work_mem = 4 MiB. Какой алгоритм планер выберет, и почему Merge Join тут лучше Hash Join?
ОтветAnswer
Планер выберет Merge Join. Причины: (1) Hash Join построил бы хэш на 50M × ~50 байт = ~2.5 GiB — далеко за work_mem, ушёл бы в batched join с записью обеих таблиц на диск, что 2-5x медленнее. (2) Merge Join делает Index Scan обеих таблиц — никаких extra пишет, никакой сортировки, потому что индексы уже дают отсортированный поток. Стоимость = log_fanout(50M) на первый Index Scan + один линейный проход. На SSD это будет в районе 10-30 секунд против 1-2 минут у batched Hash Join. Merge Join выгоден именно на огромных, уже отсортированных данных.

Чек-лист

  • Merge Join = «зип» двух отсортированных потоков, O(N + M).
  • Требует, чтобы обе стороны были отсортированы по join-ключу. Если нет — вставляется Sort-узел.
  • Идеальный сценарий: обе таблицы имеют B+tree индекс по join-ключу → два Index Scan + merge → минимум CPU и I/O.
  • Поддерживает range-conditions (<, <=, >=), что Hash Join не умеет.
  • При недостатке work_mem Sort идёт в external (диск) — добавляет 2x I/O.
  • Materialize под inner — знак дубликатов по join-ключу.
  • На OLTP редок; на OLAP/DWH и больших отсортированных таблицах — главный конкурент Hash Join.
  • В Postgres диагностируется через SET enable_hashjoin = off; SET enable_nestloop = off; — посмотри, что планер посчитал бы при отсутствии других вариантов.
Merge sort: divide-and-conquer и стабильная O(n log n) read_in_order: пропускаем сортировку

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Какое требование должно выполняться, чтобы Merge Join был эффективен?

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

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

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

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