Support us

Почему реализация большого кэша данных на.net и java — не очень хорошая идея?

Оставить комментарий
Почему реализация большого кэша данных на.net и java — не очень хорошая идея?

Большинство программистов, участвовавших в разработке веб-приложений, хоть раз сталкивались с кэшированием часто используемых данных. Некоторые даже создавали собственные реализации кэшей, натыкались на проблемы и пытались решить их. Давайте обсудим типичные проблемы, возникающие при разработке больших кэшей данных. Также попытаемся разобраться, в чем заключается проблема большинства реализаций кэшей данных на .net и java.

Сперва определимся, что такое большой кэш. Пусть это будет структура данных наподобие ассоциативного массива, которая может быть использована для кэширования значений по ключу. Пусть в качестве значений и ключей используется массив байт произвольного размера. Пусть количество различных пар (ключ, значение) в кэше ограничено большим числом (например, 1 миллион). Пусть суммарный объем памяти, который может занимать кэш, ограничен большим числом (например, 1 ГБ). Если при добавлении новой записи в кэш его размер превышает допустимый предел, то из кэша автоматически удаляются некоторые существующие записи, чтобы освободить место под новую запись. Кэш может использовать различные алгоритмы для определения, какие записи следует удалить при нехватке места.

Для чего же может быть полезен большой кэш? В первую очередь, для веб-приложений, которым нужны данные из различных бэкендов (баз данных, веб-сервисов, файловых хранилищ, чужих сайтов, общих кэшей и т.п.) для формирования ответов на входящие запросы. Данные, которые редко меняются либо часто запрашиваются, могут быть закэшированы в памяти веб-приложения, чтобы уменьшить нагрузку на бэкенды и увеличить скорость работы веб-приложения. Какой должен быть размер кэша для таких приложений? До тех пор, пока время доступа к данным из кэша меньше времени доступа к этим же данным из бэкенда, ответ на этот вопрос - чем больше, тем лучше. Ведь чем больше кэш, тем выше вероятность того, что запрашиваемые данные уже присутствуют в этом кэше. Бытует ошибочное мнение, что размер кэша не должен превышать физического объема оперативной памяти, установленной на компе. Говорят, что как только размер кэша превысит объем свободной оперативной памяти, то сразу же начинаются жуткие тормоза из-за того, что часть кэша нужно помещать в своп. Это не совсем так. Тормоза начинаются лишь в случае, если объем активно используемых данных (aka working set) не помещается в оперативную память. Например, если в кэше активно используются лишь 10% данных, то объем такого кэша может спокойно превышать объем имеющейся оперативной памяти в 10 раз, без существенных потерь в производительности.

Жизненный цикл типичной реализации кэша на .net или java

Программисты обожают писать веб-приложения на .net и java. Когда скорость веб-приложений начинает упираться в производительность бэкендов, программисты задумываются над кэшированием.

Первое, что обычно приходит на ум - "Почему бы не воспользоваться обычным Dictionary (aka HashTable) для организации кэша? По дороге завернем этот кэш во всеми любимый паттерн "синглтон", чтобы доказать окружающим, что мы уже шарим в паттернах проектирования.". После того, как последующее тестирование показывает десятикратное увеличение скорости обработки запросов, свежий билд радостно выкладывается в продакшн. Где его ждут адские муки с такими симптомами, как CPU 100% in GC и OutOfMemoryException. Выясняется, что разработчики забыли ограничить максимальный размер кэша.

Теперь срочно придумывается и реализуется нетривиальный алгоритм для ограничения размера кэша. Например, на основе LRU. Это существенно усложняет код кэша и добавляет кучу новых багов, связанных с проблемами синхронизации доступа к данным в многопоточной программе. После героических усилий по отладке этого кода, новый билд выкладывается в продакшн. Но вскоре выявляется забавная закономерность - если размер кэша сильно ограничить, то веб-приложение начинает тормозить из-за низкого процента попаданий в кэш. Если выставить средний размер кэша, то приложение начинает тратить много времени на сборку мусора, что вызывает периодические "подвисания" веб-приложения на период до нескольких секунд. Если же выставить слишком большой размер, то возвращаемся к OutOfMemoryError и CPU 100% in GC.

Тут кто-то из команды узнает про существование "слабых" ссылок (aka Weak Reference или Soft Reference) и восклицает - "Эврика! Давайте сделаем кэш на основе "слабых" ссылок - пусть сборщик мусора сам решает, когда нужно чистить содержимое кэша". Реализация кэша снова переписывается. Теперь код стал проще и понятнее, т.к. он не нуждается в явном отслеживании объема памяти, занимаемым кэшем, и не нужно хранить список LRU для закэшированных данных. Но в продакшне приложение вновь начинает жутко тормозить, возвращая нас к хорошо знакомому CPU 100% in GC, но уже без OutOfMemoryError. В чем же проблема? В том, что "слабые" ссылки не предназначены для автоматического ограничения размера кэшей. Сборщик мусора начинает удалять объекты, на которые указывают "слабые" ссылки, только при нехватке памяти под новые объекты. Таким образом, кэш занимает всю свободную память при постоянном добавлении новых элементов. Т.к. при обработке запросов веб-приложение обычно нуждается в памяти под временные объекты (например, под Request и Response), а вся память занята, то при каждом зарпосе может происходить многократный вызов сборщика мусора, чтобы он освободил память, занятую некоторыми данными из кэша. Это может вылиться в использование 100% реусрсов процессора, если сборщик мусора очень медленный. Сборщик мусора с поддержкой поколений (aka generational gc) обычно вызывает самую ресурсоемкую процедуру при нехватке памяти, чтобы очистить максимально возможный объем памяти. Эта процедура чистит все поколения сразу. В первую очередь удаляются все неиспользуемые объекты и только если освободился недостаточный объем памяти, начинается чистка "слабых" ссылок.

На этом месте разработчики сильно задумываются и решают вернуться к предыдущей реализации кэша (с багами, LRU и явным отслеживанием объема памяти, занимаемым кэшем). Там хоть можно было жить с низким hit rate'ом, но без повышенного использования ресурсов процессора и без периодических тормозов. Но тут кто-то из команды узнает про существование off-heap хранилищ данных типа Hazelcast (привет, Viaden) и Ehcache. Вот это радость! Теперь веб-приложения в продашкне больше не жрут 100% процессорного времени, не кидаются OutOfMemoryException'ами и в то же время показывают очень высокий cache hit ratio. Но почему? Что за черная магия? Ниже попробуем с этим разобраться.

Почему тормозят on-heap реализации больших кэшей под .net и java?

Потому что их сборщики мусора, работающие по принципу mark-and-sweep, не приспособлены для работы с большим количеством объектов, свойственным для больших кэшей. Время, затрачиваемое на каждый цикл этих сборщиков мусора, прямо пропорционально количеству объектов в памяти программы. Разработчики java и .net хорошо знали об этом недостатке, поэтому придумали костыль в виде разделения объектов на поколения. Объекты помещаются в разные поколения в зависимости от их возраста - только что созданные объекты помещаются в поколение "дети"; объекты, пережившие один цикл сборки мусора, перемещаются в поколение "взрослые"; объекты, пережившие несколько циклов сборки мусора, перемещаются в поколение "старики" (очевидно, сюда попадают большинство данных, помещенных в кэш). Количество поколений может варьироваться, но обычно не превышает трех. Сборщик мусора очищает разные поколения с разной частотой - "детей" чистит очень часто, а "стариков" - раз в сто лет. Это позволяет минимизировать время, затрачиваемое на сборку мусора - ведь чем младше поколение, тем больше вероятность того, что его объекты все еще находятся в кэшах процессора и не были выгружены в своп. Таким образом, если в программе преобладают объекты с маленьким временем жизни (например, которые живут лишь в пределах обрабатываемого запроса), то они будут быстро удалены при следующей чистке "детей". Именно на такой вариант расчитаны сборщики мусора в java и .net. Если "стариков" становится много, то начинаются проблемы. Например, периодические кратковременные "зависания" программы на время, пока сборщик мусора чистит "стариков". Если у программы достаточно свободной памяти, то с этими "зависаниями" еще можно жить, т.к. они случаются достаточно редко. Но как только у программы начинает заканчиваться свободная память (например, она занята под кэш), то частота "зависаний" резко увеличивается вплоть до постоянного 100% использования процессора - ведь сборщик мусора безуспешно пытается всеми способами освободить как можно больше памяти.

У сборщиков мусора с поддержкой поколений есть еще один недостаток при работе с большим количеством объектов старше "детей". Операции записи в такие объекты могут работать медленне, чем для "детей". Также большое количество операций записей в такие объекты увеличивает время работы цикла сборки мусора для "детей". Причина этому - write barrier'ы, про которые можно прочесть тут. Представьте, что в массив, находящийся в поколении "старики", был добавлен новый элемент. Затем начался цикл сборки мусора для "детей". Чтобы избежать удаления только что добавленного в массив элемента, сборщик мусора должен знать, что кто-то на него ссылается. Но для этого он должен опросить всех "стариков", тем самым убивая на нет все преимущества разделения объектов на поколения. Для того, чтобы не опрашивать объекты в старших поколениях, был придуман хитрый трюк под названием write barrier. Когда происходит запись в объект старших поколений, вызывается специальный код, который отмечает это событие в специальной структуре данных - обычно это битовая карта, где каждый бит отвечает за небольшой регион памяти. Установленный бит говорит о том, что в данном регионе памяти находится объект, который может указывать на новый объект из "детей". При каждом цикле очистки "детей" сборщик мусора находит с помощью этой структуры все объекты, которые потенциально могут указывать на "детей", и проверяет их. Чем больше таких объектов, тем дольше будет длиться цикл очистки. Если вспомнить, что большинство данных большого кэша являются "стариками", и что в кэш постоянно добавляются новые данные, то должно быть понятно, что write barrier может существенно замедлить работу кэша.

Можно ли обойти грабли сборщика мусора средствами .net и java?

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

Вывод

Если вам нужен эффективный кэш данных для .net или java, то проще всего реализовать его на языках программирования, в которых отсутствует автоматический сборщик мусора, после чего использовать готовую библиотеку из ваших программ на .net или java.
16 лет dev.by — «дефолтный» источник информации о беларусском ИТ

Вы можете...

Читайте также
12 онлайн-курсов по языку Java для новичков и профессионалов (август, 2023)
12 онлайн-курсов по языку Java для новичков и профессионалов (август, 2023)
12 онлайн-курсов по языку Java для новичков и профессионалов (август, 2023)
Java по-прежнему входит в список самых популярных языков программирования. Вместе с Digitaldefynd мы составили список курсов по Java, которые подойдут как новичкам, так и людям с опытом программирования, чтобы освоить этот востребованный язык.
Microsoft запустила обучающий сайт по Java
Microsoft запустила обучающий сайт по Java
Microsoft запустила обучающий сайт по Java
1 комментарий
Как оплачиваются самые популярные языки GitHub и какой прогноз
Как оплачиваются самые популярные языки GitHub и какой прогноз
Как оплачиваются самые популярные языки GitHub и какой прогноз
Вакансии для .NET/C#-разработчиков на jobs.dev.by
Вакансии для .NET/C#-разработчиков на jobs.dev.by
Вакансии для .NET/C#-разработчиков на jobs.dev.by

Хотите сообщить важную новость? Пишите в Telegram-бот

Главные события и полезные ссылки в нашем Telegram-канале

Обсуждение
Комментируйте без ограничений

Релоцировались? Теперь вы можете комментировать без верификации аккаунта.

Комментариев пока нет.