MAATRIX / Блог / ClickHouse съел всю память сервера на одном GROUP BY

ClickHouse съел всю память сервера на одном GROUP BY

MAATRIX

В пятницу вечером аналитик запустил один запрос в дашборде — и через минуту весь сервер с ClickHouse перестал отвечать: отвалились остальные базы на той же машине, SSH подвисал по десять секунд на каждую команду, а в dmesg посыпались строчки про Out of memory. Дежурный сначала грешил на утечку памяти в самом ClickHouse, потом на битую память сервера, а потом на реплику, которая якобы «зависла и не отдаёт данные». Правда оказалась куда проще и куда неприятнее: один GROUP BY по колонке с высокой кардинальностью, без внешней агрегации и без лимитов на память, честно попытался построить хеш-таблицу такого размера, что её не хватило бы и на сервер вдвое больше. Разбираем инцидент по шагам — что видели, что отбросили и что в итоге поменяли.

Что сломалось: один запрос положил всю машину

Сервер — выделенная машина под аналитику, ClickHouse держал агрегаты по логам событий за последние 90 дней, таблица на несколько миллиардов строк с движком MergeTree, партиционирование по дням. Рядом на той же машине крутились ещё Redis для кэша дашбордов и небольшой Postgres для метаданных — обычная для небольших контор экономия на железе.

Запрос выглядел безобидно: аналитик хотел посчитать количество уникальных пользователей и несколько агрегатов в разрезе session_id за месяц:

SELECT
    session_id,
    uniqExact(user_id) AS users,
    count() AS events,
    groupArray(event_type) AS types
FROM events
WHERE event_date >= '2026-08-01'
GROUP BY session_id

session_id в этой схеме был почти уникальным на каждую строку — де-факто кардинальность колонки приближалась к числу строк в выборке. GROUP BY по такой колонке — это не агрегация в привычном смысле, а построение отдельной группы (и отдельного состояния uniqExact, и отдельного массива groupArray) почти на каждую запись. В момент запуска запрос за секунды съел всю свободную память, потом полез в файловый кэш, потом в память Redis и Postgres по соседству, и ядро начало убивать процессы по своему усмотрению.

Через 40 секунд после запуска запроса освободилось всё: OOM killer прибил сам clickhouse-server, память вернулась, но заодно легли Redis (потерялся прогретый кэш дашбордов) и один из воркеров Postgres, который в этот момент писал транзакцию — пришлось откатывать и разбираться, не побилось ли что на диске.

Что видели в логах и метриках

Первым делом подняли dmesg — и там всё было прямым текстом:

Out of memory: Killed process 18422 (clickhouse-serv) total-vm:47285432kB, anon-rss:31457892kB
oom_score_adj:0

anon-rss под 31 ГБ на сервере с 32 ГБ RAM — фактически весь объём памяти ушёл в один процесс. free -h в момент, снятый мониторингом за 15 секунд до убийства, показывал доступную память около 200 МБ — то есть падение было не постепенным, а обвальным.

Дальше пошли в system.query_log ClickHouse (благо база пережила рестарт и лог сохранился на диске):

SELECT
    query_id,
    query,
    memory_usage,
    read_rows,
    type
FROM system.query_log
WHERE event_time >= now() - INTERVAL 30 MINUTE
ORDER BY memory_usage DESC
LIMIT 5

Один запрос с полем memory_usage в районе 30 ГБ и типом ExceptionWhileProcessing — это и был тот самый GROUP BY. Важная деталь: type показывал, что запрос завершился исключением уже после того, как память была съедена — то есть даже собственный лимит ClickHouse на память запроса не сработал вовремя, потому что он был выставлен либо в ноль (без ограничения), либо гораздо выше реального объёма RAM на сервере.

Заодно посмотрели system.metrics на предмет MemoryTracking и BackgroundPoolTask — фоновые мержи в этот момент шли штатно, ничего необычного не потребляли. Это сразу отсекло версию про «просто совпало с тяжёлым мержем партиций».

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Гипотезы, которые отбросили

Первая версия — утечка памяти в самом ClickHouse из-за версии сервера. Проверили system.build_options и changelog: используемая версия была стабильной, известных открытых issue с похожим паттерном на момент проверки не нашли. К тому же утечка обычно копится часами, а тут память ушла за десятки секунд — характер роста не совпадал.

Вторая версия — сбоящая память сервера (битые модули RAM). Прогнали memtester на выделенном под тест окне и посмотрели dmesg на предмет ECC-ошибок за последний месяц — ничего. Ошибка чётко привязывалась по времени к запуску конкретного запроса аналитика, а не к случайным моментам, что для аппаратной проблемы нетипично.

Третья версия — реплика «зависла» из-за проблем с сетью или диском. Проверили iostat и сетевые метрики на момент инцидента — диск не был перегружен, сеть тоже. Реплика не «зависала» сама по себе — процесс ClickHouse был убит ядром, и все клиентские соединения к нему по этой причине оборвались одномоментно, что снаружи выглядело как зависание.

Четвёртая версия — виноват groupArray без лимита на размер массива, а не uniqExact. Частично это тоже верно, но при проверке на копии данных именно uniqExact с почти-уникальной группировкой давал основной вклад в память: каждое отдельное состояние uniqExact — это отдельная небольшая хеш-таблица, и когда таких состояний миллионы (по числу групп), суммарный объём растёт линейно от количества групп, а не от объёма данных в привычном понимании.

В чём была реальная причина

Совпало сразу три фактора, и по отдельности каждый был бы терпим:

  1. GROUP BY по колонке с кардинальностью, близкой к количеству строк. ClickHouse хорошо агрегирует, когда групп на порядки меньше строк — вот тогда экономия памяти на агрегатах реально работает. Здесь групп было почти столько же, сколько строк.
  2. uniqExact и groupArray в одном запросе. Оба хранят состояние на каждую группу: uniqExact — точную хеш-таблицу уникальных значений, groupArray — сам массив значений без дедупликации и без ограничения на размер. На почти-уникальной группировке это фактически дублирование исходных данных в памяти в другом виде.
  3. Отсутствие лимита на память запроса и внешней агрегации. Настройка max_memory_usage для профиля аналитика была выставлена в 0 (без ограничения) «чтобы не мешала тяжёлым отчётам», а max_bytes_before_external_group_by тоже стоял в 0 — то есть ClickHouse даже не пытался сбросить промежуточное состояние агрегации на диск, а держал всё в RAM до последнего.

Отдельно проверили: если бы max_memory_usage был выставлен на разумное значение (скажем, 8–10 ГБ на пользователя, конкретную цифру каждый подбирает под свой сервер и профиль нагрузки), запрос просто упал бы с понятной ошибкой Memory limit exceeded — неприятно для аналитика, но безопасно для сервера. Вместо этого лимита не было, и запрос падал вместе с сервером.

Почему один запрос способен уронить весь сервер

Ключевой момент, который стоит понять один раз и запомнить: у ClickHouse по умолчанию (в зависимости от версии и профиля) нет жёсткого предохранителя «эта сессия не может съесть больше X от общей памяти сервера», если вы сами явно не выставили max_memory_usage и max_memory_usage_for_user. Планировщик ОС и cgroup-лимиты, если они не настроены отдельно для процесса ClickHouse, тоже не спасают — процесс просто наращивает потребление, пока не упрётся либо в лимит, либо в физическую память сервера.

Это принципиально отличается от, например, Postgres с классической архитектурой процессов, где один бэкенд ограничен work_mem и в худшем случае съедает свою долю, но не обязательно валит весь инстанс одним запросом (хотя и там есть свои грабли — если интересно, у нас разбирался похожий сценарий с Out of Memory в PostgreSQL). В ClickHouse аналитические запросы по своей природе рассчитаны на обработку огромных объёмов, и система по умолчанию доверяет вам самим выставить разумные границы.

Если аналитическая нагрузка на сервере разнородная — часть запросов лёгкие точечные, часть тяжёлые агрегаты по всей истории — то без явных профилей и квот один неудачный запрос от одного пользователя рано или поздно повторит этот сценарий. У нас в блоге есть отдельный разбор частых ошибок ClickHouse на сервере — переполнение памяти там на первом месте не случайно.

Что изменили после инцидента

Правки разделили на три уровня — сам ClickHouse, инфраструктуру сервера и процесс работы аналитиков.

Настройки ClickHouse. В профиль по умолчанию и, отдельно, в профиль для ad-hoc-запросов аналитиков добавили жёсткие лимиты:

<profiles>
    <analyst>
        <max_memory_usage>10000000000</max_memory_usage>
        <max_memory_usage_for_user>12000000000</max_memory_usage_for_user>
        <max_bytes_before_external_group_by>6000000000</max_bytes_before_external_group_by>
        <max_bytes_before_external_sort>6000000000</max_bytes_before_external_sort>
        <max_execution_time>120</max_execution_time>
        <max_result_rows>1000000</max_result_rows>
        <readonly>1</readonly>
    </analyst>
</profiles>

max_bytes_before_external_group_by включает сброс промежуточного состояния агрегации на диск, когда оно превышает заданный порог — запрос становится медленнее, но не роняет сервер. Конкретные числа здесь — это отправная точка, а не универсальный рецепт: их нужно подбирать под объём RAM и диска на вашем сервере и под реальный профиль запросов, у вас цифры почти наверняка будут другими.

Инфраструктура. ClickHouse вынесли на отдельный сервер, без соседства с Redis и Postgres — совместное размещение критичных сервисов на одной машине само по себе было архитектурной ошибкой, инцидент её просто проявил. Дополнительно настроили cgroup-лимит на процесс ClickHouse с небольшим запасом от физической памяти сервера, чтобы даже в худшем случае оставался запас для SSH и системных процессов и была возможность зайти и разобраться, а не ждать перезагрузки. Добавили алерт на system.metrics (MemoryTracking) с порогом в 70% от лимита сервера — это дало предупреждение за несколько секунд до потенциальной проблемы, что при таком резком росте не всегда успевает помочь, но в большинстве не столь взрывных случаев работает.

Процесс. Для тяжёлых нерегулярных отчётов ввели правило: запросы с groupArray или uniqExact по колонкам, у которых кардинальность заранее не оценена, сначала проверяются на EXPLAIN и на небольшой выборке (LIMIT по партициям), и только потом запускаются на полном диапазоне дат. Для оценки уникальных значений там, где точность до единицы не критична, перешли на uniqCombined или uniq вместо uniqExact — они используют приближённые алгоритмы с фиксированным и предсказуемым объёмом памяти на состояние, вместо растущей хеш-таблицы.

Отдельно настроили мониторинг самой базы через Grafana — если раньше смотрели только на CPU и диск, то теперь в панели есть память по каждому инстансу ClickHouse отдельно, с историей за последние недели, чтобы подобные всплески были видны заранее, а не только по факту падения. Мы отдельно разбирали, как настроить мониторинг баз данных через Grafana, если у вас такого дашборда пока нет — стоит сделать до первого похожего инцидента, а не после.

Как проверить, что у вас такого не случится

Если вы держите ClickHouse на своём сервере, стоит пройтись по короткому чек-листу прямо сейчас, не дожидаясь пятничного вечера:

  • Проверьте max_memory_usage в текущих профилях: SELECT * FROM system.settings WHERE name LIKE '%memory%' — если значение 0, лимита фактически нет.
  • Оцените кардинальность колонок, по которым обычно строите отчёты: SELECT count(DISTINCT session_id) FROM events в сравнении с общим count() — если цифры близки, GROUP BY по такой колонке потенциально опасен без внешней агрегации.
  • Убедитесь, что max_bytes_before_external_group_by и max_bytes_before_external_sort не равны 0 хотя бы в профилях для ручных запросов.
  • Проверьте, не делит ли ClickHouse сервер с другими критичными сервисами — Postgres, Redis, очередями. Если делит, лучше развести или хотя бы ограничить каждый процесс через cgroup.
  • Если аналитика растёт, а текущего сервера уже не хватает по памяти под реальные объёмы данных — иногда единственное честное решение — увеличить RAM или взять сервер с запасом заранее, а не подбирать лимиты впритык под старое железо. Для таких задач у нас есть отдельные конфигурации под выделенный сервер для аналитики больших данных в Великобритании — с расчётом RAM под конкретный объём таблиц, а не «на глаз».

Таблица для сравнения агрегатных функций по потреблению памяти на группу — то, чем стоит руководствоваться при выборе, что использовать в тяжёлых GROUP BY:

ФункцияТочностьПамять на группуКогда использовать
uniqExactТочнаяРастёт с числом уникальных значений в группеКогда точность критична и группы небольшие
uniqCombinedПриближённаяФиксированная, небольшаяДашборды, где погрешность в доли процента не критична
uniqПриближённая (HyperLogLog)Фиксированная, минимальнаяМаксимально тяжёлые отчёты по огромной кардинальности
groupArray без лимитаРастёт линейно с числом элементов в группеТолько для заведомо небольших групп
groupArray(N)(...)Ограничена N элементамиКогда нужен пример значений, а не все целиком

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли просто увеличить оперативную память и не трогать настройки?

Можно, и это снизит риск, но не устранит его — без лимита на память запроса рано или поздно найдётся ещё более тяжёлый запрос, который съест уже новый объём RAM. Увеличение памяти и правильные лимиты — это дополняющие меры, а не взаимозаменяемые.

Почему ClickHouse вообще не ограничивает память по умолчанию так же строго, как некоторые другие СУБД?

Потому что ClickHouse проектировался для обработки огромных объёмов данных, где искусственно заниженные лимиты по умолчанию мешали бы штатной тяжёлой аналитике. Разработчики оставляют настройку лимитов на усмотрение администратора, ожидая, что профиль нагрузки и объём железа вы знаете лучше, чем можно было бы угадать заранее.

Как понять, что запрос вот-вот повторит такой сценарий, до того как это случится?

Проверяйте кардинальность группирующих колонок заранее через count(DISTINCT ...), смотрите EXPLAIN на предмет предполагаемого объёма данных и держите включённым алерт на потребление памяти ClickHouse с запасом в 20–30% от установленного лимита.

Помогает ли перезапуск ClickHouse после такого инцидента сам по себе?

Перезапуск возвращает память, но не убирает причину — тот же запрос при повторном запуске уронит сервер снова. Сначала нужно выставить лимиты и разобраться с самим запросом, и только потом можно спокойно перезапускать сервис.

Стоит ли вообще запрещать аналитикам произвольные ad-hoc запросы?

Полный запрет обычно снижает пользу от аналитики больше, чем стоит риск. Разумнее дать отдельный профиль с жёсткими лимитами по памяти и времени выполнения — тогда неудачный запрос упадёт с понятной ошибкой, а не заберёт с собой весь сервер.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →