Spilling на диск: aggregations, joins, sort, window
К этому моменту мы знаем: если запросу не хватает памяти, он либо ждёт (блокировка драйверов), либо его убивают (low-memory killer). Но есть третий путь — spilling: выгрузить часть состояния на локальный диск, освободить heap и довести запрос до конца, заплатив замедлением вместо смерти.
Spilling превращает «запрос упал по памяти» в «запрос отработал медленнее». Это компромисс: диск на порядки медленнее RAM, поэтому spill — не бесплатное расширение памяти, а аварийный клапан. Разберём, какие операторы умеют спиллить, как это устроено механически, как настраивается и почему за spill всегда платят дисковым I/O.
Что такое spill и какая память спиллится
В уроке 1 мы ввели revocable memory — память, которую запрос может вернуть. Spill — это и есть механизм возврата: spillable-оператор берёт часть своего состояния из heap, сериализует его на локальный диск воркера, освобождает heap-память, а позже, когда состояние снова понадобится, читает его обратно с диска.
Спиллится именно revocable-память — и в этом весь смысл деления памяти на категории. User memory нельзя ужать: хэш-таблица join обязана быть в памяти, чтобы по ней искать. А revocable-память spillable-оператора — можно: оператор спроектирован так, что умеет работать, имея часть состояния на диске.
Spill поддержан для четырёх классов операторов:
- Hash aggregation —
GROUP BY. Спиллится хэш-таблица групп. - Hash join — как inner, так и outer. Спиллится build-сторона.
- Sort —
ORDER BY. Спиллятся отсортированные сегменты (runs). - Window functions — спиллятся буферы партиций окна.
Механика по операторам
Spill — это не «скинули кусок RAM на диск и забыли». Каждый класс операторов спиллит по-своему, потому что должен уметь досчитать правильный результат, имея часть данных на диске.
Hash aggregation. Хэш-таблица групп при давлении сбрасывается на диск частями. Когда входные данные кончились, оператор не может просто отдать результат: одни и те же группы могли попасть и в память, и в несколько спилл-файлов. Он считывает спилленные сегменты и сливает аккумуляторы одинаковых ключей (merge) — только после слияния агрегат по каждой группе верен.
Hash join. Спиллится build-сторона. Чтобы сохранить корректность, build и probe партиционируются по join-ключу на согласованные партиции. Если build-партиция спиллилась, соответствующая probe-партиция тоже придерживается, и пара досоединяется отдельным проходом после чтения build-партиции с диска.
Sort. Классический external merge sort. Оператор набирает данные в памяти, сортирует этот фрагмент, сбрасывает на диск как отсортированный run. Так накапливается несколько отсортированных runs на диске. В конце оператор сливает runs (k-way merge), выдавая единый отсортированный поток.
DuckDB: external sort и larger-than-memory sortWindow. Window-функция работает над партициями окна; буферы партиций спиллятся, а при вычислении функции дочитываются с диска.
Общий узор всех четырёх случаев — spill, а затем merge. Спилленное состояние нельзя просто «забыть»: оператор обязан собрать корректный результат из памяти плюс диска. Поэтому spill стоит не только записи на диск, но и дополнительного чтения и слияния — это часть цены.
Цена spill: почему это аварийный клапан
Spill спасает запрос от гибели, но даётся дорого. Узкое место — disk I/O.
Локальный SSD выдаёт порядки гигабайт в секунду, RAM — десятки и сотни гигабайт в секунду; латентность доступа отличается ещё драматичнее. Когда оператор спиллит, он:
- сериализует страницы (CPU на сериализацию и сжатие);
- пишет их на диск (disk write I/O);
- позже читает обратно (disk read I/O);
- десериализует и сливает (CPU на merge).
Запрос со spill может работать в разы дольше, чем тот же запрос, целиком уместившийся в RAM. Поэтому spill — это аварийный клапан, а не способ «дать запросам бесконечную память». Если spill в кластере срабатывает постоянно, правильная реакция — не радоваться, что запросы не падают, а разобраться: добавить памяти, переписать запросы, навести изоляцию через resource groups.
Конфигурация spill
Spill настраивается на воркерах (это они держат состояние и диски), в etc/config.properties.
# etc/config.properties (на каждом воркере)
# Включить spill глобально
spill-enabled=true
# Каталог(и) для spill-файлов. Можно несколько через запятую —
# Trino распределит нагрузку по разным физическим дискам
spiller-spill-path=/mnt/spill1,/mnt/spill2
# Потолок суммарного места под spill на ноду — для ВСЕХ запросов сразу
max-spill-per-node=100GB
# Потолок места под spill на ноду для ОДНОГО запроса
query-max-spill-per-node=20GB
# Кодек сжатия spill-страниц (экономит I/O ценой CPU)
spill-compression-codec=LZ4
# Шифрование spill-файлов: на каждый файл — свой случайный ключ
spill-encryption-enabled=true
Точные имена и дефолты свойств сверяй со страницей admin/spill той версии Trino, которую разворачиваешь, — набор и значения иногда меняются между релизами. Что важно понять про каждый рычаг:
spiller-spill-path— список каталогов через запятую. Несколько каталогов на разных дисках складывают I/O-пропускную способность дисков. Это самый эффективный способ ускорить spill.max-spill-per-node— общий потолок места под spill на ноде. Не даёт spill-файлам забить диск полностью.query-max-spill-per-node— потолок на один запрос, чтобы один запрос не выел весь spill-бюджет ноды.spill-compression-codec— сжатие страниц перед записью. Меньше байт на диск ценой CPU; обычно сжатие выгодно, потому что disk I/O — более дефицитный ресурс, чем CPU.spill-encryption-enabled— на каждый spill-файл генерируется случайный ключ. Промежуточные данные запроса временно лежат на диске в открытом виде без шифрования — для чувствительных данных это включают.
Два правила выбора диска под spill. Первое: НЕ спилль на системный диск ОС — забитый spill’ом системный диск способен подвесить всю ноду. Второе: НЕ спилль на тот же диск, куда пишутся логи JVM, — spill и логирование начнут конкурировать за I/O и мешать друг другу. Под spill выделяй отдельные диски (/mnt/spill1, /mnt/spill2), а не корневой раздел.
Когда spill включать, а когда нет
Spill — не «включил и забыл». Решение зависит от профиля нагрузки кластера.
| Профиль нагрузки | Spill | Почему |
|---|---|---|
| Интерактивные дашборды, низкая латентность критична | Скорее выключить | Spill превращает быстрый запрос в медленный; лучше отклонить тяжёлый запрос, чем замедлить все |
| Тяжёлая batch-аналитика, важнее завершить, чем быстро | Скорее включить | Лучше медленный успешный запрос, чем убитый по памяти |
| Смешанная нагрузка | Spill + resource groups | Изолировать batch от интерактива (урок 5), spill — для batch-группы |
Идея в том, что spill меняет приоритет с «быстро» на «дойти до конца». Для batch-ETL это правильный размен. Для дашборда, который пользователь ждёт на экране, — почти всегда нет: медленный дашборд воспринимается как сломанный.
Попробуй сам
Включи spill на тестовом Trino и поймай его срабатывание.
- В
etc/config.propertiesворкера задайspill-enabled=trueиspiller-spill-pathна отдельный каталог (например,/tmp/trino-spill). Перезапусти Trino. - Возьми воркер с небольшим heap и запусти крупную агрегацию с высокой кардинальностью группировки или сортировку большого набора — что-то, что точно не уместится в RAM, например
SELECT ..., count(*) FROM tpch.sf100.lineitem GROUP BY <много колонок>. - Во время выполнения посмотри в каталог spill — там появятся файлы. После завершения запроса они исчезнут. В Web UI в деталях запроса найди показатели spill (объём спилленного).
- Засеки время того же запроса со
spill-enabled=trueиspill-enabled=false(если без spill он не падает) — оцени, во сколько раз spill замедлил выполнение.
Цель — увидеть spill-файлы вживую и прочувствовать, что spill спасает запрос ценой реального замедления.