Buffer manager: как DuckDB управляет памятью
DuckDB умеет обрабатывать датасеты больше оперативной памяти. Не «иногда» и не «с оговорками» — это штатный режим: на ноутбуке с 16 ГБ RAM DuckDB прогоняет запросы по 100+ ГБ данных. Чтобы понять, как это вообще возможно, нужно начать с компонента, который этим управляет, — buffer manager (буферный менеджер).
Этот урок — фундамент всего модуля. Buffer manager решает, что держать в памяти, а что — на диске; он же первым реагирует на нехватку памяти. Все механизмы out-of-core исполнения — внешняя агрегация, внешний join, внешняя сортировка — стоят на нём. Без понимания буферного менеджера остальной модуль будет набором не связанных приёмов.
Зачем нужен буферный менеджер
Наивная модель работы с данными — «загрузить всё в RAM и работать». Она ломается на двух классах данных:
- Данные больше RAM: датасет на 100 ГБ просто не поместится в 16 ГБ памяти.
- Промежуточные результаты больше RAM: даже если исходная таблица помещается, hash-таблица join или состояние агрегации в процессе запроса могут раздуться сверх памяти.
Buffer manager — это слой между «логическими данными, которые нужны движку» и «физической памятью, которой ограниченно».
Trino: модель памяти — user, revocable и heap headroomDataFusion: управление памятью — MemoryPool и spill Его задача — создать иллюзию, что памяти достаточно, перемещая данные между RAM и диском так, чтобы движок мог работать, не зная деталей.
Аналогия — виртуальная память операционной системы: процесс адресует больше памяти, чем есть физически, а ОС подкачивает страницы с диска. Buffer manager DuckDB делает то же самое, но на уровне СУБД и для своих структур данных — и потому делает это умнее, чем мог бы делать general-purpose своп ОС, потому что знает семантику данных.
Что считается «памятью» для buffer manager
Buffer manager управляет не одной сущностью, а несколькими типами потребителей памяти:
- Блоки данных таблиц. Когда движок читает таблицу, её колоночные блоки (по умолчанию 256 КиБ каждый) загружаются в память через buffer manager.
- Буферы под векторы и DataChunk-и. Векторизованное исполнение оперирует чанками по ~2048 значений; память под них тоже учитывается.
- Состояние операторов с накоплением. Hash-таблица join, hash-таблица агрегации, буфер сортировки — это структуры, которые растут по ходу запроса и могут занять много памяти.
- Кэш блоков. Уже прочитанные блоки буферный менеджер старается удерживать в памяти, чтобы не перечитывать их с диска.
Все эти потребители делят один общий бюджет — memory_limit. Buffer manager — арбитр, который этот бюджет распределяет и следит, чтобы суммарное потребление его не превысило.
Pin и unpin: как блок удерживается в памяти
Базовая операция буферного менеджера — pin / unpin. Когда оператору нужен блок данных, он просит buffer manager «закрепить» (pin) блок: тот гарантирует, что блок находится в памяти и не будет вытеснен, пока он закреплён. Закончив работу с блоком, оператор его «открепляет» (unpin) — теперь buffer manager волен вытеснить блок, если памяти не хватает.
Закреплённый блок вытеснить нельзя — он используется прямо сейчас. Открепленный блок остаётся в памяти как кэш: если он понадобится снова, не придётся читать с диска. Но именно открепленные блоки — кандидаты на вытеснение, когда buffer manager упирается в memory_limit.
Тонкость: блоки таблицы, прочитанные с диска и не изменённые, при вытеснении просто отбрасываются — их копия и так лежит в файле базы, перечитать можно в любой момент. А вот промежуточные данные запроса (содержимое hash-таблицы, буфера сортировки), которых на диске ещё нет, при вытеснении нужно сначала записать в temp-файл. Это и есть «спилл» (spill), о котором подробно — в следующих уроках.
Вытеснение при нехватке памяти
Когда buffer manager обнаруживает, что суммарное потребление приближается к memory_limit, он начинает освобождать память — вытесняет открепленные блоки. Стратегия выбора жертвы основана на принципе «давно не использованный блок вытесняем первым» (классическая идея LRU — least recently used). Логика: блок, к которому давно не обращались, скорее всего не понадобится в ближайшее время.
Процесс:
- Потребление памяти подошло к
memory_limit. - Buffer manager ищет открепленные блоки — кандидаты на вытеснение.
- Выбирает наименее недавно использованные.
- Если блок «чистый» (есть копия на диске) — просто отбрасывает его из памяти. Если «грязный» (промежуточные данные без копии на диске) — пишет в temp-файл, затем освобождает память.
- Освобождённая память выдаётся тому, кто её запросил.
-- посмотреть текущий лимит и потребление
SELECT current_setting('memory_limit');
-- 12.8 GiB (по умолчанию ~80% физической RAM)
-- наблюдать использование памяти движком
SELECT * FROM duckdb_memory();
-- покажет потребление по категориям: hash table, sort, ...
Функция duckdb_memory() показывает разбивку текущего потребления по типам потребителей — это окно в то, что сейчас держит buffer manager.
По умолчанию memory_limit равен примерно 80% физической оперативной памяти машины. Оставшиеся 20% — запас для самой ОС, других процессов и накладных расходов. Этот лимит можно менять командой SET memory_limit, и в следующем уроке мы увидим, зачем его иногда специально занижают.
Почему buffer manager умнее свопа ОС
Возникает резонный вопрос: зачем DuckDB свой buffer manager, если у ОС уже есть виртуальная память и своп — пусть ОС и подкачивает страницы? Ответ — в семантике.
Своп ОС работает со страницами памяти вслепую: он не знает, что в странице, что важнее, что скоро понадобится. Он реагирует постфактум, когда память уже кончилась, и часто принимает плохие решения — выгружает то, что вот-вот понадобится.
Buffer manager DuckDB знает контекст:
- Он знает, что именно лежит в каждом блоке — данные таблицы, hash-таблица, буфер сортировки.
- Он знает, что блок чистый (можно отбросить) или грязный (надо записать).
- Он управляет вытеснением проактивно, до того как память кончилась, выбирая жертвы осмысленно.
- Он координируется с операторами: например, внешняя агрегация специально устроена так, чтобы её состояние можно было спилить аккуратными партициями, а не случайными страницами.
Поэтому полагаться на своп ОС — плохая идея: запрос будет работать, но в разы медленнее и непредсказуемо. Правильный подход — дать DuckDB честный memory_limit и temp_directory, и пусть buffer manager управляет спиллом сам. На многих системах своп вообще стоит отключать для процесса с DuckDB, чтобы под нагрузкой не словить деградацию из-за слепой подкачки ОС.
| Своп ОС | Buffer manager DuckDB | |
|---|---|---|
| Знает содержимое страницы/блока | нет | да |
| Различает чистые и грязные данные | нет | да |
| Момент реакции | постфактум, память кончилась | проактивно, до исчерпания |
| Координация с операторами запроса | нет | да |
| Результат под нагрузкой | непредсказуемая деградация | контролируемый спилл |
Buffer manager и параллелизм
DuckDB исполняет запрос на многих потоках. Все они делят один buffer manager и один общий memory_limit. Это значит, что бюджет памяти — общий ресурс между потоками: если восемь потоков параллельно строят части hash-таблицы, суммарно они должны уложиться в memory_limit, а не каждый в него.
Отсюда практическое следствие, к которому мы вернёмся в уроке про бюджет памяти на поток: чем больше потоков, тем меньше памяти приходится на каждый, и тем выше шанс, что операторам придётся спилить. Buffer manager и настройка threads связаны: число потоков влияет на то, как делится память.
Блоки и почему именно 256 КиБ
Buffer manager оперирует не отдельными байтами и не строками, а блоками фиксированного размера — по умолчанию 256 КиБ. Данные таблицы на диске хранятся блоками, и в память они поднимаются тоже блоками. Это типичное для СУБД решение, и размер блока — компромисс.
Слишком мелкий блок означал бы много отдельных операций ввода-вывода и раздутую структуру учёта (на каждый блок — запись в таблицах буферного менеджера). Слишком крупный блок означал бы грубую гранулярность: чтобы поднять одну нужную строку, пришлось бы загрузить большой блок целиком, и память тратилась бы на лишнее. 256 КиБ — баланс: блок достаточно велик, чтобы амортизировать накладные расходы на одну операцию I/O, и достаточно мал, чтобы вытеснение и загрузка были гибкими.
Для buffer manager фиксированный размер блока ещё и упрощает управление: все «слоты» в памяти одинаковы, освобождённое место под один блок всегда годится под любой другой. Это убирает фрагментацию, которая мучила бы менеджер при блоках переменного размера.
Блок (256 КиБ) — единица, которой buffer manager перемещает данные между RAM и диском. Это не то же, что vector (~2048 значений, единица векторизованного исполнения) и не row group (~122 880 строк, единица хранилища). У DuckDB несколько уровней гранулярности под разные задачи: vector — для исполнения, row group — для организации хранения и пропуска по статистике, блок — для управления памятью. Не путайте их.
Попробуй сам
- Запусти DuckDB и посмотри лимит по умолчанию:
SELECT current_setting('memory_limit');Сопоставь с объёмом RAM твоей машины — близко ли к 80%? - Выполни
SELECT * FROM duckdb_memory();на «холодной» сессии — что показывает потребление, пока запросов не было? - Создай большую таблицу:
CREATE TABLE big AS SELECT range AS id, range % 1000 AS k FROM range(20000000);Снова посмотриduckdb_memory()— изменилось ли потребление? - Выполни тяжёлый запрос с группировкой:
SELECT k, count(*) FROM big GROUP BY k;и сразу после —SELECT * FROM duckdb_memory();Видно ли категорию памяти под hash-таблицу агрегации? - Поставь маленький лимит:
SET memory_limit = '200MB';и повтори запрос. Он всё ещё отрабатывает? Подумай, что должен был сделать buffer manager, чтобы запрос завершился при таком лимите.
ClickHouse: memory tracker и управление памятью на запрос