Модель памяти Trino: user, revocable и heap headroom
Trino исполняет SQL в памяти. Хэш-таблицы для join, буферы агрегаций, окна сортировки, output-буферы exchange — всё это живёт в JVM heap воркера. Если запрос попросит больше, чем есть, воркер столкнётся с OutOfMemoryError, а это не «запрос упал» — это «JVM-процесс упал и забрал с собой все остальные запросы на этой ноде». Поэтому Trino не позволяет аллокациям расти бесконтрольно: он ведёт собственный учёт памяти поверх JVM и отбирает у запросов память по чётким правилам.
Чтобы управлять памятью осознанно — настраивать лимиты, понимать, почему запрос убит «по памяти», когда включать spilling — нужно сначала понять, на какие категории Trino делит heap. Категорий три: user memory, revocable memory и system-аллокации, прикрытые heap headroom. Каждая существует не случайно, и у каждой своя механика учёта.
Почему Trino считает память сам
JVM умеет считать память только грубо: есть -Xmx, есть garbage collector, есть OutOfMemoryError когда heap исчерпан. Этого мало для движка, который одновременно обслуживает десятки запросов. Если один запрос-обжора съест весь heap, GC начнёт работать вхолостую (long GC pauses), а потом упадёт вся JVM — вместе с двадцатью невиновными запросами других пользователей.
Trino решает это так: каждый оператор, прежде чем выделить значимый объём (построить ещё один сегмент хэш-таблицы, добавить страницу в буфер сортировки), запрашивает резервирование у локального менеджера памяти воркера — LocalMemoryManager. Менеджер ведёт счётчики: сколько байт сейчас числится за каждым запросом, сколько суммарно занято на ноде. Если резервирование выводит запрос или ноду за лимит — оператор не получает память и срабатывает один из защитных механизмов (блокировка драйвера, spill или kill запроса).
Важно понимать: Trino-учёт — это бухгалтерия, а не аллокатор. Реальные байты по-прежнему выделяет JVM через new byte[]. Trino лишь ведёт книгу: «за запросом Q сейчас числится 4.2 GB user memory». Точность учёта зависит от того, насколько честно операторы сообщают свой размер. Поэтому даже при идеально настроенных лимитах остаётся неучтённая память — и под неё резервируют headroom.
User memory: то, что запрос просит явно
User memory — это память, объём которой напрямую определяется запросом и данными. Её невозможно сократить, не убив или не замедлив запрос радикально. Классические потребители user memory:
- Hash-таблицы join. При hash join build-сторона целиком превращается в хэш-таблицу в памяти. Чем больше build-таблица, тем больше user memory.
- Буферы агрегаций.
GROUP BYстроит хэш-таблицу групп: ключ группы -> аккумуляторы. Кардинальность группировки прямо задаёт размер. - Сортировка.
ORDER BYбез spill держит весь сортируемый набор в памяти. - Окна. Window-функции буферизуют партиции окна.
User memory — это «честный» расход: запрос соединяет таблицу на 8 GB — он и займёт около 8 GB под хэш-таблицу, никакие настройки этого не отменят. Поэтому лимиты на user memory (query.max-memory, query.max-memory-per-node) — это именно потолок «сколько данных запросу позволено держать». Превысил потолок — запрос убивается, потому что ужать его нечем.
User memory отвечает на вопрос «сколько данных запрос держит в памяти». Это не баг и не утечка — это нормальная работа join и aggregation. Лимиты на user memory защищают не от утечек, а от слишком тяжёлых запросов: соединения гигантских таблиц без фильтров, группировки с миллиардами уникальных ключей.
Revocable memory: память, которую можно отозвать
Revocable memory (отзываемая память) — это память, которую запрос занял, но в случае давления может вернуть, выгрузив своё состояние на диск. «Отзыв» — это и есть spilling: spillable-оператор сбрасывает часть состояния на локальный диск, освобождает heap, а позже дочитывает данные обратно.
Revocable-память используют операторы, поддерживающие spill: spillable hash aggregation, spillable hash join, sort, некоторые window-операции. Пока давления нет, их состояние просто живёт в heap. Когда нода приближается к лимиту, Trino командует таким операторам «отзовись» — они спиллят, и память освобождается для других.
Ключевое следствие: revocable memory не считается в query.max-memory (лимит user memory), потому что её всегда можно вернуть и она не угрожает кластеру так, как нерастворимая user memory. Зато она считается в query.max-total-memory — лимите «вся память запроса, включая отзываемую». Именно поэтому дефолт query.max-total-memory равен query.max-memory x 2: total-лимит даёт запросу запас сверх его user-памяти, чтобы было куда положить revocable-состояние до того, как оно спиллится.
| Свойство | User memory | Revocable memory | System-аллокации |
|---|---|---|---|
| Кто потребляет | join hash-таблицы, GROUP BY, ORDER BY | spillable join/aggregation/sort | exchange-буферы, парсинг, метаданные |
| Можно отозвать | Нет | Да — через spill на диск | Нет |
Учитывается в query.max-memory | Да | Нет | Нет |
Учитывается в query.max-total-memory | Да | Да | Нет |
| Что под неё резервируют | сам лимит | total-лимит сверх user | heap headroom |
| Реакция на нехватку | kill запроса | spill, затем kill | headroom + GC |
System-аллокации и heap headroom
Третья категория — system-память: всё, что JVM-процесс Trino занимает не «по заказу конкретного оператора с резервированием». Это:
- буферы сетевого обмена exchange между стадиями;
- временные объекты парсинга, планирования, анализа запроса;
- кэши метаданных коннекторов;
- внутренние структуры самой JVM, GC, потоки;
- неточность учёта операторов — оператор сообщил «занял 100 MB», а реально из-за выравнивания объектов и overhead JVM занял 115 MB.
Эту память Trino не контролирует резервированиями. Если бы heap был распределён под user + revocable «под завязку», system-аллокации некуда было бы положить — и вот тут случился бы настоящий OutOfMemoryError всей JVM.
Защита от этого — heap headroom, свойство memory.heap-headroom-per-node. Это кусок heap, который Trino отрезает себе и не отдаёт под учитываемую память запросов. По умолчанию headroom равен 30% максимального heap ноды. Если на воркере -Xmx100G, то 30 GB резервируется под system-аллокации, и только оставшиеся 70 GB менеджер памяти готов раздавать запросам.
Слишком маленький headroom — главная причина настоящих JVM-OOM в Trino. Если урезать memory.heap-headroom-per-node, запросам станет доступно больше heap, но при всплеске exchange-буферов или большом плане JVM упадёт целиком — и заберёт все запросы ноды. 30% — разумный дефолт; уменьшать его стоит только с метриками GC и осознанием риска.
Отсюда жёсткое правило конфигурации, которое движок проверяет на старте:
query.max-memory-per-node + memory.heap-headroom-per-node < -Xmx
Сумма «памяти, которую можно отдать одному запросу на ноде» и «зарезервированного headroom» должна быть строго меньше максимального heap JVM. Иначе Trino откажется стартовать: такая конфигурация заведомо ведёт к OOM. Если воркеру задан -Xmx100G, а query.max-memory-per-node оставлен дефолтным (30% = 30 GB) и headroom дефолтный (30% = 30 GB), сумма 60 GB меньше 100 GB — конфигурация валидна, остаётся ещё 40 GB общего «дыхания».
Откуда исчезли memory pools
В старых материалах и докладах про Trino/Presto встречается модель memory pools: heap делился на general pool и reserved pool, и при нехватке самый «прожорливый» запрос целиком переезжал в reserved pool. Этой модели больше нет. Современный Trino использует единую модель: user / revocable / system, без отдельных пулов, а вместо «переезда в reserved pool» работает low-memory killer (его разберём в уроке 3).
Поэтому, читая чужую конфигурацию или статью, проверяйте дату: упоминания query.low-memory-killer.policy актуальны, а вот reserved pool, query.max-memory-per-node в паре с reserved-настройками — признак устаревшего материала.
Попробуй сам
Подними любой Trino (например, docker run -p 8080:8080 trinodb/trino:481) и через CLI или Web UI оцени модель памяти на практике.
-
Открой Web UI на
http://localhost:8080. В деталях любого запроса найди показатели памяти — «User Memory» и «Total Memory». Запусти лёгкийSELECTпоtpch.tinyи тяжёлый join поtpch.sf1и сравни цифры: где user memory заметно растёт, а где почти ноль. -
Через коннектор
jmxпосмотри учёт памяти изнутри:SELECT * FROM jmx.current."trino.memory:name=general,type=memorypool";Найди поля с freeBytes и reservedBytes — это и есть бухгалтерия менеджера памяти.
-
Прикинь на бумаге: воркер с
-Xmx64G, дефолтныеquery.max-memory-per-node(30%) иmemory.heap-headroom-per-node(30%). Сколько GB отрезано под headroom, какой потолок user memory у одного запроса на ноде, выполняется ли правилоper-node + headroom < -Xmx?
Цель — научиться отличать «запрос много читает» (user memory) от «движку не хватило дыхания» (headroom).