Java / Как мы избавились от пауз GC с помощью собственного java off-heap storage решения

:

Некоторые системы просто не могут давать адекватный отклик без кэширования данных. Причем рано или поздно они могут наткнуться на проблему, что данных, которые хотелось бы кэшировать, становиться все больше и больше. Если ваша система написана на java, то это приводит к неизбежным паузам GC. Когда-то Одноклассники тоже столкнулись с этой проблемой. Мы не хотели ограничивать себя в размере кэшируемых данных, но в то же время понимали, что GC нам просто не позволит иметь Heap требуемого нам объема. С другой стороны, мы хотели продолжать писать на java. В этом топике мы опишем, как решили эту проблему для себя со всем плюсами и минусами нашего подхода, а также опытом использования. Надеемся, что наш подход заинтересует тех, кому приходится бороться с паузами GC.

off-heap ). У вас наверняка возник вопрос: зачем нам понадобился такой нестандартный подход с  off-heap , и почему мы не использовали готовое решение?

Очевидно, что самое эффективное хранилище (в частности кэш) — то, которое всегда находится в оперативной памяти и доступно процессу приложения без какого-либо сетевого взаимодействия. Для приложений, написанных на java, вполне разумным является реализация хранилища тоже на языке программирования java, так как пропадает проблема интеграции. К тому же любой программист, работающий над системой, может легко разобраться в тонкостях его работы, посмотрев исходники. Но так как при большом heap практически любая java программа начинает страдать от пауз GC, то при суммарном размере данных в несколько гигабайт и выше, система становится недееспособной (если только весь Heap это не просто один большой массив байтов). Настройка GC — задача не из тривиальных. Если же ваш heap уже содержит десятки гигабайт, то настроить GС, чтобы паузы были адекватны, для приложения, с которым взаимодействует пользователь, практически нереально. А так как большая оперативная память становится все более и более доступной, то подход с java off-heap кэшом приобретает все больше и больше популярности.

Когда-то давно данный подход действительно можно было посчитать нестандартым. Но, посмотрите, сейчас все больше продуктов появляется и развивается в этом направлении. Около полутора лет назад на рынке появилось первое решение такого рода: BigMemory от Terracota. Полгода назад Hazelcast объявил о том, что они выложили на  alpha-тестирование продукт под названием Elastic Memory. Но, к сожалению, он до сих пор в beta стадии, да к тому же, как и BigMemory, будет платным. Cassandra около полугода назад начала использовать кэширование записей вне кучи ( off-heap row cache). Но это кэш для внутренних целей, который не так просто выковырять, чтобы использовать у себя. Крепкого open-source продукта в этой области попросту пока нет. Однако стоит заметить, что осенью в инкубатор apache попал проект DirectMemory. Правда, когда ждать готового продукта и когда он себя зарекомендует в крупных проектах, вопрос открытый. Мы же начали использовать свое решение больше четырех лет назад, ничего готового, как вы видите, в то время попросту не было.

Реализация


Общение java приложения с памятью вне кучи может быть реализовано несколькими способами. Одноклассники в свое время выбрали подход, основанный на использовании класса sun.misc.Unsafe, который входит в приватный пакет HotSpot, а сейчас и OpenJDK. Данный класс позволяет работать напрямую с памятью, без явного использования JNI, что оставляет наше решение кроссплатформенным, и мы можем запускать одни и те же бинарники как на основной системе, так как и на компьютере разработчика, причем не важно — сидит ли разработчик под Windows, Ubuntu или Mac. Чтобы не писать сложный алгоритм управления памятью, как, например, решили сделать в DirectMemory, мы для каждого объекта, который хотим положить в хранилище или удалить из него, выделяем или освобождаем память отдельно. Пусть управлением памятью занимается операционная система, у неё это очень хорошо получается. Именно по причине того, что нам нужно часто выделять и освобождать память, мы не используем стандартный класс java.nio.ByteBuffer, который находится в составе JDK, имеет открытое API и позволяет работать с памятью вне кучи. Проблема с ByteBuffer в нашем случае в том, что он создает дополнительный мусор и не позволяет напрямую освобождать память, а делает это на основе фантомных ссылок. Последнее ведет к тому, что, выделив много памяти вне кучи, она не может быть освобождена, пока не сработает GC, даже если объекты ByteBuffer, выделившие эту память, уже не используются. Хотя для решения этой проблемы есть флаг -XX:MaxDirectMemorySize=, который инициирует сборку мусора при достижении выделенной памяти вне кучи заданного значения, все равно большое количество фантомных ссылок может негативно повлиять на GC. Данное поведение ByteBuffer скорее всего объясняется тем, что он проектировался для выделения больших объемов памяти и их переиспользования. Мы же используем противоположный паттерн работы. Конечно, отказ от ByteBuffer заставил нас делать некоторые вещи самостоятельно, как то, например, заботиться об  byte order.

У пытливого читателя может возникнуть вопрос: не стесняемся ли мы использовать приватные механизмы HotSpot (sun.misc.Unsafe) для нашего функционала, ведь они могут поменяться или исчезнуть в один прекрасный момент. Нет, нисколько. Ведь эта самая HotSpot новой версии не окажется у нас в рабочих версиях неожиданно, т.е. мы будем к этому готовы. Все, что мы используем из Unsafe, использует уже упомянутый ByteBuffer. Т.е. если в очередной версии что-то изменится, нам будет достаточно заглянуть в исходники и сделать такие же изменения в нашем фреймворке, а точнее всего в одном классе, отвечающем за выделение/освобождение памяти и сохранение данных.

Возможности нашего фреймворка


Нет ничего сложного в том, чтобы сериализовать объект в массив байтов и поместить его вне кучи. Чем же еще нам, в Одноклассниках, помогает фреймворк?

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

Read-through . Некоторые из наших хранилищ помогают снять нагрузку с базы, т.е. используются в качестве кэшей. Для такого случая у нас есть опция read-through . Если в кэше нет данных, которые запрашивает клиент, то мы идем в БД и читаем их. Здесь у нас есть еще некоторые хитрости, например, если придет два одинаковых запроса одновременно, то в БД мы пойдем только один раз, а второй запрос просто дождется результата первого. Это очень помогает в случае холодного старта. Или если клиент постоянно запрашивает данные, которых в кэше нет, то через какое-то количество попыток мы перестаем обращаться к БД за этими данными.

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

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

Клиентская библиотека. Если хранилище становится очень большим и не помещается полностью на наше commodity железо, то мы разносим его по нескольким машинам и используем шардинг по ключу. Причем делаем мы это не  из-за возрастающих пауз GC, а именно из-за ограничения железа. Конечно, тут теряется вся прелесть in-process кэша и появляется сетевое взаимодействие, но тут уж ничего не поделаешь. Зато остаётся наш старый знакомый код, целиком написанный на нашей любимой Java. К тому же какая-то логика может тоже быть легко вынесена на эти отдельные боксы, так как CPU там все равно простаивает. Наша клиентская библиотека так же позволяет обновлять или получать только требуемые поля объектов из хранилища, что значительно уменьшает трафик. Для отказоустойчивости и масштабируемости, мы используем дублирование таким образом, что за каждую шарду у нас отвечает как минимум два хоста. Запросы на чтение идут по  round-robin алгоритму, апдейты идут на все ноды.

Недостатки и вынужденные компромиссы


Конечно же, в нашем фреймворке есть и недостатки. Например, по причине того, что ключи мы храним все же в куче, в  какой-то момент GC может начать сказываться на производительности приложения. Мы стараемся не использовать ключи больше Long, поэтому даже на наших объемах данных мы вполне удовлетворены текущими характеристиками.

Как уже упоминалось, в отличие от некоторых существующих решений, мы отдельно аллоцируем память для каждого объекта. Хотя это и не может не сказываться на дефрагментации памяти, зато мы обошлись без сложного алгоритма управления памятью.

Чтобы эффективно уметь делать гранулированные обновления, нам приходится отдельно аллоцировать память для каждого поля переменной длины (такое как строка или коллекция). Таким образом, для создания одного объекта иногда мы делаем несколько низкоуровневых вызовов.

Использование внутри Одноклассников


Используя вышеописанный фреймворк, мы храним профили пользователей, профили групп, метаинформацию по фотографиям, информацию по классам (like), статистику действий пользователя по отношению к своим друзьям и группам, куски ленты и  кое-что еще. Например, распределенное хранилище статистики, на основе которой считаются веса пользователей для попадания в ленту, содержит около 300Гб данных. Максимальный же размер данных в памяти хранилища на одном боксе у нас достигает 90 Гб. Средняя нагрузка на 2ух процессорную железку с 4 мя ядрами на каждом в пике составляет около 20K запросов в секунду.

Стоит особо отметить, что наше хранилище ни в коей мере не служит нам заменой RDBMS или чем-то вроде NoSQL решения. Чаще мы его используем просто в качестве кэша для базы данных. Возможно, в некоторых местах использования мы могли бы перейти на NoSQL вместо существующей связки кэш плюс RDBMS, но это требует сильного пересмотра бизнес-логики , которая может опираться на транзакционность, обеспечиваемую RDBMS.

Таким образом, описанный фреймворк позволяет нам писать весь код на java, хранить в heap такой объем данных, который требует задача, не иметь головной боли с настройками GC, быстро внедрять хранение или кэширование новых данных.

Нам будет также очень интересно узнать о вашем опыте решения проблем пауз GC на больших объемах heap.