Команда Python for Devs подготовила перевод статьи о том, как DuckDB ломает привычные представления о масштабах аналитических данных. Автор на реальных бенчмарках показывает, что 1 ТБ данных можно агрегировать за считанные секунды — без Spark, без распределённых кластеров и без сложной инфраструктуры.
Приготовьтесь выбросить за борт все привычные нормы и «общепринятую мудрость» о распределённых вычислениях. Сегодня мы развенчаем убеждение, что DuckDB подходит только для «небольших» объёмов данных.
В этой статье мы будем последовательно атаковать следующие установки:
Только Spark подходит для работы с терабайтами данных (или что он ВСЕГДА является лучшим выбором);
Для обработки терабайт данных требуется очень много времени.
К концу статьи мы хотим полностью сбить вас с толку — чтобы вы задумались, а не было ли всё, чему вас учили про MapReduce, ошибочным.
Вы говорили, что DuckDB — для небольших данных

Ранее я активно продвигал идею использовать DuckDB для любых наборов данных, которые можно считать «небольшими» (< 20 ГБ). Недавно в LinkedIn меня оспорили несколько внимательных дата-инженеров, указав, что у меня искажённое представление о возможностях DuckDB. Будучи любопытным дата-инженером, я клюнул на эту наживку, закатал рукава и решил провести бенчмарки на куда более крупных наборах данных.
Но насколько более крупных?
Сначала я нацелился примерно на 200 ГБ. DuckDB прочитал этот объём менее чем за 10 секунд.
Это было слишком быстро. Почти магия. А что насчёт 500 ГБ? И тут я упёрся в стену — в буквальном смысле, физическую. На жёстком диске моего Mac M2 просто не хватило места для 500 ГБ. Я заглянул в ближайший Best Buy и купил вот эту штуку:
Небольшое отступление. Внешний жёсткий диск на 4 ТБ может показаться избыточным — это был тот самый момент «либо по-крупному, либо никак». Я рассудил так: «Если 500 ГБ работают, значит, нужен запас для гораздо более масштабных тестов в будущем».

Я создал датасет на 500 ГБ на внешнем диске в DuckDB. Он прочитал эти данные примерно за 40 секунд.
И тут я понял, что пора замахнуться на настоящего тяжеловеса. На полный терабайт данных. 1 ТБ!
Создаём датасет на 1 ТБ для DuckDB
В моих предыдущих статьях вы увидите, что я использую скрипт, который быстро генерирует строки данных с помощью функции DuckDB generate_series. В общих чертах это выглядит так:

Всё довольно просто:
Вы задаёте количество строк — и скрипт генерирует Parquet-файл.
Но если хочется сделать это в масштабе и не ждать несколько часов (или, чего доброго, не скатиться в пугающе последовательное выполнение), что тогда делать?
Подключаем старый добрый
Python ProcessPoolExecutorи распараллеливаем. (код здесь)
Окей, но ты правда сгенерировал целый терабайт данных?
Да. Моему M2 Pro (16 ГБ RAM) потребовалось примерно 70 минут, чтобы «поджарить это яйцо» при параллельной работе 10 воркеров. Вот доказательство:

Датасет такой: 400 файлов, каждый примерно по 2,76 ГБ.
Итак, без лишних прелюдий — давайте приступим и прогоним на этом наборе несколько бенчмарков.
Небольшое отступление. Если вы всё ещё не переехали со старого питонячьего виртуального окружения через
-m venvна UV — сделайте себе одолжение и переходите прямо сейчас.uvбыстрее ставит пакеты, упрощает работу с конкретными Python-окружениями; можно продолжать ещё долго… потом скажете спасибо.
Бенчмарк… и что именно здесь происходит?
Сегодняшний бенчмарк будет таким:
запустим типовой агрегирующий запрос по всему датасету на 1 ТБ;
сгруппируем по дате, посчитаем строки и просуммируем значение.
Это стандартный аналитический запрос, который я видел на практике за последние два десятилетия работы дата-инженером и лидом BI. Я не подбирал его специально, чтобы DuckDB выглядел лучше. По сути, бенчмарк-сценарий сводится к следующему:

Для бенчмарка мы запустим его 5 раз. Ниже — результаты:

Среднее время обработки локально: 1 минута 29 секунд.
Подожди — ты же сказал, что мы сможем «раздавить» целый терабайт меньше чем за 30 секунд!?
Да, я так сказал. Первый бенчмарк был на моём ноутбуке и на локальной машине — и это уже впечатляет.
А что будет, если я создам полный датасет на 1 ТБ в MotherDuck и попробую снова?
Пора присоединиться к стае
В MotherDuck есть отличные варианты загрузки данных. Можно, например:
хранить CSV и Parquet в S3/Azure/GCP;
импортировать датасет TCP-H;
использовать локальный CLI, чтобы сгенерировать данные.
Для этой статьи я выбрал третий вариант. Вот скрипт, который создал датасет на 1 ТБ в MotherDuck:
Я создал представление (view) в базе MotherDuck, которое использовало функцию generate_series (как и в предыдущем локальном бенчмарке). После этого я запустил скрипт, чтобы пройтись по данным циклом и многократно вставить их.
После 10 итераций я увидел, что до 1 ТБ чуть-чуть не дотягивает; я вручную запустил процесс загрузки ещё несколько раз, пока не получил 1 ТБ.

Теперь у нас больше 1 ТБ данных. Пора выбрать вычислительную мощность.
В MD есть четыре стандартных варианта вычислительной мощности: Pulse, Standard, Jumbo и Mega. Я выбрал Mega, поскольку мы работаем с полным терабайтом данных:
Для бенчмарка мы запускали ровно тот же запрос, что и локально, но с одной оговоркой:
В MotherDuck есть умное кеширование; если прогнать один и тот же запрос пять раз, то результаты со 2-й по 5-ю итерации будут примерно по 5 секунд или меньше — потому что данные будут читаться из кеша, а не сканироваться заново.
Как это обойти?
��росто: на каждой итерации мы будем суммировать значения по разным нижним и верхним границам диапазона. Это делает запрос недетерминированным и убирает возможность «просто попасть в кеш-слой». Шаблон запроса выглядит так:

Ниже — результаты бенчмарка:

Чёрт возьми -
В среднем получилось меньше 17 секунд. Вы также можете спросить: «Эй, а что такое run 0, он же cold start?»
Помните: теперь мы имеем дело с облачным дата-вархаусом, где данные не всегда лежат наготове, чтобы мгновенно вытащить их из RAM; иногда они окажутся на диске, и их придётся считать оттуда. Поэтому итерация 0 — это прогревочный запуск на случай, если при первом обращении нужно читать с диска. Для датасетов, которые вы часто будете запрашивать в MotherDuck, это обычно не проблема: ваши данные, скорее всего, будут готовы к обработке и окажутся в «горячей» RAM.
Я готов — давайте копнём глубже
DuckDB поддерживает индексы, но на них там особо не делают акцент. Зато индекс Zonemap - это «секретное оружие»: он позволяет извлекать пользу из заранее отсортированных данных за счёт отслеживания min/max на уровне метаданных.

Как внедрить такие zone maps?
Давайте перезагрузим наш датасет так, чтобы при вставке мы сортировали данные по случайной дате. Процесс загрузки выглядел так:

После этого я собрал ещё один бенчмарк — и вот результаты:

Отсортированный датасет (zonemap) улучшил время бенчмарка примерно на 30%. Отличная оптимизация — просто за счёт того, что мы загрузили таблицу в отсортированном виде по полю, по которому делаем группировку.
Русскоязычное сообщество про Python

Друзья! Эту статью подготовила команда Python for Devs — канала, где каждый день выходят самые свежие и полезные материалы о Python и его экосистеме. Подписывайтесь, чтобы ничего не пропустить!
Итоги
Эта статья показала сдвиг парадигмы в возможностях DuckDB. Мы разрушили представление о том, что такое «небольшие» данные, и на что вообще способен этот «утёнок». Даже на моём локальном ноутбуке мы сканировали 1 ТБ данных менее чем за 2 минуты. Если бы мои batch-задачи обновляли отчёты за 2 минуты без Spark, я был бы более чем доволен.
Вот все примеры кода: