Команда Go for Devs подготовила перевод статьи о том, как Go 1.24 с новой реализацией Swiss Tables помог снизить использование памяти в продакшне на сотни гигабайт. В статье разбирают, что изменилось в реализации map, как это отразилось на профилях памяти, и какие оптимизации в коде дали дополнительный эффект.


В первой части — «Как мы выследили регрессию использования памяти в продакшен-сервисах на Go 1.24» — мы рассказали, как обновление до Go 1.24 привело к скрытой регрессии в рантайме, которая увеличила использование физической памяти (RSS) во всех сервисах Datadog. Мы совместно с сообществом Go выявили проблему, проверили исправление и спланировали безопасный релиз. Но во время отслеживания раскатки мы заметили нечто удивительное: в самых нагруженных окружениях память не просто вернулась к прежним значениям — её потребление заметно снизилось. Это породило новые вопросы: что изменилось в Go 1.24, что сделало некоторые рабочие нагрузки более эффективными по памяти? И почему улучшение не наблюдается во всех окружениях одинаково?

В этой статье мы разберём, как новая реализация Swiss Tables в Go помогла снизить потребление памяти в большой in-memory map, покажем, как мы профилировали и оценивали это изменение, и поделимся оптимизациями на уровне структур, которые дали ещё большую экономию по всему парку сервисов.

Чтобы понять, почему в высоконагруженных окружениях снизилось использование памяти, мы начали смотреть профили кучи для одного из сервисов в реальном времени. Сравнив профили «до» и «после», мы увидели следующее:

Мы экономим примерно 500 MiB живой кучи на map под названием shardRoutingCache в пакете ShardRouter. Если учесть влияние сборщика мусора Go (GOGC), который по умолчанию равен 100, это даёт уменьшение памяти на 1 GiB (то есть 500 × 2).
Если добавить ожидаемое увеличение RSS примерно на 400 MiB из-за проблемы с mallocgc, описанной в первой части, всё равно получается чистое снижение использования памяти примерно на 600 MiB!

Но почему так происходит? Для начала копнём глубже в map shardRoutingCache и разберёмся, как она наполняется.

Некоторые из наших сервисов обработки данных на Go используют пакет ShardRouter. Как следует из названия, этот пакет определяет целевой шард для входящих данных на основе ключей маршрутизации. Для этого при старте сервиса выполняется запрос к базе данных, а ответ используется для заполнения map shardRoutingCache.

Map устроена следующим образом:

shardRoutingCache map[string]Response // ключ — это routing key, полученный из входящих данных

type ShardType int // enum, описывающий тип шарда (hot, warm, cold)

type Response struct {
  ShardID      int32
  ShardType    ShardType
  RoutingKey   string      // (Позже разберём, зачем хранить routing key и в ключе, и в значении!)
  LastModified *time.Time  // метка времени последнего изменения записи
}

Оценка потребления памяти на одну запись

Чтобы точнее оценить сокращение памяти, давайте посчитаем размер каждой пары ключ–значение. На 64-битных архитектурах ключ занимает 16 байт (размер заголовка строки). Размер значения складывается из:

  • 4 байта для shardID (int32)

  • 8 байт для shardType (int)

  • 16 байт для заголовков строки routingKey

  • 8 байт для указателя lastModifiedTimestamp

О размере самих строк routingKey и о размере структур time.Time, на которые указывает lastModifiedTimestamp, поговорим позже (без спойлеров!). Пока предположим, что строки пустые, а указатель равен nil.

Это означает, что на каждое значение мы выделяем: (4+8+16+8) = 36 байт, или 40 байт с учётом выравнивания. В сумме это даёт 56 байт на каждую пару ключ–значение.

Большинство выделений памяти происходит на старте сервиса, когда выполняется запрос к базе данных. То есть, в течение работы сервиса вставки в эту map происходят крайне редко. Позже мы посмотрим, как это влияет на размер map.

Теперь сделаем шаг назад и посмотрим, как устроены map в Go 1.23 по сравнению с Go 1.24 и как это отражается на размере shardRoutingCache.

Как работали bucket-ориентированные map в Go 1.23

Структура bucket'ов и их расположение

Реализация map в рантайме Go 1.23 использует хеш-таблицы, организованные в массив bucket'ов. В Go количество bucket'ов всегда является степенью двойки (2ⁿ), и каждый bucket содержит 8 слотов для хранения пар ключ–значение. Давайте визуализируем это на примере map, которая содержит 2 bucket'а:

Каждый bucket содержит восемь слотов для хранения пар ключ–значение.

При вставке новой пары ключ–значение bucket, в который попадёт элемент, определяется с помощью хеш-функции: hash(key). Затем необходимо просканировать все существующие элементы в bucket, чтобы определить, совпадает ли ключ с уже существующим:

  • Если совпадает — обновляем пару ключ–значение.

  • Если нет — вставляем элемент в первый пустой слот.

При чтении элемента из map нужно проделать ту же операцию: просканировать все элементы в bucket. Сканирование всех элементов bucket при чтении и записи обычно составляет значительную часть CPU-накладных расходов при работе с map.

Когда bucket заполнен, но в других bucket'ах ещё достаточно свободного места, новые пары ключ–значение добавляются в overflow bucket'ы, которые связаны с исходным bucket. При каждой операции чтения/записи overflow bucket'ы также сканируются, чтобы определить, вставляем ли мы новый элемент или обновляем существующий:

Когда overflow bucket заполняется, но в других bucket'ах всё ещё есть свободное место, создаётся новый overflow bucket, который связывается с предыдущим, и так далее.

Увеличение map и коэффициент загрузки

Наконец, поговорим об увеличении map и о том, как он влияет на количество bucket'ов. Начальное количество bucket'ов определяется тем, как инициализируется map:

myMap := make(map[string]string)
// Эта map будет инициализирована с одним bucket'ом

myMapWithPreallocation := make(map[string]string, 100)
// Для 100 элементов нужно 100/8 = 12,5 bucket'ов.
// Ближайшая степень двойки — 2^4 = 16: map будет инициализирована с 16 bucket'ами

Для каждого bucket коэффициент загрузки определяется как количество элементов в bucket, делённое на количество слотов в bucket. В примере выше bucket 0 имеет коэффициент загрузки 8/8, а bucket 1 — 1/8.

По мере роста map Go отслеживает средний коэффициент загрузки по всем bucket'ам без overflow. Когда средний коэффициент строго превышает 13/16 (или 6,5/8), необходимо перераспределить map в новую, с удвоенным количеством bucket'ов. Обычно следующая вставка создаёт новый массив bucket'ов — в два раза больше предыдущего — и теоретически должна скопировать содержимое старого массива в новый.

Так как Go часто используется для серверов, чувствительных к задержкам, нежелательно, чтобы встроенные операции оказывали произвольное большое влияние на латентность. Поэтому вместо того чтобы копировать всю map за одну вставку, Go до версии 1.23 хранил оба массива одновременно:

Далее, при каждой новой записи в map Go постепенно перемещал элементы из старых bucket'ов в новые:

Примечание о коэффициентах загрузки: высокий коэффициент загрузки означает, что в среднем в каждом bucket хранится больше пар ключ–значение. В результате при вставке элемента нужно просканировать больше ключей в bucket, чтобы понять, обновляем мы существующий элемент или вставляем новый. То же справедливо и для чтения значения. С другой стороны, низкий коэффициент загрузки означает, что большинство bucket'ов пустует, а это ведёт к избыточному расходу памяти.

Оценка потребления памяти map

Теперь у нас почти вся информация, чтобы оценить размер map shardRoutingCache на Go 1.23. К счастью, мы отправляем кастомную метрику, которая показывает количество элементов в map:

Для примерно 3 500 000 элементов при максимальном среднем коэффициенте загрузки 13/16 нам потребуется как минимум:

  • Требуемое число bucket'ов = 3 500 000 / (8 × 13/16) ≈ 538 462 bucket'ов.

  • Ближайшая степень двойки, большая чем 538 462, — 2²⁰ = 1 048 576 bucket'ов.

Однако, поскольку 538 462 близко к 524 288 (2¹⁹), а shardRoutingCache почти не модифицируется, можно ожидать, что старые bucket'ы всё ещё будут выделены — мы всё ещё находимся в процессе перехода со старой map на новую, большую.

Это означает, что для shardRoutingCache выделено 2²⁰ (новые bucket'ы) + 2¹⁹ (старые bucket'ы), то есть 1 572 864 bucket'а.

Каждый bucket включает:

  • указатель на overflow bucket (8 байт на 64-битной архитектуре),

  • массив на 8 байт (используется внутренне),

  • 8 пар ключ–значение по 56 байт каждая: 56 × 8 = 448.

Итого один bucket занимает 464 байта памяти.

Следовательно, для Go 1.23 суммарная память под массивы bucket'ов составляет:
1 572 864 bucket'а × 464 байта/bucket = 729 808 896 байт ≈ 696 MiB.

Это только основной массив bucket'ов. Нужно также учесть overflow bucket'ы. Для хорошо распределяющей хеш-функции их должно быть относительно немного.

Реализация map в Go предварительно выделяет overflow bucket'ы на основе количества bucket'ов (2ⁿ⁻⁴ overflow bucket'ов). Для 2²⁰ bucket'ов это примерно 2¹⁶ = 65 536 overflow bucket'ов, что добавляет 65 536 × 464 байта — около 30,4 MiB.

В итоге расчётный объём памяти для map:

  • основной массив bucket'ов (включая старые) ≈ 696 MiB,

  • предварительно выделенные overflow bucket'ы ≈ 30,4 MiB,

  • всего ≈ 726,4 MiB.

Это согласуется с наблюдением в живом профиле кучи, где для map shardRoutingCache на Go 1.23 было около 930 MiB живой кучи: ~730 MiB под map и ~200 MiB под строки routingKey.

Как Swiss Tables и расширяемое хеширование всё изменили

Go 1.24 представил совершенно новую реализацию map, основанную на Swiss Tables и расширяемом (extendible) хешировании. В Swiss Tables данные хранятся в группах: каждая группа содержит 8 слотов для пар ключ–значение и 64-битное контрольное слово (8 байт). Количество групп в Swiss Table всегда является степенью двойки.

Рассмотрим пример таблицы, которая содержит две группы:

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

Вставка элементов в Swiss Tables

Давайте посмотрим, как вставляется новая пара ключ–значение в такую 2-групповую Swiss Table. Сначала вычисляем 64-битный хеш ключа hash(k1) и делим его на две части: первые 57 бит называются h1, последние 7 бит — h2.

Затем определяем, какую группу использовать, вычислив h1 mod 2 (так как у нас две группы). Если h1 mod 2 == 0, то пытаемся сохранить пару ключ–значение в группе 0:
Если h1 mod 2 == 0, мы пробуем сохранить пару ключ–значение в группе 0.

Перед вставкой проверяем, существует ли уже пара с таким ключом в группе 0. Если существует — обновляем существующую пару; если нет — вставляем новый элемент в первый пустой слот.

Вот здесь Swiss Tables проявляют свою силу: раньше для этого нужно было линейно сканировать все пары ключ–значение в bucket.

В Swiss Tables контрольное слово позволяет сделать это гораздо эффективнее. Так как каждый байт хранит младшие 7 бит хеша (h2) для своего слота, мы сначала сравниваем h2 вставляемого ключа с 7 младшими битами каждого байта контрольного слова.

Эта операция поддерживается аппаратно с помощью SIMD (single instruction, multiple data): сравнение выполняется одной инструкцией CPU параллельно для всех 8 слотов группы. Если специализированное SIMD-аппаратное обеспечение недоступно, сравнение реализуется стандартными арифметическими и побитовыми операциями.

Примечание: начиная с Go 1.24.2, SIMD-операции для map ещё не поддерживаются на arm64. Можно следить (и голосовать ?) за GitHub-ишью, которое отслеживает реализацию для архитектур, отличных от amd64.

Обработка заполненных групп

Что происходит, когда все слоты в группе заняты? Ранее, в Go 1.23, приходилось создавать overflow bucket'ы. В Swiss Tables пара ключ–значение сохраняется в следующей группе, где есть свободный слот. Поскольку операция пробинга (поиска) выполняется очень быстро, рантайм может позволить себе проверять дополнительные группы, чтобы определить, существует ли элемент. Последовательность пробинга останавливается, когда встречается первая группа с пустым (не удалённым) слотом:
Вставка k9, v9 в Swiss Table.

В результате эта быстрая техника пробинга позволяет полностью избавиться от концепции overflow bucket'ов.

Коэффициенты загрузки групп и рост map

Теперь посмотрим, как в Swiss Tables работает рост map. Как и раньше, коэффициент загрузки группы определяется как количество элементов в группе, делённое на её вместимость. В приведённом выше примере группа 0 имеет коэффициент загрузки 8/8, а группа 1 — 2/8.

Поскольку пробинг с использованием контрольного слова значительно быстрее, Swiss Tables используют более высокий максимальный коэффициент загрузки по умолчанию — 7/8, что снижает использование памяти.

Когда средний коэффициент загрузки строго превышает 7/8, map необходимо перераспределить — создать новую с удвоенным количеством групп. Обычно следующая вставка вызывает разделение (split) таблицы.

Но как быть с ограничением tail latency в системах, чувствительных к задержкам, о которых мы говорили ранее в контексте реализации Go 1.23? Если map содержит тысячи групп, то одна вставка будет вынуждена оплатить цену перемещения и пересчёта хешей для всех существующих элементов, что может занять много времени:

Диаграмма показывает операцию разделения таблицы, когда коэффициент загрузки строго выше 7/8, при вставке (k,v).

Go 1.24 решает эту проблему, устанавливая лимит на количество групп, которые могут храниться в одной Swiss Table. Одна таблица может содержать максимум 128 групп (1024 слота).

А что, если нам нужно хранить больше 1024 элементов? Здесь вступает в дело расширяемое хеширование (extendible hashing).

Вместо того чтобы реализовывать всю map в виде одной Swiss Table, каждая map становится директорией из одной или нескольких независимых Swiss Table. С помощью расширяемого хеширования переменное количество старших бит хеша ключа используется для определения, в какую таблицу попадёт ключ:

Расширяемое хеширование даёт два преимущества:

  • Ограниченное копирование групп: благодаря ограничению размера одной Swiss Table количество элементов, которые нужно копировать при добавлении новых групп, всегда ограничено.

  • Независимое разделение таблиц: когда таблица достигает 128 групп, она делится на две Swiss Table по 128 групп каждая. Это самая затратная операция, но она выполняется отдельно для каждой таблицы и остаётся контролируемой по стоимости.

В результате для очень больших таблиц подход с разделением таблиц в Go 1.24 более эффективен по памяти, чем подход Go 1.23, который хранил старые bucket'ы в памяти во время постепенной миграции.

Подытожим преимущества по памяти по сравнению с Go 1.23:

  • Более высокий коэффициент загрузки: Swiss Tables поддерживают коэффициент загрузки 87,5% (против 81,25% в Go 1.23), что требует меньше слотов в сумме.

  • Исключение overflow bucket'ов: Swiss Tables убирают необходимость в overflow bucket'ах и их указателях, компенсируя таким образом дополнительную память под контрольное слово.

  • Более эффективный рост: в отличие от Go 1.23, где старые bucket'ы продолжали занимать память во время миграции, подход Go 1.24 с разделением таблиц работает эффективнее.

Оценка потребления памяти map

Теперь применим всё, что мы узнали, чтобы оценить размер shardRoutingCache на Go 1.24.

Для 3 500 000 элементов при максимальном среднем коэффициенте загрузки 7/8 нам нужно как минимум:

  • Требуемое количество групп = 3 500 000 / (8 × 7/8) ≈ 500 000 групп.

  • Требуемое количество таблиц = 500 000 (групп) / 128 (групп в одной таблице) ≈ 3900 таблиц.

Так как таблицы растут независимо, директория может содержать любое количество таблиц — не только степени двойки.

Каждая таблица хранит 128 групп. Каждая группа содержит:

  • контрольное слово: 8 байт

  • 8 пар ключ–значение по 56 байт: 56 × 8 = 448 байт

Таким образом, каждая таблица занимает (448 + 8) байт на группу × 128 групп ≈ 58 368 байт.

В результате для Go 1.24 суммарное использование памяти под Swiss Tables:
3 900 таблиц × 58 368 байт/таблицу = 227 635 200 байт ≈ 217 MiB.
(Для сравнения, в Go 1.23 размер map составлял примерно 726,4 MiB.)

Это соответствует нашим наблюдениям в профилях кучи: переход на Go 1.24 экономит примерно 500 MiB живой кучи — или около 1 GiB RSS, если учитывать GOGC — для map shardRoutingCache.

Почему в низконагруженных окружениях таких улучшений не видно

Начнём с количества элементов в map в низконагруженной среде:

С учётом этого применим те же самые формулы.

В Go 1.23 с хеш-таблицами на bucket'ах

  • Для 550 000 элементов при максимальном среднем коэффициенте загрузки 13/16 нам требуется как минимум:

  • Требуемое число bucket'ов = 550 000 / (8 × 13/16) ≈ 84 615 bucket'ов.

Ближайшая степень двойки, превышающая 84 615, — 2¹⁷ = 131 072 bucket'а.

Поскольку 84 615 существенно больше 2¹⁶ (65 536), можно ожидать, что старые bucket'ы от последнего ресайза будут освобождены.

Это также соответствует 2¹³ предварительно выделенным overflow bucket'ам, так что общее число bucket'ов:

  • 2¹⁸ + 2¹⁴ = 139 264 bucket'а.

В результате для Go 1.23 суммарная память под массивы bucket'ов составляет:

  • 139 264 bucket'а × 464 байта на bucket = 64 618 496 байт ≈ 62 MiB.

В Go 1.24 со Swiss Tables

Для тех же 550 000 элементов и максимального среднего коэффициента загрузки 7/8 нам нужно:

  • Требуемое число групп = 550 000 / (8 × 7/8) ≈ 78 571 групп.

  • Требуемое число таблиц = 78 571 (групп) / 128 (групп в таблице) ≈ 614 таблиц.

Каждая таблица использует:

  • (448 + 8) байт на группу × 128 групп ≈ 58 368 байт.

Итого суммарная память под Swiss Tables:

  • 614 таблиц × 58 368 байт на таблицу = 35 838 144 байта ≈ 34 MiB.

Экономия живой кучи всё ещё составляет ~28 MiB. Однако это на порядок меньше, чем рост RSS на 200–300 MiB, который мы наблюдали из-за регрессии в mallocgc. Поэтому в низконагруженных окружениях общее потребление памяти всё равно растёт, что и совпадает с нашими наблюдениями.

Но возможность для оптимизации памяти всё ещё оставалась.

Как мы ещё больше сократили потребление памяти map

Вернёмся к map shardRoutingCache:

shardRoutingCache map[string]Response // ключ — routing key, извлечённый из полезной нагрузки данных

type ShardType int // enum, представляющий тип шарда (hot, warm, cold)

type Response struct {
  ShardID      int32
  ShardType    ShardType
  RoutingKey   string
  LastModified *time.Time
}

Изначально мы предполагали, что строки RoutingKey пустые, а указатель LastModified равен nil. Тем не менее все наши расчёты сходились и давали хорошую оценку памяти. Как так?

Пересмотрев код, мы обнаружили, что поля RoutingKey и LastModified никогда не заполняются в map shardRoutingCache. Хотя структура Response используется в других местах с установленными значениями этих полей, в этом конкретном кейсе они остаются пустыми.

Заодно мы заметили, что ShardType — это enum типа int64, хотя у него всего три возможных значения. Значит, можно использовать uint8 и при этом оставить запас до 255 значений.

Учитывая, что эта map может становиться очень большой, мы решили оптимизировать её. Мы подготовили PR, который делает две вещи:

  • меняет тип поля ShardType с int (8 байт) на uint8 (1 байт), оставляя возможность хранить до 255 значений;

  • вводит новый тип cachedResponse, который содержит только ShardID и ShardType, — больше мы не сохраняем пустую строку и nil-указатель.

Это уменьшает размер одной пары ключ–значение с 56 байт до:

  • 16 байт для заголовка ключа (без изменений),

  • 4 байта для ShardID,

  • 1 байт для ShardType (+ 3 байта выравнивания).

В итоге — 24 байта (с выравниванием) на каждую пару ключ–значение.

В наших высоконагруженных окружениях это примерно сократило размер map с 217 MiB до 93 MiB. Если учесть GOGC, это даёт около 250 MiB снижения RSS на под для всех сервисов обработки данных, использующих этот компонент.

Мы подтвердили экономию памяти в живых профилях кучи — пора было оценить операционные последствия.

Как это повлияло на снижение затрат

Эта оптимизация map была развернута во всех сервисах обработки данных, и теперь можно рассмотреть два пути снижения затрат.

Вариант 1: уменьшение лимита памяти в Kubernetes-контейнерах.
Это позволит другим приложениям в кластере использовать память, которую мы освободили на каждом поде.

Вариант 2: обмен памяти на CPU с помощью GOMEMLIMIT.
Если нагрузка ограничена CPU, установка GOMEMLIMIT позволяет обменять сэкономленную память на процессорное время. Снижение нагрузки на CPU может позволить нам уменьшить количество подов.

Для некоторых рабочих нагрузок, где GOMEMLIMIT уже был установлен, мы заметили небольшое снижение среднего потребления CPU:

Русскоязычное Go сообщество

Друзья! Эту статью перевела команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Go 1.24 в продакшене: выводы и уроки

Это исследование дало несколько важных инсайтов о том, как оптимизировать память в приложениях на Go:

  • Совместно с сообществом Go мы нашли, диагностировали и помогли исправить тонкую, но заметную регрессию памяти, появившуюся в Go 1.24. Она затрагивала физическое потребление памяти (RSS) во многих рабочих нагрузках — несмотря на то, что была незаметна для внутренних метрик кучи Go.

  • Каждая новая версия языка приносит оптимизации, но и риск регрессий, которые могут значительно повлиять на продакшн. Оставаться на свежих релизах Go важно, чтобы не только пользоваться улучшениями (как Swiss Tables), но и успевать ловить и устранять проблемы раньше, чем они ударят по продакшну на масштабе.

  • Метрики рантайма и профилирование живой кучи стали ключевыми инструментами расследования. Они помогли нам сформировать корректные гипотезы, отследить расхождения между RSS и управляемой Go памятью и эффективно коммуницировать с командой Go. Без подробных метрик и умения их анализировать такие тонкие проблемы могли бы остаться незамеченными или быть неверно интерпретированы.

  • Swiss Tables, появившиеся в Go 1.24, обеспечили значительную экономию памяти по сравнению с хеш-таблицами на bucket'ах из Go 1.23 — особенно для больших map. В наших высоконагруженных окружениях это привело к сокращению использования памяти под map примерно на 70%.

  • Помимо улучшений на уровне рантайма, собственные оптимизации структуры данных оказали реальный эффект. Убрав неиспользуемые поля из Response и подобрав более подходящие типы, мы ещё сильнее снизили потребление памяти.

Вместе эти изменения сделали Go 1.24 чистой победой для наших сервисов. И они подтвердили ключевой инженерный принцип Datadog: мелочи — будь то детали реализации рантайма или структура данных — могут складываться в огромный прирост производительности на масштабе.

Комментарии (0)


  1. Dhwtj
    22.09.2025 09:52

    Железо дёшево, человеческое время нет.


    1. flashmozzg
      22.09.2025 09:52

      Почему-то в этой формуле забывают про время потраченное пользователями (на то, что приложение звпускается на несколько секунд дольше и аыполняет операции медленнее). Соотношение пользователей к разработчикам обычно не в пользу последних.


    1. diderevyagin
      22.09.2025 09:52

      На больших системах - нет. В условиях когда есть условные 1000 серверов экономия даже 1% - огромный выигрыш, который не только отобьет работу инженера но и очень быстро принесет прямую прибыль.