В Go map резко выросла, и потребление памяти временно приблизилось к двойному, хотя записи не дублировались логически. Каким механизмом роста хеш-таблицы объясняется этот пик?
Пик возникает потому, что при расширении map некоторое время сосуществуют прежнее и новое внутреннее хранилища. Записи постепенно переносятся в новую структуру, поэтому память под старую ещё не может быть освобождена, хотя логически каждая запись существует в одном экземпляре.
Динамическая хеш-таблица должна увеличивать ёмкость по мере заполнения, иначе растут коллизии и замедляется доступ. Полная немедленная перестройка всей таблицы создала бы длинную задержку в операции записи, поэтому runtime Go использует поэтапное перемещение данных, распределяя стоимость роста между последующими операциями.
При росте map приложение может временно потребить существенно больше памяти, чем требуется для итогового набора записей. Это особенно заметно для больших значений, указателей и нескольких одновременно растущих map.
Неверно считать, что удаление части записей немедленно возвращает память, а также что текущий логический размер map равен её пиковому физическому потреблению. При жёстком лимите памяти такой временный пик может привести к давлению на GC, вытеснению из контейнерного лимита или аварийному завершению процесса.
Когда runtime принимает решение увеличить map, он выделяет новое внутреннее хранилище с большей ёмкостью. Старое хранилище сохраняется до завершения переноса, а операции с map помогают выполнять этот перенос небольшими порциями; конкретные детали зависят от реализации и версии Go.
Пока перенос не завершён, runtime должен корректно обслуживать данные из обеих областей. После перемещения записей старое хранилище становится недостижимым для map и может быть reclaimed сборщиком мусора, но это не обязательно означает немедленное уменьшение RSS процесса.
Пиковая память зависит не только от числа записей. В неё входят сами ключи и значения, внутренние структуры таблицы, резерв при расширении, временно сосуществующие старые и новые области, а также возможные накладные расходы на выравнивание и указатели.
Предварительное задание приблизительной ёмкости через make обычно снижает число расширений и вероятность таких пиков, но не является точной гарантией размера внутреннего хранилища. Если map долго растёт, полезно оценивать не только средний объём данных, но и максимальный размер, скорость роста и количество одновременно живущих таблиц.
Сервис собирал большие наборы метаданных в нескольких map. В момент массового импорта RSS резко возрастал, после чего GC чаще запускался, а задержки запросов увеличивались.
Рассматривались два варианта: ограничить размер map и периодически пересоздавать её либо заранее оценить объём и создать map с достаточной начальной ёмкостью. Ограничение размера лучше контролировало память, но приводило к потере данных или дополнительной логике выгрузки; пересоздание уменьшало удерживаемую память, однако создавало новые аллокации и пиковую нагрузку при копировании.
Выбрали предварительное резервирование для ожидаемого объёма и ограничение максимального размера входного импорта. Это сократило количество расширений и пиковый объём памяти, сохранив предсказуемость поведения. Решение проверили нагрузочным тестом, потому что точный выигрыш зависит от типов ключей и значений, а не только от числа записей.
1. Освобождается ли старая память сразу после завершения переноса?
Нет. После переноса старое хранилище становится недостижимым, но его возврат зависит от работы GC и аллокатора. Даже после освобождения объектов процесс может сохранить полученные страницы для повторного использования, поэтому RSS способен уменьшаться не сразу или почти не измениться.
2. Гарантирует ли заранее заданная ёмкость отсутствие дальнейшего роста?
Нет. Аргумент ёмкости является ориентиром, а не жёстким лимитом. Если количество элементов превысит фактически доступную ёмкость либо структура потребует дополнительного роста из-за особенностей реализации, map всё равно может расшириться.
3. Почему рост map влияет на GC, даже если итоговое число записей невелико?
Во время роста одновременно живы старое и новое хранилища. Если ключи или значения содержат указатели, GC должен учитывать объекты, достижимые через обе структуры, пока старая ещё используется. Поэтому временный рост heap может увеличить объём сканируемой памяти и частоту циклов GC, даже если после завершения переноса логический размер map не изменился.