Таблицы страниц съедают память: разбор случаев от Oracle до Postgres

Автор блог-поста начинает с обсуждения 2003 года, где Линус Торвальдс спорил против хеш-таблиц страниц: в древовидной таблице записи соседних страниц лежат рядом, и одна строка кеша заполняет сразу несколько записей TLB, а хеш-таблица разбрасывает соседей по корзинам. В дипломной работе 1997 года Торвальдс объяснял выбор многоуровневых таблиц, считая память под отображение вторичной заботой. Но в 2020 году Shakeel Butt нашёл случай, где экономия памяти совсем не вторична: он отправил патч с метрикой таблиц страниц в memory.stat. Roman Gushchin спросил, зачем она нужна: таблицы страниц обычно занимают около 1/512 отображаемой памяти, то есть меньше 1% для большинства cgroup. Shakeel привёл пример: сетевой драйвер в пользовательском пространстве, который отображает память приложения для нулевого копирования и сильно расходует таблицы страниц.

Правило 1/512 верно, пока каждая страница отображена один раз. Если одну и ту же страницу отображают N процессов, каждому нужны свои записи таблицы (PTE), и стоимость вырастает примерно до N/512 от отображаемой памяти. При 512 процессах с одинаковыми записями (по 8 байт каждая) таблицы страниц занимают столько же, сколько сами страницы данных: 512 × 8 = 4 КиБ на страницу в 4 КиБ. Строго говоря, считать нужно адресные пространства, а не процессы.

Далее автор перечисляет найденные случаи. 2002 год, Andrea Arcangeli: на x86-машинах с 64 ГиБ памяти она заканчивалась при большом объёме свободной. Сотни процессов отображали одни и те же 1 ГиБ разделяемой памяти, и каждому требовалось около 2 МиБ таблиц (с PAE). Таблицы жили в области lowmem (около 896 МиБ физической памяти на 32-битной x86); патч перенёс их в highmem. Автор называет это краевым случаем, возможным только из-за 32-битной машины с PAE. 2022 год, Khalid Aziz: сервер Oracle с 512 ГБ ОЗУ падал по нехватке памяти, когда 1500+ клиентов подключались к области SGA (разделяемая память) в 300 ГБ. В худшем случае, когда каждый процесс отображает всю SGA, одни PTE заняли бы 878 ГБ. Khalid предложил mshare, чтобы процессы делили таблицы страниц; насколько проверил автор, ни одна версия mshare пока не принята. 2021 год, Qi Zheng: процесс с 590 ГиБ резидентной памяти и 110 ГиБ таблиц страниц, хотя должно хватать около 1,2 ГиБ. Аллокаторы jemalloc и tcmalloc возвращают память через madvise(MADV_DONTNEED): данные освобождаются и PTE очищаются, но сами таблицы остаются, и пустые таблицы копятся. Общих отображений здесь не было, только один процесс. Патч переписывали несколько раз и приняли в 2025 году.

Это не только редкости из рассылок: образец повторяется в базах данных. Percona (2021, Jobin Augustine): Postgres на машине со 192 ГБ ОЗУ, shared buffers 138 ГиБ и всего 80 подключениями; таблицы страниц выросли с 45 МиБ до более чем 25 ГиБ по мере того, как процессы обращались к кешу, началась подкачка и OOM-убийства. С huge pages (большими страницами) таблицы остались на уровне 61 МиБ. ClickHouse (2026, Kaushik Iska): машина со 128 ГиБ, shared buffers 32 ГиБ; каждый процесс, сканирующий кешированную таблицу в 15,6 ГиБ, добавлял 31 МиБ таблиц; при 200 подключениях таблицы заняли 6,1 ГиБ, а с huge pages, 111 МиБ.

Большие страницы не бесплатны: для резервирования нужна нефрагментированная память, и на нагруженной машине, как отмечает ClickHouse, тот же запрос «регулярно проваливается», а THP может задерживать выделение. В 2016 году Mel Gorman предлагал отказаться от дефрагментации для THP по умолчанию («прошли годы, пора сдаться»). Автор также упоминает Nelson Elhage, писавшего о возможных утечках памяти и нагрузке на процессор из-за THP.

В конце, проблема NUMA. Поток на другом узле читает таблицы страниц из удалённой памяти при промахах TLB. Mitosis (ASPLOS 2020) показала, что удалённые таблицы замедляют приложение так же сильно, как удалённые данные; статья Hydra (USENIX ATC 2024) воспроизвела это на 8-сокетной машине с 8 ТБ ОЗУ. Mitosis реплицирует всё дерево таблиц на каждый узел: память растёт как размер таблицы × число узлов, а каждое изменение обновляет все копии. Hydra копирует PTE на узел только тогда, когда поток этого узла получает ошибку страницы, а каждая страница таблицы хранит список узлов с копиями. Итог автора: либо одна копия и плата за удалённое чтение при промахах TLB, либо копия на каждом узле и плата за синхронизацию.

Ключевые факты

  • Таблицы страниц обычно составляют около 1/512 отображаемой памяти, но если одну страницу отображают N процессов, стоимость растёт примерно до N/512: при 512 процессах таблицы занимают столько же, сколько данные.
  • Случаи из практики: Oracle с 512 ГБ ОЗУ и 1500+ клиентами на SGA в 300 ГБ (в худшем случае 878 ГБ одних PTE); процесс с 590 ГиБ RSS и 110 ГиБ таблиц из-за накопившихся пустых таблиц.
  • Postgres у Percona: таблицы выросли с 45 МиБ до более чем 25 ГиБ при 80 подключениях, с huge pages остались на 61 МиБ; у ClickHouse при 200 подключениях 6,1 ГиБ против 111 МиБ.
  • Huge pages требуют нефрагментированной памяти, а THP может задерживать выделение; mshare для общих таблиц, по проверке автора, пока не принят.
  • На NUMA-машинах выбор такой: одна копия таблиц и удалённое чтение при промахах TLB либо копия на узел (Mitosis, Hydra) и плата за синхронизацию.

Почему это важно

Принято считать, что таблицы страниц составляют ничтожную долю памяти, и аргумент 1/512 действительно верен, когда страница отображена один раз. Пост показывает, где он ломается: много процессов отображают одну и ту же разделяемую память, и у каждого свои таблицы. Тогда расход растёт кратно числу процессов, и серверы падают по нехватке памяти (OOM) при внешне свободной памяти или при скромном числе подключений.

Кому это важно

Прежде всего разработчикам ядра и системным инженерам. Также тем, кто эксплуатирует базы данных с большой областью разделяемой памяти (в разборе, Oracle и Postgres, ClickHouse как источник измерений) и сервисы, где процессы возвращают память через madvise(MADV_DONTNEED). Для NUMA-машин с несколькими узлами памяти раздел про Mitosis и Hydra описывает конкретный компромисс.

Как это применить

Из разобранных случаев вытекают такие приёмы. Если много процессов отображают большой общий сегмент (shared buffers в Postgres), помогли huge pages: у Percona таблицы остались на 61 МиБ, у ClickHouse при 200 подключениях стоили 111 МиБ вместо 6,1 ГиБ. Надо помнить, что для их резервирования нужна нефрагментированная память. Для Oracle предложение mshare (общие таблицы страниц) пока, по проверке автора, не принято. Патч для накопления пустых таблиц от аллокаторов, возвращающих память через MADV_DONTNEED, принят в 2025 году.

Можно ли доверять

Это блог-пост-обзор: автор в тексте не назван, дата публикации не указана. Цифры, не собственные замеры автора, а пересказ писем из рассылок ядра и статей Percona и ClickHouse; автор сам оговаривает, что часть проверок («насколько я проверил») неполная. Для расчёта N/512 приведена простая арифметика, её можно перепроверить. Размеры замедления из Mitosis и Hydra в тексте не приведены.

Риски и подводные камни

Случай Andrea Arcangeli автор называет краевым: он возник из-за 32-битной машины с PAE и ограничений lowmem, обобщать его не стоит. Huge pages решают проблему не всегда: резервирование может проваливаться на нагруженной машине, THP способен задерживать выделение, а Mel Gorman в 2016 году предлагал отказаться от дефрагментации для THP по умолчанию. На NUMA-машинах нет бесплатного варианта: одна копия таблиц ведёт к удалённому чтению, копия на узел, к расходу памяти и синхронизации. Судьба патча с метрикой в memory.stat и итог случая с Oracle в источнике не описаны.

«для всех, кроме очень больших cgroups, значение будет в шуме счётчиков, которые ведутся отдельно для каждого процессора»

— Roman Gushchin, о метрике таблиц страниц (цитата в блог-посте)