Лимиты памяти: max-memory, max-memory-per-node, max-total-memory
В прошлом уроке мы разобрали категории памяти: user, revocable, system. Теперь — конкретные рычаги. Trino даёт три основных свойства лимитов памяти, и они защищают кластер с двух разных сторон: один лимит ограничивает общий по кластеру расход запроса, другой — расход на одной ноде, третий накрывает то же сверху, включая отзываемую память.
Перепутать их легко, а цена ошибки высока: слишком жёсткий лимит убивает легитимные тяжёлые запросы, слишком мягкий — пускает запрос-обжору положить ноду. Разберём каждое свойство: что именно оно считает, какой у него дефолт и как они работают в связке.
Кластерный лимит: query.max-memory
query.max-memory ограничивает суммарную распределённую user memory одного запроса по всему кластеру. Это потолок «сколько данных запросу позволено держать в памяти на всех воркерах вместе взятых».
Дефолт — 20 GB. Если у вас 10 воркеров и запрос на каждом держит по 2.5 GB user memory под хэш-таблицы join, суммарно это 25 GB — превышение дефолтного лимита, и запрос будет убит с ошибкой EXCEEDED_GLOBAL_MEMORY_LIMIT.
Важные свойства этого лимита:
- Считается только user memory — нерастворимая память (join hash-таблицы, GROUP BY, sort без spill). Revocable-память сюда не входит.
- Считается сумма по всему кластеру, а не по одной ноде. Запрос может занять 2 GB на каждом из 10 воркеров — итого 20 GB, на грани.
- Превышение -> запрос убивается, потому что user memory нечем ужать.
# etc/config.properties (на координаторе)
# Кластерный потолок user memory одного запроса
query.max-memory=20GB
20 GB — это дефолт, рассчитанный на скромный кластер. На продакшен-кластере из мощных воркеров с большим heap его обычно поднимают: тяжёлая аналитика по lakehouse легко требует сотни GB суммарной user memory. Поднимать стоит осознанно — лимит существует, чтобы один монструозный запрос не выел всю память кластера и не заморил конкурентные запросы.
Лимит на ноду: query.max-memory-per-node
query.max-memory-per-node ограничивает user memory одного запроса на одном воркере. Это не «сколько всего на ноде», а «сколько на ноде позволено занять одному конкретному запросу».
Дефолт — 30% максимального heap ноды. Дефолт не абсолютная цифра, а доля от -Xmx. На воркере с -Xmx100G это 30 GB; на воркере с -Xmx48G — 14.4 GB. Это удобно: один и тот же config.properties корректно масштабируется на воркеры разного размера.
Зачем нужен отдельный per-node лимит, если уже есть кластерный? Потому что данные распределяются по нодам неравномерно. Из-за перекоса (data skew) запрос может уложиться в кластерные 20 GB, но при этом 18 GB из них окажутся на одной несчастливой ноде — и именно эта нода свалится в OOM, хотя «по кластеру всё хорошо». Per-node лимит ловит такой перекос: как только запрос на любой ноде превышает свой per-node потолок, он убивается с EXCEEDED_LOCAL_MEMORY_LIMIT, не успев положить ноду.
# etc/config.properties
# Потолок user memory ОДНОГО запроса на ОДНОЙ ноде
# 30% максимального heap воркера (дефолт)
query.max-memory-per-node=30%
Лимит сверху: query.max-total-memory
query.max-total-memory ограничивает всю память запроса по кластеру — user плюс revocable.
Дефолт — query.max-memory x 2. При дефолтном query.max-memory=20GB это означает query.max-total-memory=40GB.
Логика разделения такая. query.max-memory следит за «нерастворимой» частью — той, что нельзя отозвать. query.max-total-memory следит за полным отпечатком запроса в памяти, потому что revocable-память, пока не спилилась, тоже реально занимает heap прямо сейчас. Запас в два раза даёт spillable-операторам место накопить revocable-состояние, прежде чем оно сбросится на диск. Без total-лимита запрос с интенсивным spill мог бы держать гигантское revocable-состояние, и сумма user + revocable незаметно перегрузила бы кластер.
Превышение total-лимита -> запрос убивается. Аналогично есть и per-node вариант, query.max-total-memory-per-node, ограничивающий user + revocable на одной ноде.
Точные дефолты — сводка
Запомни эти значения наизусть: это самый частый предмет вопросов и самый частый источник ошибок конфигурации.
| Свойство | Что лимитирует | Дефолт | Ошибка при превышении |
|---|---|---|---|
query.max-memory | user memory, сумма по кластеру | 20GB | EXCEEDED_GLOBAL_MEMORY_LIMIT |
query.max-memory-per-node | user memory, одна нода | 30% максимального heap ноды | EXCEEDED_LOCAL_MEMORY_LIMIT |
query.max-total-memory | user + revocable, сумма по кластеру | query.max-memory x 2 | EXCEEDED_GLOBAL_MEMORY_LIMIT |
memory.heap-headroom-per-node | резерв heap под system-аллокации | 30% максимального heap ноды | (не лимит запроса — резерв ноды) |
Обрати внимание: дефолт query.max-total-memory задан относительно query.max-memory. Поднимешь query.max-memory до 100 GB, не трогая total, — query.max-total-memory автоматически станет 200 GB. Это удобно, но об этом легко забыть.
Как лимиты работают вместе — пример
Кластер: координатор плюс 8 воркеров, на каждом воркере -Xmx80G. Оставляем все свойства памяти дефолтными. Что получаем:
query.max-memory-per-node= 30% от 80 GB = 24 GB — потолок user memory одного запроса на одном воркере.memory.heap-headroom-per-node= 30% от 80 GB = 24 GB — резерв под system-аллокации.- Проверка правила старта:
24 GB (per-node) + 24 GB (headroom) = 48 GB < 80 GB (-Xmx)— конфигурация валидна, остаётся 32 GB «дыхания» под прочие конкурентные запросы. query.max-memory= 20 GB — суммарный кластерный потолок user memory запроса.query.max-total-memory= 20 GB x 2 = 40 GB — потолок user + revocable по кластеру.
Тонкий момент: query.max-memory (20 GB) меньше, чем query.max-memory-per-node x число воркеров (24 GB x 8 = 192 GB). Это нормально и осознанно. Per-node лимит — это страховка от перекоса на отдельной ноде, а не «бюджет, который надо набрать со всех нод». Реальным кластерным потолком user memory работает именно query.max-memory: запрос упрётся в 20 GB задолго до того, как приблизится к per-node лимитам.
Частая ошибка при тюнинге: поднять query.max-memory до огромного значения, забыв про query.max-memory-per-node и про правило per-node + headroom < -Xmx. Кластерный лимит станет щедрым, но запрос всё равно упрётся в per-node потолок на первой же ноде с перекосом. Поднимать лимиты надо согласованно, держа в голове все четыре свойства разом.
Превышение любого лимита даёт информативную ошибку. Пример вывода в CLI:
trino> SELECT l.*, o.* FROM tpch.sf1000.lineitem l
-> JOIN tpch.sf1000.orders o ON l.orderkey = o.orderkey;
Query 20260520_101500_00012_a3k9p failed:
Query exceeded distributed user memory limit of 20GB
[EXCEEDED_GLOBAL_MEMORY_LIMIT]
Сообщение прямо называет нарушенный лимит и его значение. Дальше у инженера три пути: переписать запрос (фильтры, уменьшить join), включить spilling (перевести часть нагрузки в revocable + диск), либо осознанно поднять лимит, если кластер реально потянет.
ClickHouse: настройка лимитов памяти Spark: broadcast hint и борьба с нехваткой памяти на joinГлобальные лимиты против сессионных override
Свойства из config.properties — это глобальные лимиты: они действуют на весь кластер и меняются только перезапуском. Но иногда нужен потолок не «для кластера», а «для конкретного запроса или пользователя». Для этого у части memory-свойств есть сессионные аналоги, задаваемые через SET SESSION без перезапуска кластера.
Сессионное свойство query_max_memory_per_node — пример такого override. Оно соответствует глобальному query.max-memory-per-node, но действует только в рамках текущей сессии:
-- Понизить per-node лимит только для своей сессии
SET SESSION query_max_memory_per_node = '8GB';
-- Посмотреть, что действует сейчас в сессии
SHOW SESSION LIKE 'query_max_memory%';
Важная асимметрия: сессионный override может лимит только понизить относительно глобального, но не поднять выше. Логика очевидна — иначе любой пользователь обходил бы защиту кластера, выставив себе гигантский лимит одной строкой SQL. Глобальное свойство задаёт жёсткий потолок, сессионное — позволяет отдельному запросу быть скромнее этого потолка.
Зачем это на практике. Аналитик знает, что его конкретный запрос тяжёлый и рискует выесть память в ущерб соседям, — он сам понижает себе лимит. Или администратор через resource groups и сессионные свойства даёт «дешёвой» группе запросов более строгие лимиты, чем кластерный дефолт. Сессионные override — это инструмент тонкой настройки поверх грубого глобального потолка.
Не все memory-свойства имеют сессионный аналог, и набор сессионных свойств меняется между версиями Trino. Полный актуальный список смотри командой SHOW SESSION на своём кластере или на странице свойств своей версии. Принцип же неизменен: сессионный override уточняет глобальный лимит в сторону строгости, но не ослабляет защиту кластера.
Наблюдение за памятью запроса
Лимиты бессмысленно настраивать вслепую — нужно видеть, сколько памяти запросы реально потребляют. У Trino два основных окна наблюдения.
Web UI координатора. В списке запросов и в деталях каждого запроса видны показатели памяти — текущая и пиковая user memory, total memory. Сравнив пиковую память типичных запросов с лимитами, понимаешь, есть ли запас или кластер работает на грани.
Коннектор jmx. Менеджер памяти публикует свои счётчики как JMX-метрики, а коннектор jmx даёт запрашивать их обычным SQL:
-- Состояние учёта памяти на нодах кластера
SELECT node, freebytes, reservedbytes, maxbytes
FROM jmx.current."trino.memory:name=general,type=memorypool";
node | freebytes | reservedbytes | maxbytes
----------------------+--------------+---------------+--------------
trino-worker-1 | 48318382080 | 12884901888 | 61203283968
trino-worker-2 | 55834574848 | 5368709120 | 61203283968
reservedbytes — это и есть бухгалтерия менеджера памяти из урока 1: сколько байт сейчас числится занятыми. Регулярно глядя на эти метрики под нагрузкой, видишь, насколько близко кластер подходит к лимитам и пора ли их пересматривать или добавлять воркеров.
Попробуй сам
Запусти Trino и проверь лимиты на практике.
-
Посмотри текущие действующие значения через системную таблицу:
SELECT * FROM system.metadata.materialized_views; -- пример системной схемыТочнее лимиты видны через коннектор
jmx— найди MBean’ы менеджера памяти и сверь их с тем, что ожидаешь по дефолтам. -
Временно ужми лимит в
etc/config.properties, напримерquery.max-memory=1GB, перезапусти Trino и запусти намеренно тяжёлый join поtpch.sf100. Поймай ошибкуEXCEEDED_GLOBAL_MEMORY_LIMITи прочитай её текст целиком. -
Для своего реального или гипотетического воркера с конкретным
-Xmxпосчитай все четыре значения из таблицы дефолтов и проверь правилоquery.max-memory-per-node + memory.heap-headroom-per-node < -Xmx. Если оно нарушено — какие свойства и как ты поменяешь?
Цель — уметь по -Xmx за минуту восстановить всю раскладку лимитов и заметить опасную конфигурацию.