Стратегия управления памятью
Doris Allocator служит единой точкой входа для выделения крупных блоков памяти в системе. Он своевременно вмешивается в процесс выделения памяти, ограничивая его и обеспечивая эффективное и контролируемое использование памяти.
Doris MemoryArbitrator — это арбитр памяти, который в реальном времени отслеживает потребление памяти процессом Doris BE, регулярно обновляет сведения о состоянии памяти и собирает снимки статистики, связанной с памятью.
Doris MemoryReclamation — это механизм освобождения памяти, который при недостатке доступной памяти запускает GC для освобождения её части, обеспечивая стабильное выполнение большинства задач в кластере.
Doris Allocator

Allocator запрашивает память у системы и использует MemTracker для отслеживания объёмов выделяемой и освобождаемой памяти. Крупными блоками памяти, необходимыми для пакетного выполнения операторов, управляют различные структуры данных.
В процессе выполнения запроса выделением крупных блоков памяти в основном управляют структуры данных Arena, HashTable и PODArray. Allocator служит единым интерфейсом работы с памятью для Arena, PODArray и HashTable, обеспечивая единое управление памятью и её локальное повторное использование.

Allocator использует универсальный аллокатор для запроса памяти. При выборе между Jemalloc и TCMalloc учитывался предыдущий опыт Doris: в тестах с большим числом одновременных запросов на Spin Lock структуры CentralFreeList в TCMalloc приходилось 40% общего времени выполнения запросов. Хотя отключение агрессивного decommit памяти позволяет повысить производительность, оно приводит к значительному перерасходу памяти. Поэтому для периодического освобождения кеша TCMalloc приходится использовать отдельный поток. Jemalloc превосходит TCMalloc при большом числе одновременных запросов и является зрелым и стабильным решением. В Doris 1.2.2 был выполнен переход на Jemalloc. После настройки его производительность в большинстве сценариев сопоставима с TCMalloc, а потребление памяти ниже. Производительность при большом числе одновременных запросов также значительно улучшилась.
Arena
Arena — это пул памяти, который хранит список блоков памяти и выделяет из них память в ответ на запросы alloc. Это сокращает число обращений к системе за памятью и повышает производительность. Блок памяти называется Chunk. Он существует на протяжении всего жизненного цикла пула памяти и освобождается вместе с остальными блоками при уничтожении пула. Обычно жизненный цикл пула совпадает с жизненным циклом запроса. Arena также поддерживает выравнивание памяти и в основном используется для хранения сериализованных/десериализованных данных в процессе Shuffle, сериализованных Key в HashTable и т. д.
Начальный размер Chunk составляет 4096 байт. Для отслеживания позиции выделенной памяти внутри него используется курсор. Если оставшейся памяти в текущем Chunk недостаточно для выполнения текущего запроса, запрашивается новый Chunk и добавляется в список. Чтобы сократить число обращений к системе за памятью, пока размер текущего Chunk меньше 128M, размер каждого нового Chunk удваивается. Когда размер текущего Chunk превышает 128M, размер нового Chunk выбирается в пределах 128M при условии удовлетворения текущего запроса памяти, чтобы избежать избыточного расхода памяти. По умолчанию предыдущий Chunk больше не участвует в последующих операциях alloc.
HashTable
HashTable в Doris в основном используется в Hash Join, агрегации, операциях над множествами и оконных функциях. Основная используемая реализация PartitionedHashTable содержит до 16 вложенных HashTable и поддерживает параллельное слияние двух HashTable. Каждый вложенный Hash Join расширяется независимо, что должно уменьшить суммарное потребление памяти и распределить задержки при расширении.
Когда размер HashTable меньше 8M, она расширяется в 4 раза. Когда размер HashTable превышает 8M, она расширяется в 2 раза. Пока размер HashTable меньше 2G, коэффициент расширения составляет 50%, то есть расширение запускается при заполнении HashTable на 50%. После превышения размера 2G коэффициент расширения меняется на 75%. Чтобы избежать избыточного расхода памяти, HashTable обычно заранее расширяют с учётом объёма данных перед её построением. Кроме того, в Doris разработаны разные HashTable для разных сценариев. Например, PHmap используется для оптимизации производительности при параллельной работе в сценариях агрегации.
PODArray
PODArray — это динамический массив элементов типа POD. В отличие от std::vector, он не инициализирует элементы, поддерживает часть интерфейсов std::vector, выравнивание памяти и расширение в 2 раза. При уничтожении PODArray деструкторы отдельных элементов не вызываются, а сразу освобождается весь блок памяти. Он в основном используется для хранения данных столбцов, например String. Кроме того, PODArray широко применяется при вычислении функций и фильтрации по выражениям.
Повторное использование памяти
В слое выполнения Doris активно применяется повторное использование памяти, и большинство заметных узких мест, связанных с памятью, уже устранено. Например, блоки данных Block повторно используются на протяжении всего выполнения запроса. На стороне Sender в Shuffle всегда сохраняется один Block для приёма данных, а ещё один Block попеременно используется при передаче по RPC. При чтении Tablet слой хранения повторно использует столбец предиката для чтения, фильтрации и копирования данных в Block верхнего слоя, а затем выполняет Clear. При загрузке таблицы Aggregate Key, когда MemTable с кешированными данными достигает определённого размера, выполняется предварительная агрегация, после которой MemTable уменьшается и запись продолжается, и т. д.
Кроме того, перед началом Scan данных Doris заранее выделяет набор Free Blocks с учётом числа Scanners и потоков. Каждый раз при запуске Scanner планировщиком из этого набора берётся Block и передаётся слою хранения для чтения данных. После завершения чтения Block помещается в очередь производителя, откуда оператор верхнего слоя забирает его для дальнейших вычислений. После копирования данных оператор верхнего слоя возвращает Block в Free Block для следующего запуска Scanner планировщиком, обеспечивая повторное использование памяти. После завершения Scan данных все Free Block освобождаются в тех же потоках, где они были предварительно выделены. Это позволяет избежать дополнительных накладных расходов, возникающих при выделении и освобождении памяти в разных потоках. Количество Free Blocks также в определённой степени определяет параллелизм Scan данных.
GC памяти
Doris BE периодически получает от системы сведения об объёме физической памяти, используемой процессом, и текущем объёме доступной системной памяти, а также собирает снимки MemTracker всех задач запросов, загрузки и compaction. Когда потребление памяти процессом BE превышает лимит или системе не хватает доступной памяти, Doris освобождает кеш и завершает некоторые запросы или загрузки, чтобы высвободить память. Этот процесс регулярно выполняется отдельным потоком GC.

Minor GC запускается, когда потребление памяти процессом Doris BE превышает SoftMemLimit (по умолчанию 81% общего объёма системной памяти) или оставшийся объём доступной системной памяти опускается ниже порогового уровня Warning (обычно не более 3.2GB). В этот момент выполнение запроса приостанавливается при выделении памяти через Allocator, данные из кеша принудительно загружаются, а также освобождаются часть Data Page Cache и устаревшие записи Segment Cache. Если освобождено менее 10% памяти процесса и включён перерасход памяти запросами, запросы с большой долей перерасхода памяти отменяются, пока не будет освобождено 10% памяти процесса или не останется запросов, которые можно отменить. Затем уменьшаются интервал получения сведений о состоянии системной памяти и интервал GC. Остальные запросы продолжают выполняться, когда обнаруживается доступная память.
Если потребление памяти процессом BE превышает MemLimit (по умолчанию 90% общего объёма системной памяти) или оставшийся объём доступной системной памяти опускается ниже порогового уровня Low (обычно не более 1.6GB), запускается Full GC. В дополнение к перечисленным действиям загрузка также приостанавливается при принудительном сбросе данных кеша, а весь Data Page Cache и большая часть остальных кешей освобождаются. Если освобождено менее 20% памяти, начинается поиск в списке MemTracker всех запросов и загрузок по определённой стратегии. Сначала отменяются запросы с высоким потреблением памяти, затем загрузки с большой долей перерасхода памяти и загрузки с высоким потреблением памяти, пока не будет освобождено 20% памяти процесса. Затем увеличиваются интервал получения сведений о состоянии системной памяти и интервал GC, а остальные запросы и загрузки продолжают выполняться. Обычно GC занимает от сотен микросекунд до десятков миллисекунд.
Метод расчёта лимитов памяти и пороговых уровней
-
Лимит памяти процесса MemLimit =
be.conf/mem_limit * PhysicalMemory, по умолчанию составляет 90% общего объёма системной памяти, подробнее см. . -
Мягкий лимит памяти процесса SoftMemLimit =
be.conf/mem_limit * PhysicalMemory * be.conf/soft_mem_limit_frac, по умолчанию составляет 81% общего объёма системной памяти. -
Нижний порог оставшейся доступной системной памяти LowWaterMark =
be.conf/max_sys_mem_available_low_water_mark_bytes, значение по умолчанию -1. В этом случае LowWaterMark =min(PhysicalMemory - MemLimit, PhysicalMemory * 0.05). На машине с 64G памяти значение LowWaterMark немного меньше 3.2 GB (поскольку фактическое значениеPhysicalMemoryчасто меньше 64G). -
Предупреждающий порог оставшейся доступной системной памяти WarningWaterMark =
2 * LowWaterMark. На машине с 64G памяти значениеWarningWaterMarkпо умолчанию немного меньше 6.4 GB.
Расчёт оставшейся доступной памяти в системе
Если объём доступной памяти, указанный в сообщении об ошибке, ниже нижнего порога, это также считается превышением лимита памяти процесса. Значение доступной системной памяти берётся из MemAvailable в /proc/meminfo. Если MemAvailable недостаточно, последующие запросы на выделение памяти могут вернуть std::bad_alloc или привести к OOM процесса BE. Поскольку обновление статистики памяти процесса и GC памяти BE выполняются с некоторой задержкой, небольшой запас памяти резервируется в виде нижнего порога, чтобы по возможности избежать OOM.
Здесь MemAvailable — это общий объём памяти, который можно предоставить пользовательским процессам, по возможности не задействуя swap. Операционная система определяет его на основе текущего объёма свободной памяти, буферов, кеша, фрагментации памяти и других факторов. Упрощённая формула расчёта: MemAvailable = MemFree - LowWaterMark + (PageCache - min(PageCache / 2, LowWaterMark)). Это значение совпадает со значением available, которое показывает команда free. Подробнее см.:
Почему MemAvailable значительно меньше суммы MemFree, buffers и cached
Нижний порог по умолчанию составляет 3.2G (1.6G до 2.1.5). Он рассчитывается на основе MemTotal, vm/min_free_kbytes, confg::mem_limit, config::max_sys_mem_available_low_water_mark_bytes и позволяет избежать избыточного расхода памяти. Здесь MemTotal — это общий объём системной памяти, значение которого также берётся из /proc/meminfo; vm/min_free_kbytes задаёт буфер, резервируемый операционной системой для процесса GC памяти, и его значение обычно составляет от 0.4% до 5%. На некоторых облачных серверах значение vm/min_free_kbytes может составлять 5%, из-за чего отображаемый объём доступной системной памяти оказывается меньше фактического. Увеличение config::max_sys_mem_available_low_water_mark_bytes позволит зарезервировать больший запас памяти для Full GC на машинах с объёмом памяти более 64G, а уменьшение этого значения позволит использовать память максимально полно.