Low-memory killer и поведение кластера под давлением
Лимиты из прошлого урока ловят запросы, которые по отдельности слишком тяжелы. Но есть другой сценарий: каждый запрос в своих лимитах, а кластеру всё равно не хватает памяти, потому что их много одновременно. Десять запросов по 4 GB user memory на ноде с 30 GB доступного heap — каждый легитимен, сумма катастрофична.
Если ничего не предпринять, итог — настоящий OutOfMemoryError JVM: воркер падает, унося все запросы на ноде. Чтобы этого не случилось, у Trino есть механизм поведения под давлением. Его ядро — low-memory killer: компонент, который при нехватке памяти осознанно выбирает и убивает один запрос, чтобы спасти остальные. Разберём, как кластер реагирует на давление по шагам и по какой логике killer выбирает жертву.
Первая линия обороны: блокировка драйверов
Прежде чем кого-то убивать, Trino пытается переждать давление, не давая ему расти. Здесь работает то, что мы описали в уроке 1: оператор не аллоцирует память напрямую, он запрашивает резервирование у менеджера памяти.
Когда нода приближается к пределу доступной памяти, менеджер начинает отказывать в резервировании. Оператор, не получивший память, не падает — он блокируется. Драйвер, в котором этот оператор работает, ставится на паузу: он перестаёт потреблять CPU и перестаёт просить ещё память. Как только память освобождается (другой запрос завершился, кто-то спиллил revocable-состояние), резервирование удаётся — драйвер просыпается и продолжает.
Блокировка драйверов решает временные всплески: один запрос вот-вот завершит сортировку и отпустит память — остальные просто подождут пару секунд. Платой становится рост латентности: заблокированные драйверы не двигаются. Но кластер цел, и ни один запрос не потерян.
Когда блокировки недостаточно
Блокировка работает, только если давление временное — кто-то скоро освободит память. А если нет? Представь: на ноде десять запросов, каждый держит большую нерастворимую user memory (хэш-таблицы join), и каждый заблокирован, ожидая память. Никто не может продвинуться, потому что для продвижения нужна память, а память освободит только тот, кто продвинется и завершится. Классический дедлок: все ждут всех.
Если просто оставить кластер в таком состоянии, заблокированные запросы будут висеть бесконечно, а нода — простаивать с полным heap. Вот тут и нужен low-memory killer: кто-то должен разорвать дедлок, принудительно убрав одного участника.
Дедлок памяти — это не «медленно», это «навсегда». Без low-memory killer кластер под памятным дедлоком встанет намертво: все драйверы заблокированы, никто не освобождает heap, новые запросы тоже не проходят. Killer — не «грубость», а необходимый размыкатель. Лучше потерять один запрос, чем заморозить весь кластер.
Как работает low-memory killer
Low-memory killer живёт на координаторе. Координатор периодически собирает с воркеров информацию о памяти — сколько занято, сколько свободно, какие запросы что держат на каждой ноде. Killer анализирует эту картину и решает: есть ли ситуация, требующая вмешательства.
Killer срабатывает не от первого же отказа в резервировании. Он ждёт, чтобы убедиться: давление устойчивое, а не мгновенный всплеск, который разрешится сам блокировкой драйверов. Для этого есть задержка — свойство task.low-memory-killer.high-memory-pool-utilization и связанная пауза: нода должна провести в состоянии нехватки памяти определённое время, прежде чем killer признает ситуацию дедлоком и начнёт действовать. Эта задержка отсеивает ложные срабатывания: дать блокировке драйверов шанс разрулить всё самостоятельно.
Поведением killer управляет свойство query.low-memory-killer.policy. Доступные политики:
Значение query.low-memory-killer.policy | Что делает |
|---|---|
total-reservation-on-blocked-nodes | (дефолт) Убивает запрос, занимающий больше всего памяти на тех нодах, что испытывают нехватку. Прицельно бьёт по виновнику давления |
total-reservation | Убивает запрос с наибольшим суммарным резервированием памяти по всему кластеру |
none | Killer выключен. Не рекомендуется: при дедлоке кластер встанет |
# etc/config.properties (координатор)
# Политика выбора жертвы low-memory killer
query.low-memory-killer.policy=total-reservation-on-blocked-nodes
Почему жертва — самый «большой» запрос
Логика выбора жертвы у дефолтной политики не случайна. Killer бьёт по запросу, который держит больше всего памяти на проблемных нодах, по двум причинам.
Во-первых, эффективность: убийство большого запроса освобождает максимум памяти за одно действие. Один удар разруливает дедлок; убивать несколько мелких запросов пришлось бы дольше, и каждое убийство — это потерянная чужая работа.
Во-вторых, справедливость: именно тяжёлый запрос с наибольшей вероятностью и есть причина давления. Убить его — значит наказать виновника, а не случайного соседа. Десять лёгких дашбордных запросов и один монструозный аналитический join: killer уберёт join, дашборды переживут.
Убитый запрос завершается с ошибкой вида Query killed because the cluster is out of memory и кодом CLUSTER_OUT_OF_MEMORY. Это сигнал не «запрос плохой», а «кластеру в этот момент не хватило памяти на всех». Реакция инженера — посмотреть на конкуренцию: возможно, нужны resource groups (урок 5), чтобы тяжёлая аналитика не конкурировала с дашбордами, либо spilling (урок 4), чтобы запросы выживали за счёт диска, либо просто больше памяти на кластере.
Полная картина поведения под давлением
Соберём всё вместе. Когда на ноде растёт давление памяти, Trino проходит по ступеням:
- Менеджер памяти отказывает в новых резервированиях. Рост памяти на ноде останавливается.
- Драйверы, не получившие память, блокируются. Они на паузе, не потребляют CPU, ждут освобождения. Если давление временное — кто-то скоро завершится, заблокированные проснутся, инцидент исчерпан.
- Spillable-операторы отзывают revocable-память. По команде они сбрасывают состояние на диск, освобождая heap. Если revocable-память есть и её достаточно — давление спадает без жертв.
- Давление устойчиво, спиллить больше нечего, признаки дедлока. После выдержки задержки координатор признаёт ситуацию дедлоком.
- Low-memory killer выбирает жертву по политике (дефолт — самый «тяжёлый» запрос на проблемных нодах) и убивает её. Память освобождается, дедлок разомкнут, остальные запросы продолжают.
Заметь порядок: убийство — последняя ступень, а не первая. Trino сначала тормозит рост, потом пытается обойтись spill, и только убедившись, что иначе кластер встанет, жертвует одним запросом. Если в твоём кластере killer срабатывает часто — это симптом: либо постоянная перегрузка по памяти, либо отсутствие изоляции нагрузки через resource groups, либо запросы, которым нужен включённый spilling.
Диагностика срабатываний killer
Когда killer убивает запрос, важно отличить «разовую невезуху» от «системной проблемы». Сигнал один и тот же — запрос завершился с CLUSTER_OUT_OF_MEMORY, — но реакция инженера зависит от того, как часто это происходит.
Где смотреть:
- Текст ошибки запроса. Убитый killer’ом запрос завершается с сообщением вида
Query killed because the cluster is out of memoryи кодомCLUSTER_OUT_OF_MEMORY. Это явный маркер: запрос убил именно killer, а не per-query лимит (EXCEEDED_GLOBAL_MEMORY_LIMIT/EXCEEDED_LOCAL_MEMORY_LIMIT— это другое, превышение лимита одним запросом). - Web UI. В списке завершённых запросов видны failed-запросы с этим кодом. Если их единицы в день — кластер изредка попадает под пик. Если десятки — кластер хронически перегружен.
- Метрики через
jmx. Менеджер памяти и killer публикуют JMX-метрики; по ним строят алерт «частота срабатываний killer выросла».
Как читать частоту:
Spark: unified memory manager и давление памятиЧастота CLUSTER_OUT_OF_MEMORY | Что это значит | Реакция |
|---|---|---|
| Единичные случаи, редкие пики | Кластер изредка попадает под совпадение тяжёлых запросов | Обычно терпимо; можно ничего не менять |
| Регулярно, в предсказуемые часы | В пиковые окна нагрузка стабильно превышает память | Resource groups для изоляции, расписание тяжёлых запросов, spilling |
| Постоянно, в разное время | Кластер хронически недоразмерен по памяти | Добавить воркеров или heap; пересмотреть лимиты |
Killer — это индикатор, а не решение. Само его срабатывание не «чинит» перегрузку, оно лишь не даёт кластеру встать. Если CLUSTER_OUT_OF_MEMORY сыплется регулярно, бороться надо не с killer (отключать его политикой none категорически нельзя — вернётся дедлок), а с причиной: изоляцией нагрузки, spilling или ёмкостью кластера.
Попробуй сам
Спровоцируй давление памяти на тестовом Trino и понаблюдай за реакцией.
- Возьми воркер с небольшим heap (например, контейнер с
-Xmx2GчерезJAVA_TOOL_OPTIONS) и ужмиquery.max-memoryтак, чтобы один тяжёлый join всё же проходил, но запас был мал. - Запусти параллельно несколько тяжёлых join’ов по
tpch.sf100— например, открой три CLI-сессии и стартуй запросы почти одновременно. Следи за Web UI: какие запросы переходят в состояние с заблокированными драйверами (растёт «Blocked» время), а какой получает ошибкуCLUSTER_OUT_OF_MEMORY. - Поменяй
query.low-memory-killer.policyс дефолтного наtotal-reservation, повтори сценарий и подумай: изменился ли выбор жертвы и почему один запрос-обжора будет выбран обеими политиками одинаково.
Цель — увидеть своими глазами ступени «блокировка -> spill -> kill» и понять, что убийство запроса по памяти — это защита кластера, а не сбой.