Learning Platform
Глоссарий Troubleshooting
Урок 13.01 · 22 мин
Средний
memoryheapjvminternals

Модель памяти 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 запроса).

Учёт памяти: оператор не аллоцирует напрямую
ОператорHashBuilder, OrderBy, Aggregation — любой оператор, которому нужна значимая память для промежуточного состояния
reserve(bytes)
Менеджер памятиLocalMemoryManager воркера: ведёт счётчики байт по каждому запросу и суммарно по ноде, сверяет с лимитами
ok / отказ
JVM heapРеальная JVM-память: массивы байт, объекты Block. Trino-учёт идёт ПОВЕРХ настоящих аллокаций, как бухгалтерская книга

Важно понимать: Trino-учёт — это бухгалтерия, а не аллокатор. Реальные байты по-прежнему выделяет JVM через new byte[]. Trino лишь ведёт книгу: «за запросом Q сейчас числится 4.2 GB user memory». Точность учёта зависит от того, насколько честно операторы сообщают свой размер. Поэтому даже при идеально настроенных лимитах остаётся неучтённая память — и под неё резервируют headroom.

Spark: управление памятью — Unified Memory Manager ClickHouse: настройка памяти и MemoryTracker

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) — это именно потолок «сколько данных запросу позволено держать». Превысил потолок — запрос убивается, потому что ужать его нечем.

NOTE

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 командует таким операторам «отзовись» — они спиллят, и память освобождается для других.

user memory vs revocable memory
User memoryНельзя отозвать без убийства запроса. Хэш-таблицы join, буферы GROUP BY, сортировка без spill. Лимитируется query.max-memory
разница — в реакции на давление
Revocable memoryМожно отозвать: spillable-оператор сбрасывает состояние на локальный диск и освобождает heap. Учитывается в query.max-total-memory, но не в query.max-memory

Ключевое следствие: 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 memoryRevocable memorySystem-аллокации
Кто потребляетjoin hash-таблицы, GROUP BY, ORDER BYspillable join/aggregation/sortexchange-буферы, парсинг, метаданные
Можно отозватьНетДа — через spill на дискНет
Учитывается в query.max-memoryДаНетНет
Учитывается в query.max-total-memoryДаДаНет
Что под неё резервируютсам лимитtotal-лимит сверх userheap headroom
Реакция на нехваткуkill запросаspill, затем killheadroom + 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 менеджер памяти готов раздавать запросам.

WARNING

Слишком маленький 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 общего «дыхания».

Раскладка JVM heap воркера (-Xmx100G, дефолты)
Heap headroom — 30 GBmemory.heap-headroom-per-node = 30% от -Xmx. Под exchange-буферы, парсинг, кэши, неточность учёта. Запросам недоступно
Лимит одного запроса на ноде — до 30 GBquery.max-memory-per-node = 30% от -Xmx. Потолок user memory ОДНОГО запроса на этой ноде
Остаток — около 40 GBHeap, доступный под user memory других конкурентных запросов на этой же ноде сверх одного max-per-node

Откуда исчезли 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 оцени модель памяти на практике.

  1. Открой Web UI на http://localhost:8080. В деталях любого запроса найди показатели памяти — «User Memory» и «Total Memory». Запусти лёгкий SELECT по tpch.tiny и тяжёлый join по tpch.sf1 и сравни цифры: где user memory заметно растёт, а где почти ноль.

  2. Через коннектор jmx посмотри учёт памяти изнутри:

    SELECT * FROM jmx.current."trino.memory:name=general,type=memorypool";

    Найди поля с freeBytes и reservedBytes — это и есть бухгалтерия менеджера памяти.

  3. Прикинь на бумаге: воркер с -Xmx64G, дефолтные query.max-memory-per-node (30%) и memory.heap-headroom-per-node (30%). Сколько GB отрезано под headroom, какой потолок user memory у одного запроса на ноде, выполняется ли правило per-node + headroom < -Xmx?

Цель — научиться отличать «запрос много читает» (user memory) от «движку не хватило дыхания» (headroom).


Проверка знанийKnowledge check
Почему revocable memory не учитывается в query.max-memory, но учитывается в query.max-total-memory, и какой смысл в дефолтном соотношении total = max-memory x 2?
ОтветAnswer
Revocable memory — это память spillable-операторов (hash aggregation, hash join, sort), которую при давлении можно вернуть, сбросив состояние на диск. Поскольку её всегда можно отозвать через spill, она не угрожает кластеру так, как нерастворимая user memory, и потому не считается в query.max-memory — лимите именно user memory. Но эта память всё равно реально занимает heap прямо сейчас, поэтому её учитывает query.max-total-memory — лимит на всю память запроса, включая отзываемую. Дефолт query.max-total-memory = query.max-memory x 2 даёт запросу запас сверх его user-памяти: место, куда можно положить revocable-состояние, прежде чем оно спиллится на диск или прежде чем сработает total-лимит. Без такого запаса spillable-операторы упирались бы в потолок сразу, не успев накопить состояние до момента spill.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Чем отличается user memory от revocable memory в модели памяти Trino?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6