Learning Platform
Глоссарий Troubleshooting
Урок 14.05 · 22 мин
Средний
sortexternal-sortmerge-sortducdkb-1.4

External sort: k-way merge sort, переписанный в 1.4

ORDER BY кажется простой операцией, но сортировка — одна из самых требовательных к памяти задач СУБД. Чтобы отсортировать данные, в общем случае нужно видеть их все сразу: последний элемент потока может оказаться первым в результате. External sort — механизм, позволяющий ORDER BY отработать, когда данные не помещаются в RAM. В DuckDB 1.4 движок сортировки был полностью переписан как k-way merge sort, и этот урок разбирает, как он устроен.

Это третий и последний из «больших» out-of-core механизмов модуля. Логика спилла та же, что у агрегации и join, но алгоритмически сортировка устроена иначе: вместо партиционирования по хешу — слияние отсортированных кусков.


Почему сортировка требовательна к памяти

Агрегацию и join спасало партиционирование по хешу: строки разбивались на независимые группы, каждая обрабатывалась отдельно. С сортировкой так не получается — у сортировки нет «групп», которые можно обработать изолированно. Любой элемент может встать на любую позицию в финальном порядке, и определить его место нельзя, не сравнив с другими.

Наивная сортировка требует все данные в RAM: загрузить весь датасет, применить алгоритм сортировки, выдать результат. Для данных больше памяти это невозможно напрямую. Нужен алгоритм, который сортирует по частям и затем эти части объединяет, — и это external merge sort.


Идея external merge sort: run-ы и слияние

External merge sort работает в две фазы.

Фаза 1 — формирование run-ов (sorted runs). Данные читаются кусками такого размера, чтобы кусок поместился в memory_limit. Каждый кусок сортируется в памяти обычным быстрым in-memory алгоритмом и записывается на диск как отсортированный run. Run — это фрагмент данных, внутри себя полностью упорядоченный. После этой фазы на диске лежит несколько отсортированных run-ов; весь датасет не упорядочен, но каждый run — да.

Фаза 2 — слияние (merge). Отсортированные run-ы сливаются в один полностью отсортированный результат. Слияние не требует держать run-ы в памяти целиком: достаточно читать их потоково.

External merge sort: две фазы
Фаза 1: данные кусками в RAMЧитаем кусок размером в memory_limit, сортируем в памяти
сортировка в памяти, запись на диск
Отсортированные run-ы на дискеНесколько фрагментов, каждый внутри себя полностью упорядочен
фаза 2: слияние
Полностью отсортированный результатRun-ы слиты в единый упорядоченный поток

K-way merge: слияние сразу многих run-ов

Сердце алгоритма — фаза слияния. Как слить несколько отсортированных run-ов в один отсортированный поток?

Базовая идея слияния двух отсортированных списков знакома: смотрим на первые элементы обоих, берём меньший, продвигаемся в том списке, повторяем. K-way merge обобщает это на k run-ов сразу: смотрим на «головные» (наименьшие ещё не выданные) элементы всех k run-ов, выбираем глобально наименьший, выдаём его, продвигаемся в его run-е, повторяем.

Чтобы быстро находить наименьший из k голов, используется структура min-heap (куча минимумов) размера k: в ней лежат текущие головы всех run-ов, и извлечь минимум из неё дёшево. Каждый шаг: извлечь минимум из кучи (это очередной элемент результата), взять следующий элемент из того run-а, откуда был минимум, положить его в кучу. Так результат собирается по одному элементу, строго в порядке.

K-way merge через min-heap
Головы run-овНаименьший ещё не выданный элемент каждого из k отсортированных run-ов
в min-heap
Min-heap размера kКуча минимумов: извлечение глобально наименьшего элемента дёшево
извлекаем минимум, тянем следующий
Отсортированный выходЭлементы выдаются по одному в строгом порядке

Почему k-way, а не сливать парами? Можно было бы сливать run-ы попарно: 0+1, 2+3, потом результаты, и так далее. Но это многопроходный merge — данные перечитываются с диска несколько раз. K-way merge сливает много run-ов за один проход, минимизируя число чтений с диска. Меньше проходов по диску — быстрее сортировка, ведь диск медленный.

NOTE

Память фазы слияния — это не run-ы целиком, а лишь min-heap размера k плюс небольшие буферы чтения каждого run-а. Поэтому слияние укладывается в скромный объём памяти независимо от размера датасета: даже терабайтные run-ы сливаются потоково, в память поднимаются только текущие головы и порции для чтения.


Что нового в переписанном движке 1.4

В DuckDB 1.4 движок сортировки был полностью переписан. Новая реализация — это как раз чистый k-way merge sort, и она принесла несколько улучшений.

Адаптивность к уже упорядоченным данным. Если входные данные уже отсортированы или почти отсортированы, движок это распознаёт и не делает лишней работы. Сортировка по колонке, по которой данные уже упорядочены физически (например, таблица создавалась последовательно по этой колонке), обходится почти даром. Это важная оптимизация: на практике данные часто частично упорядочены.

Лучшая параллельность. Переписанный движок эффективнее задействует несколько потоков и на фазе формирования run-ов, и на фазе слияния.

Предсказуемое поведение под нехватку памяти. Сортировка плавно переходит в external-режим, когда run не помещается в память, без резких скачков.

Практический вывод для пользователя простой: ORDER BY в DuckDB 1.4 и новее быстрее и устойчивее, особенно на больших и на частично упорядоченных данных. Специально настраивать ничего не нужно — переписанный движок работает по умолчанию.


Видим внешнюю сортировку в работе

Воспроизведём ORDER BY на датасете, не влезающем в маленький лимит.

-- 60 млн строк со случайным ключом сортировки
CREATE TABLE big AS
SELECT range AS id,
       random() AS sort_key,
       ('payload_' || range) AS payload
FROM range(60000000);

SET memory_limit = '400MB';
SET temp_directory = '/tmp/sort-spill';

-- сортировка 60 млн строк по случайному ключу при лимите 400 МБ
SELECT id, sort_key
FROM big
ORDER BY sort_key
LIMIT 10;
-- запрос ОТРАБОТАЕТ: данные не влезли в 400 МБ,
-- отсортированы по run-ам и слиты k-way merge
SET memory_limit = '400MB';
EXPLAIN ANALYZE
SELECT id FROM big ORDER BY sort_key;

В выводе оператор ORDER_BY будет помечен как работающий в external-режиме. Во время выполнения в /tmp/sort-spill появятся файлы отсортированных run-ов, исчезающие по завершении.

Теперь — демонстрация адаптивности. Сравним сортировку по случайному ключу и по уже упорядоченной колонке:

-- по случайному ключу: полноценная сортировка
SELECT id FROM big ORDER BY sort_key LIMIT 10;

-- по id: данные УЖЕ упорядочены по id (таблица создана range)
SELECT id FROM big ORDER BY id LIMIT 10;
-- второй запрос заметно быстрее: движок 1.4 распознаёт
-- уже упорядоченные данные и не делает лишней работы
TIP

Связка ORDER BY … LIMIT — частый случай. Когда нужны только первые N строк по порядку, DuckDB не обязан полностью материализовать весь отсортированный результат: достаточно поддерживать top-N. Это отдельная оптимизация поверх сортировки, и она резко снижает и память, и время для запросов вида «топ-10 по убыванию».


Сортировка в ряду out-of-core механизмов

Подведём общую черту под тремя механизмами модуля. Все три позволяют операции отработать при нехватке памяти, спиллив на диск, но устроены по-разному.

МеханизмКак разбивает задачуЧто спиллитФаза объединения
External aggregationпартиции по хешу group-ключапартиции группнезависимая доагрегация партиций
External hash joinпартиции по хешу ключа join (обе таблицы)партиции build и probejoin пар партиций
External sortкуски, сортируемые в памятиотсортированные run-ыk-way merge run-ов

Агрегация и join опираются на хеш-партиционирование: задача распадается на независимые куски. Сортировка опирается на merge: куски не независимы, их нужно слить с учётом порядка. Но принцип один — свести задачу, не влезающую в память, к серии операций, каждая из которых в память помещается.


Попробуй сам

  1. Создай большую таблицу: CREATE TABLE t AS SELECT range AS id, random() AS rk, ('s' || range) AS s FROM range(50000000);
  2. Задай SET temp_directory = '/tmp/sort-demo'; и SET memory_limit = '350MB'; Выполни SELECT id, rk FROM t ORDER BY rk LIMIT 10; — запрос должен отработать.
  3. Во время выполнения посмотри /tmp/sort-demo из другого терминала — есть ли файлы отсортированных run-ов? Что с ними после завершения?
  4. Сделай EXPLAIN ANALYZE для SELECT id FROM t ORDER BY rk; при 350MB и при большом лимите. Сравни метку режима у оператора ORDER_BY.
  5. Сравни время двух запросов при 350MB: ... ORDER BY rk (случайный ключ) и ... ORDER BY id (колонка, по которой данные уже упорядочены, ведь таблица создана через range). Какой быстрее и почему? Свяжи ответ с адаптивностью переписанного в 1.4 движка.
Trino: ORDER BY в распределённой среде — partial sort и merge
Проверка знанийKnowledge check
Почему сортировку нельзя выполнить out-of-core тем же хеш-партиционированием, что агрегацию и join, и как k-way merge sort решает задачу сортировки данных больше памяти?
ОтветAnswer
Хеш-партиционирование спасало агрегацию и join, потому что там задача распадается на независимые куски: строки одной группы (или совпадающие пары) попадают в одну партицию, и партиции обрабатываются изолированно. У сортировки таких независимых групп нет — любой элемент может встать на любую позицию финального порядка, и определить его место нельзя, не сравнив с другими элементами. Поэтому нужен другой подход — external merge sort, который в DuckDB 1.4 переписан как чистый k-way merge sort. Он работает в две фазы. Фаза 1: данные читаются кусками такого размера, чтобы кусок помещался в memory_limit, каждый кусок сортируется в памяти и записывается на диск как отсортированный run — фрагмент, внутри себя полностью упорядоченный. Фаза 2: run-ы сливаются в единый отсортированный поток через k-way merge. K-way merge смотрит на головные (наименьшие ещё не выданные) элементы всех k run-ов сразу, через структуру min-heap дёшево находит глобально наименьший, выдаёт его и тянет следующий элемент из того же run-а. Память фазы слияния — это лишь min-heap размера k и небольшие буферы чтения, а не run-ы целиком, поэтому слияние укладывается в скромный объём памяти при любом размере датасета. K-way merge (а не попарное слияние) сливает много run-ов за один проход, минимизируя число медленных чтений с диска. Переписанный в 1.4 движок дополнительно адаптивен: если данные уже упорядочены, он это распознаёт и не делает лишней работы.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что такое отсортированный run в external merge sort?

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

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

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

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