memory_limit и temp_directory: куда и когда DuckDB спиллит
Buffer manager из прошлого урока управляет памятью, но решения он принимает в рамках, заданных двумя настройками: memory_limit — сколько памяти можно занять, и temp_directory — куда сбрасывать данные, когда памяти не хватает. Эти две опции — основной рычаг управления out-of-core исполнением. Понимать их нужно точно: неправильная настройка приводит либо к OOM-падению, либо к загадочно медленным запросам.
Этот урок разбирает обе настройки: что они означают, какие значения по умолчанию, когда происходит спилл и как его наблюдать. Это связующее звено между общим устройством буферного менеджера и конкретными out-of-core операторами в следующих уроках.
memory_limit: бюджет памяти
memory_limit — это потолок памяти, который DuckDB-движок разрешает себе занять под все свои структуры: блоки данных, hash-таблицы, буферы сортировки, кэш. Buffer manager следит, чтобы суммарное потребление не превышало этот лимит; при приближении к нему он начинает вытеснять данные.
Значение по умолчанию — примерно 80% физической оперативной памяти машины. На 16 ГБ RAM это около 12.8 ГБ. Оставшиеся 20% — буфер для самой ОС, других процессов и накладных расходов, которые DuckDB не учитывает в своём лимите.
-- посмотреть текущий лимит
SELECT current_setting('memory_limit');
-- 12.8 GiB
-- задать явно
SET memory_limit = '4GB';
SELECT current_setting('memory_limit');
-- 4.0 GiB
-- принимаются единицы: KB, MB, GB, TB и бинарные KiB/MiB/GiB
SET memory_limit = '512MB';
Важно понимать, что memory_limit — это лимит для движка DuckDB, а не для всего процесса. Память, которую держит Python-интерпретатор, ваши pandas-DataFrame, другие библиотеки — в этот лимит не входит. Если в одном процессе живёт DuckDB с лимитом 12 ГБ и pandas с DataFrame на 8 ГБ, суммарно процесс хочет 20 ГБ — и на 16 ГБ машине это приведёт к проблемам, хотя сам лимит DuckDB «корректен».
memory_limit ограничивает только движок DuckDB. Если DuckDB живёт внутри Python-процесса вместе с pandas, polars, numpy — память этих библиотек в лимит не входит. На машине, где процесс делит RAM с другими потребителями, лимит DuckDB надо ставить с запасом: не 80% RAM, а столько, чтобы хватило и всему остальному.
temp_directory: куда уходит спилл
Когда buffer manager вынужден вытеснить грязные данные (промежуточные результаты, которых нет на диске), он пишет их в файлы во временной директории, заданной настройкой temp_directory. Это и есть «спилл» — сброс данных из RAM на диск.
Значения по умолчанию зависят от режима:
- Persistent-база (открыт файл
data.db): temp-директория по умолчанию —data.db.tmpрядом с файлом базы. - In-memory база (база в памяти, без файла): по умолчанию
.tmpв текущем рабочем каталоге.
-- посмотреть и задать
SELECT current_setting('temp_directory');
SET temp_directory = '/fast-ssd/duckdb-spill';
В temp-директории DuckDB создаёт временные файлы на время выполнения запросов и удаляет их по завершении. Размер этих файлов может быть значительным — если запрос спиллит десятки гигабайт промежуточных данных, столько же временно займёт и temp-директория.
Когда происходит спилл
Спилл — не постоянное состояние, а реакция на нехватку памяти. Он происходит, когда суммарное потребление DuckDB подходит к memory_limit, а запросу нужно ещё. Конкретно спилл случается, когда:
- Hash-таблица агрегации не помещается в память — внешняя агрегация партиционирует группы и спиллит партиции.
- Hash-таблица join не помещается — внешний join спиллит обе стороны.
- Буфер сортировки не помещается — внешняя сортировка спиллит отсортированные куски.
Эти три механизма — темы следующих уроков. Здесь важен общий принцип: пока запрос укладывается в memory_limit, всё работает в RAM на максимальной скорости; как только не укладывается — buffer manager начинает спиллить, и запрос замедляется, но завершается, а не падает.
Это ключевое отличие DuckDB от движков, которые при нехватке памяти просто падают с OOM. DuckDB деградирует по скорости, но не по результату. Цена out-of-core — время; выгода — запрос вообще отрабатывает.
Наблюдаем спилл
Чтобы увидеть спилл своими глазами, поставим заведомо маленький лимит и запустим тяжёлый запрос.
-- создаём датасет, который не влезет в маленький лимит
CREATE TABLE events AS
SELECT range AS id, range % 5000000 AS user_id, random() AS val
FROM range(50000000);
-- ограничиваем память до 300 МБ
SET memory_limit = '300MB';
SET temp_directory = '/tmp/duck-spill';
-- тяжёлая группировка по 5 млн уникальных user_id
SELECT user_id, count(*), avg(val)
FROM events
GROUP BY user_id;
-- запрос ОТРАБОТАЕТ, несмотря на лимит 300 МБ
Пока этот запрос выполняется, в /tmp/duck-spill появятся временные файлы — это спилл партиций hash-агрегации. По завершении запроса файлы исчезнут. Поведение видно и в плане:
SET memory_limit = '300MB';
EXPLAIN ANALYZE
SELECT user_id, count(*) FROM events GROUP BY user_id;
В выводе EXPLAIN ANALYZE оператор HASH_GROUP_BY будет помечен как работающий в out-of-core / external режиме — это и есть индикатор спилла. Если тот же запрос выполнить с большим лимитом, метка external исчезнет: данные поместились в RAM.
Главный практический навык — связывать наблюдаемое поведение с настройками. Запрос внезапно стал медленным? Проверь, не начал ли он спиллить: посмотри EXPLAIN ANALYZE на метку external у операторов и наличие файлов в temp_directory во время запроса. Спилл — это не ошибка, но это сигнал, что данные переросли memory_limit.
Практические рекомендации
Несколько правил, которые экономят часы отладки.
Ставь temp_directory на быстрый диск. Спилл — это I/O. Если temp-директория на медленном HDD или сетевом диске, out-of-core запросы будут мучительно медленными. SSD/NVMe для temp-директории кардинально ускоряет спилл. Если на машине есть быстрый локальный диск — temp-директория должна быть на нём.
Следи за местом на диске под temp_directory. Если запрос спиллит 80 ГБ, а на диске под temp-директорией свободно 20 ГБ — запрос упадёт из-за нехватки места на диске. Out-of-core переносит проблему с RAM на диск, но не отменяет её: место нужно где-то.
memory_limit ниже RAM, если процесс не один. Если DuckDB делит процесс с Python-библиотеками или машину с другими сервисами — занижай лимит, чтобы не спровоцировать системный OOM, который убьёт весь процесс жёстко, в обход аккуратного спилла DuckDB.
Занижение memory_limit — рабочий приём. Иногда лимит занижают намеренно: например, чтобы DuckDB на shared-машине не съел всю память и не помешал соседям. Это нормально — запрос просто будет больше спиллить. Лучше предсказуемо медленный запрос, чем убитый OOM-киллером процесс.
| Симптом | Вероятная причина | Что проверить / сделать |
|---|---|---|
| Запрос упал с OOM | системная память кончилась раньше спилла | занизить memory_limit, убрать конкурентов за RAM |
| Запрос упал: нет места на диске | temp_directory переполнилась спиллом | освободить диск или указать раздел побольше |
| Запрос внезапно очень медленный | начался спилл (данные переросли лимит) | EXPLAIN ANALYZE: метка external; temp на быстрый диск |
| Запрос медленный и temp на HDD/сети | спилл идёт на медленный носитель | перенести temp_directory на SSD/NVMe |
Где задавать настройки
memory_limit и temp_directory можно задать в трёх местах, и место влияет на область действия.
Через SET в сессии. SET memory_limit = '4GB' действует на текущее соединение. Это удобно для разовой настройки под конкретный тяжёлый запрос: подняли лимит, выполнили, при желании вернули.
При подключении базы. В API-клиентах настройки можно передать в момент connect — через объект конфигурации. Тогда соединение сразу открывается с нужным лимитом и temp-директорией, без отдельного SET.
Глобально для in-memory части. Часть настроек применима как глобальные, действующие на весь экземпляр DuckDB.
import duckdb
# настройки прямо при подключении
con = duckdb.connect(
"warehouse.db",
config={"memory_limit": "8GB", "temp_directory": "/fast-nvme/spill"}
)
Практический смысл различия: если у вас сервис, который держит соединение к DuckDB долго и гоняет много запросов, настройки логично задать один раз при подключении. Если же это интерактивная сессия и под отдельный запрос нужен особый лимит — удобнее SET прямо перед запросом. И то и другое — рабочие подходы; выбор зависит от того, разовая это настройка или постоянная для всего соединения.
Попробуй сам
- Посмотри значения по умолчанию:
SELECT current_setting('memory_limit');иSELECT current_setting('temp_directory');Запусти DuckDB сначала как in-memory (duckdb), потом на файл (duckdb test.db) — отличается ли temp_directory по умолчанию? - Создай большую таблицу:
CREATE TABLE t AS SELECT range AS id, range % 3000000 AS g FROM range(30000000); - Задай
SET temp_directory = '/tmp/spill-demo';иSET memory_limit = '250MB';ЗапустиSELECT g, count(*) FROM t GROUP BY g;— пока он идёт, посмотри в другом терминале содержимое/tmp/spill-demo. Появились файлы? Что с ними после завершения запроса? - Выполни
EXPLAIN ANALYZEтого же запроса с лимитом250MB, затем подними лимит до значения побольше (например,4GB) и повтори. Сравни метку режима у оператораHASH_GROUP_BY— есть ли пометка external в первом случае и пропадает ли она во втором? - Подумай: если temp_directory указать на сетевой диск, как это скажется на времени запроса из шага 3 и почему.