Обработка датасета больше RAM: бюджет памяти на поток и пределы
Предыдущие уроки разобрали механизмы out-of-core по отдельности: buffer manager, спилл, внешняя агрегация, join и сортировка. Этот урок — практический синтез: как реально прогнать запрос по датасету, который в несколько раз больше оперативной памяти, как настроить memory_limit и threads, как оценить бюджет памяти на поток, и — честно — где у out-of-core всё-таки есть пределы.
Это финальный урок модуля, и его задача — превратить понимание механики в рабочую интуицию: с какими ручками подходить к большому датасету и чего от них ожидать.
DuckDB и данные больше памяти: что реально работает
Начнём с факта, который стоит закрепить. DuckDB действительно обрабатывает датасеты больше RAM — это не маркетинг.
Trino: spilling на диск — aggregations, joins, sort Документированный пример: ранние версии с внешней агрегацией прогоняли все запросы из набора в 50 ГБ на ноутбуке с 16 ГБ RAM. Сканирование, фильтрация, агрегация, join, сортировка — все основные операции имеют out-of-core реализацию и спиллят на диск, когда нужно.
Это не значит, что «размер данных вообще не имеет значения». Это значит, что нехватка RAM переводит запрос в более медленный режим, а не в падение. Цена out-of-core — время и дисковый I/O; выгода — запрос выполним на скромном железе.
Бюджет памяти на поток
Ключевая модель для рассуждений об out-of-core — память делится между потоками. Buffer manager даёт один общий memory_limit на весь движок, а исполнение идёт на threads потоках. Грубо: на каждый поток приходится memory_limit / threads памяти под его долю работы.
Если поток-агрегатор строит свою частичную hash-таблицу, а на него приходится мало памяти — он раньше начнёт спиллить. Отсюда исследовательская оценка, которую важно подать именно как грубую эвристику, а не гарантию:
- Для агрегационно-тяжёлых запросов комфортным считается порядка 1-2 ГБ памяти на поток.
- Для join-тяжёлых запросов — порядка 3-4 ГБ на поток (build-стороны и hash-таблицы крупнее).
Цифры 1-2 ГБ на поток для агрегации и 3-4 ГБ для join — это приблизительные ориентиры из исследований, а не жёсткие гарантии. Реальная потребность зависит от числа групп, размера build-стороны, тяжести агрегатного состояния, ширины строк. Используйте эти числа как стартовую точку для прикидки, а не как формулу, которой можно слепо доверять. Точную настройку всегда даёт замер на ваших данных через EXPLAIN ANALYZE.
Из этой модели следует неинтуитивный вывод: больше потоков — не всегда быстрее. Восемь потоков делят memory_limit на восемь долей; если запрос join-тяжёлый и каждой доле не хватает, все восемь потоков спиллят, и суммарно из-за дискового I/O может выйти медленнее, чем четыре потока с вдвое большей долей памяти каждый. Параллелизм ускоряет CPU-часть, но обостряет конкуренцию за память.
-- прикидка: 16 ГБ лимита на 8 потоков -> ~2 ГБ на поток
-- для join-тяжёлого запроса это на нижней границе
SET memory_limit = '16GB';
SET threads = 8; -- ~2 ГБ/поток: join может спиллить
SET threads = 4; -- ~4 ГБ/поток: join-запросу комфортнее
-- меньше параллелизма по CPU, но меньше спилла
Подход к настройке: для join-тяжёлой нагрузки на машине с ограниченной памятью имеет смысл попробовать уменьшить threads, чтобы каждому потоку досталось больше памяти, и сравнить время. Иногда четыре потока без спилла обгоняют восемь со спиллом.
Рецепт: прогон датасета больше RAM
Соберём практический порядок действий для запроса по датасету, заведомо большему памяти.
Шаг 1. Задать temp_directory на быстрый диск с запасом места. Спилл пойдёт сюда. Нужен SSD/NVMe и свободное место, сопоставимое с объёмом промежуточных данных (может быть десятки-сотни ГБ).
SET temp_directory = '/fast-nvme/duckdb-spill';
Шаг 2. Задать честный memory_limit. Столько, сколько движок реально может занять, не уронив систему. Если процесс делит RAM с другими — занизить.
SET memory_limit = '12GB';
Шаг 3. Подобрать threads под характер запроса. Join-тяжёлый и память впритык — попробовать меньше потоков. CPU-bound и памяти достаточно — оставить по числу ядер.
SET threads = 6;
Шаг 4. Запустить и наблюдать через EXPLAIN ANALYZE. Посмотреть, какие операторы ушли в external-режим, где узкое место по времени.
Шаг 5. Уменьшить объём данных там, где можно. Самый сильный рычаг — не настройки, а сам запрос: фильтровать раньше (filter pushdown), читать только нужные колонки (projection pushdown), не делать SELECT *. Чем меньше данных входит в дорогие операторы, тем меньше спилла.
Пределы out-of-core: где честная граница
Out-of-core мощный, но не всемогущий. Несколько реальных пределов, о которых нужно знать.
Место на диске. Out-of-core переносит проблему с RAM на диск, но не отменяет её. Если запрос спиллит 200 ГБ промежуточных данных, на диске под temp_directory должно быть 200 ГБ свободно. Иначе запрос упадёт — уже не по памяти, а по месту на диске.
Скорость диска. Спилл — это I/O, и его скорость ограничена диском. На медленном HDD огромный out-of-core запрос может идти часами. NVMe принципиально меняет картину, но даже он на порядок медленнее RAM. «Запрос завершится» не означает «завершится быстро».
Не всякое промежуточное состояние спиллится. Как отмечалось в уроке про агрегацию: некоторые сложные агрегатные состояния переменного размера на момент написания не все умеют спиллить. Запрос с экзотическим агрегатом по огромному числу групп теоретически может упереться в OOM даже при работающем out-of-core. Это известное ограничение из документации DuckDB.
Память на сам запрос и метаданные. Какой-то минимум памяти нужен всегда — под структуры самого запроса, метаданные, буферы. Поставить memory_limit в несколько мегабайт и ждать, что отработает любой запрос, не выйдет: для очень большой задачи нужен разумный минимум RAM, даже если основная масса данных спиллится.
| Предел | В чём суть | Как смягчить |
|---|---|---|
| Место на диске | спилл требует свободного места под temp_directory | освободить диск, выбрать раздел побольше |
| Скорость диска | спилл ограничен I/O, HDD очень медленный | temp_directory на NVMe/SSD |
| Не всё состояние спиллится | экзотические агрегаты переменного размера | упростить агрегат, уменьшить число групп |
| Минимум памяти на запрос | структурам запроса нужен какой-то RAM | не занижать memory_limit до абсурда |
Когда датасет перерос локальную машину
Если даже при грамотной настройке out-of-core запрос идёт неприемлемо долго или упирается в место на диске — это сигнал, что задача переросла модель «один файл на одной машине». Варианты роста:
- Уменьшить данные на входе. Часто самый дешёвый ход: партиционировать исходные данные и читать только нужные партиции (partition pruning), агрегировать предварительно, не тянуть лишние колонки.
- Более мощная машина. DuckDB отлично масштабируется вертикально: больше RAM и быстрее NVMe радикально расширяют, что выполнимо in-memory или с дешёвым спиллом.
- MotherDuck. Managed-облако на DuckDB — вынести тяжёлую часть в облако.
- DuckLake. Лейкхаус-формат: данные партиционированы в объектном хранилище, запросы читают только релевантные файлы.
Принцип тот же, что в модуле про конкурентность: out-of-core — это про то, чтобы выжать максимум из одной машины. Когда и этого мало, растут не настройкой флага, а сменой архитектуры. Но планка «одной машины» у DuckDB высокая: терабайты данных на мощном узле — вполне её территория.
Попробуй сам
- Создай датасет, заметно больший твоего
memory_limitпо умолчанию: широкую таблицу на 80-100 млн строк черезrangeс несколькими колонками. - Поставь
SET temp_directoryна быстрый диск иSET memory_limit = '1GB';Прогони тяжёлый запрос сGROUP BYпо многим группам. Замерь время. - Тот же запрос с
SET threads = 8;и затемSET threads = 2;при том же лимите1GB. Сравни время. Для твоего запроса больше потоков ускорило или замедлило? Свяжи ответ с бюджетом памяти на поток. - Прикинь бюджет:
memory_limitделить наthreads— сколько памяти на поток в каждом из вариантов шага 3? Попадает ли это в ориентир 1-2 ГБ (агрегация) или 3-4 ГБ (join)? - Через
EXPLAIN ANALYZEнайди операторы в external-режиме. Затем добавь к запросу селективныйWHEREи проверь: уменьшился ли спилл, ушли ли операторы из external-режима?
Trino: модель памяти — pools, reserved memory, general pool