Java-сериализация: максимум скорости без жёсткой структуры данных

Наша команда в Сбербанке занимается разработкой сервиса сессионных данных, который организует взаимообмен единым Java-контекстом сессии между распределёнными приложениями. Наш сервис крайне нуждается в очень быстрой сериализации Java-объектов, поскольку это часть нашей mission critical задачи. Изначально нам на ум приходили: Google Protocol Buffers, Apache Thrift, Apache Avro, CBOR и др. Первая тройка из перечисленных библиотек требует для сериализации объектов описания схемы их данных. CBOR такой низкоуровневый, что умеет сериализовывать только скалярные значения и их наборы. Нам же была нужна библиотека Java-сериализации, «не задающая лишних вопросов» и не заставляющая вручную разбирать сериализуемые объекты «на атомы». Мы хотели сериализовывать произвольные Java-объекты, не зная о них практически ничего, и хотели делать это максимально быстро. Поэтому мы устроили соревнование для имеющихся Open Source решений задачи Java-сериализации.

КДПВ


Для соревнования мы отобрали наиболее популярные библиотеки Java-сериализации, главным образом, использующие бинарный формат, а также библиотеки, хорошо зарекомендовавшие себя в другихобзорах Java-сериализаторов.
Ну что, поехали!
Скорость — вот основной критерий оценки библиотек Java-сериализации, которые являются участниками нашего импровизированного соревнования. Для того чтобы объективно оценить, какая из библиотек сериализации быстрее, мы взяли реальные данные из логов нашей системы и скомпоновали из них синтетические сессионные данные разной длины: от 0 до 1 МБ. По формату данные представляли собой строки и байтовые массивы.

Примечание: Забегая вперёд, следует сказать, что победители и проигравшие выявились уже на размерах сериализуемых объектов от 0 до 10 КБ. Дальнейшее увеличение размера объектов до 1 МБ не изменило исход соревнования.
В связи с этим, для лучшей наглядности, приведённые ниже графики эффективности работы Java-сериализаторов ограничены размером объектов в 10 КБ.

Конфигурация системы, на которой производились измерения:

Примечание: К нашему сожалению, на IBM JRE отказалась работать библиотека One Nio (участники под номерами 13 и 14). Эта библиотека использует класс sun.reflect.MagicAccessorImpl для обращения к private и final (при десериализации) полям классов, минуя проверки уровня доступа. Оказалось, IBM JRE не поддерживает этих основных свойств класса sun.reflect.MagicAccessorImpl, не смотря на то, что сам класс в runtime имеется.

Для того чтобы не удалять данных участников гонки на самом старте (а, согласно Serialization-FAQ, библиотека One Nio обладает широкими возможностями), мы решили сделать fork данной библиотеки, в котором использование класса sun.reflect.MagicAccessorImpl было бы выключаемым. При выключенном использовании sun.reflect.MagicAccessorImpl в нашем fork-е используется класс sun.misc.Unsafe для достижения тех же целей.
Кроме того, в нашем fork-е была выполнена оптимизация сериализации строк — строки стали сериализовываться на 30–40% быстрее при работе на IBM JRE.

В связи с этим, в данной публикации все результаты для библиотеки One Nio получены на собственном fork-е, а не на оригинальной библиотеке.

Непосредственное измерение скорости сериализации/десериализации выполнялось с помощью Java Microbenchmark Harness (JMH) — инструмента от OpenJDK для построения и запуска benchmark-ов. Для каждого измерения (одной точки на графике) использовалось 5 секунд для «прогрева» JVM и ещё 5 секунд для самих измерений времени с последующим усреднением.
Вот, что получилось:

Гонки - все участники

Сначала заметим, что варианты библиотек, добавляющие в результат сериализации дополнительные мета-данные, работают медленнее, чем дефолтные конфигурации этих же библиотек (см. конфигурации «with types» и «for persist»).
В целом, не зависимо от конфигурации аутсайдерами по результатам сериализации становятся Jackson JSON и Bson4Jackson, которые выбывают из гонки.
Кроме того, по результатам десериализации из гонки выбывает Java Standard, т.к. при любом размере сериализуемых данных десериализация кратно медленнее конкурентов.

Взглянем поближе на оставшихся участников:

Гонки - кроме аутсайдеров

По результатам сериализации в уверенных лидерах идёт библиотека FST, а при увеличении размера объектов ей «на пятки наступает» One Nio. Заметим, что у One Nio вариант «for persist» сильно медленнее дефолтной конфигурации по скорости сериализации.
Если взглянуть на десериализацию, то видим, что One Nio с увеличением размера данных смог обогнать FST. У последнего, напротив, нестандартная конфигурация «unsafe» заметно быстрее выполняет десериализацию.

Для того чтобы расставить все точки над И, давайте посмотрим на суммарный результат по сериализации и десериализации:

Гонки - кроме аутсайдеров (общий зачёт)

Стало очевидно, что однозначных лидеров два: FST (unsafe) и One Nio.
Если на небольших объектах FST (unsafe) уверенно лидирует, то с ростом размера сериализуемых объектов он начинает уступать и, в конечном счёте, уступает One Nio.

Третью позицию с ростом размера сериализуемых объектов уверенно занимает BSON MongoDb, хотя отрыв от лидеров у него почти двукратный.


Размер результата сериализации — второй важнейший критерий оценки библиотек Java-сериализации. В каком-то плане, от размера результата зависит скорость сериализации/десериализации: компактный результат формировать и обрабатывать быстрее, чем объёмный. Для «взвешивания» результатов сериализации использовались всё те же Java-объекты, сформированные из реальных данных, взятых из логов системы (строк и байтовых массивов).

Кроме того, важным свойством результата сериализации является и то, на сколько он хорошо сжимается (например, для сохранения в БД или других хранилищах). В нашем соревновании мы использовали алгоритм сжатия Deflate, являющийся основой для ZIP и gzip.

Результаты «взвешивания» получились следующими:

Взвешивание

Ожидаемо, самыми компактными оказались результаты сериализации у одного из лидеров гонки: One Nio.
Второе место по компактности досталось BSON MongoDb (который занял третье место в гонке).
На третье место по компактности «вырвалась» библиотека Kryo, ранее не сумевшая проявить себя в гонке.

Результаты сериализации этих 3-х лидеров «взвешивания» ещё и отлично сжимаются (почти в двое). Самыми плохосжимаемыми оказались: бинарный эквивалент JSON-а — Smile и сам JSON.

Любопытный факт — все победители «взвешивания» при сериализации добавляют одинаковое количество служебных данных и к маленьким, и к большим сериализуемым объектам.


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

Гибкость

Сноски с пояснениями

1    Десериализуется LinkedHashMap.
2   Для объекта в целом — ДА, для поля объекта — НЕТ.
3   Для объекта в целом — НЕТ, для поля объекта — ДА.
4   С использованием sun.reflect.MagicAccessorImpl — ДА: boxing/unboxing, примитивы в BigInteger/BigDecimal/String. Без использования MagicAccessorImpl (доработанный в СберТех'е fork One Nio) — НЕТ.
5   Десериализуется ArrayList.
6   Десериализуется ArrayList или HashSet в зависимости от конкретного сериализованного типа.
7   Десериализуется HashMap.
8   Для объекта в целом и для поля объекта — НЕТ, но если отсутствует класс объекта, располагавшегося в коллекции/Map-е, то ДА (при этом десериализуется HashMap).
9   В оригинальной библиотеке One Nio — НЕТ, в доработанном в СберТех'е fork-е — ДА.
10При десериализации даже конструктор не вызывается.


Данный скрупулёзный «осмотр претендентов» был, пожалуй, самым трудоёмким этапом нашего «кастинга». Но зато эти результаты сравнения хорошо открывают глаза на удобство использования библиотек сериализации. В последствие можно использовать эти результаты как справочник.

Как ни обидно было осознавать, но наши лидеры по результатам гонок и взвешивания — FST (unsafe) и One Nio — оказались аутсайдерами по гибкости… Однако нас заинтересовал любопытный факт: One Nio в конфигурации «for persist» (не самая быстрая и не самая компактная) набрала больше всех баллов по гибкости — 18/20. Очень привлекательной выглядела возможность заставить дефолтную (быструю и компактную) конфигурацию One Nio работать также гибко — и способ нашёлся.

В самом начале, когда мы представляли участников соревнования, говорилось о том, что One Nio (for persist) включает в результат сериализации детальную мета-информацию о классе сериализуемого Java-объекта (*). Используя эту мета-информацию при десериализации, библиотека One Nio точно знает, как выглядел класс сериализуемого объекта на момент сериализации. Именно на основании этого знания алгоритм десериализации One Nio является таким гибким, что обеспечивает максимальную совместимость получающихся при сериализации byte[].

Оказалось, что мета-информацию (*) можно отдельно получить для указанного класса, сериализовать в byte[] и отправить на ту сторону, где будет происходить десериализация Java-объектов данного класса:

С кодом по шагам…
// Сервис №1: Получаем мета-информацию о классе SomeDto
one.nio.serial.Serializer dtoSerializerWithMeta = Repository.get( SomeDto.class );
byte[] dtoMeta = serializeByDefaultOneNioAlgorithm( dtoSerializerWithMeta );
// Сервис №1: Отправляем dtoMeta сервису №2

// Сервис №2: Восстанавливаем мета-информацию об удалённом классе SomeDto и сообщаем об этом библиотеке One Nio
one.nio.serial.Serializer dtoSerializerWithMeta = deserializeByOneNio( dtoMeta );
Repository.provideSerializer( dtoSerializerWithMeta );

// Сервис №1: Сериализуем объекты класса SomeDto
byte[] bytes1 = serializeByDefaultOneNioAlgorithm( object1 );
byte[] bytes2 = serializeByDefaultOneNioAlgorithm( object2 );
...
// Сервис №1: Отправляем байты сервису №2

// Сервис №2: Десериализуем байты в объекты класса SomeDto
SomeDto object1 = deserializeByOneNio( bytes1 );
SomeDto object2 = deserializeByOneNio( bytes2 );
...


Если произвести эту явную процедуру взаимообмена мета-информацией о классах между распределёнными сервисами, то такие сервисы смогут отправлять друг другу сериализованные Java-объекты, используя дефолтную (быструю и компактную) конфигурацию One Nio. Ведь, пока сервисы запущены, версии классов на их сторонах неизменны, а значит не зачем при каждом взаимодействии «таскать туда-сюда» константную мета-информацию внутри каждого результата сериализации. Таким образом, сделав немного больше действий в начале, затем можно использовать скорость и компактность One Nio одновременно с гибкостью One Nio (for persist). То что нужно!

В результате, для передачи Java-объектов между распределёнными сервисами в сериализованном виде (то, для чего мы и устроили данное соревнование) One Nio оказался победителем по гибкости (18/20).
Среди отличившихся ранее в гонках и взвешивании Java-сериализаторов не плохую гибкость продемонстрировали:

  • BSON MongoDb (14,5/20),
  • Kryo (13/20).


Вспомним результаты прошедших соревнований Java-сериализаторов:

  • в гонках первые две строчки рейтинга поделили FST (unsafe) и One Nio, а третье место занял BSON MongoDb,
  • на взвешивании победил One Nio, за которым шли BSON MongoDb и Kryo,
  • по гибкости, именно для нашей задачи обмена сессионным контекстом между распределёнными приложениями, первое место снова досталось One Nio, а также отличились BSON MongoDb и Kryo.


Таким образом, по совокупности достигнутых результатов пьедестал у нас получился следующим:

  1. One Nio
    В главном соревновании — гонках — делил первое место с FST (unsafe), но на взвешивании и при проверке гибкости существенно обошёл конкурента.
  2. FST (unsafe)
    Также очень быстрая библиотека Java-сериализации, однако ей не хватает прямой и обратной совместимости получающихся в результате сериализации байтовых массивов.
  3. BSON MongoDB + Kryo
    Эти 2 библиотеки поделили 3-ю строчку нашего рейтинга самых быстрых Java-сериализаторов, не требующих описания структуры данных. Обе библиотеки достаточно сильно отстали от 2-х лидеров по скорости, но при этом являются практически идентичными по компактности и гибкости. У обеих библиотек есть проблемы при сериализации Collection и Map, а у BSON MongoDB ещё и нет возможности custom-ного управления сериализацией/десериализацией (Externalizable и т.п.).


В Сбербанке в нашем сервисе сессионных данных мы использовали библиотеку One Nio, занявшую первое место в нашем соревновании. С помощью данной библиотеки сериализовывались данные сессионного Java-контекста и передавались между приложениями. Благодаря данной доработке скорость работы сессионного транспорта кратно ускорилась. Нагрузочное же тестирование показало, что на сценариях, приближенных к реальному поведению пользователей в Сбербанк Online, было получено ускорение до 40% только лишь за счёт одной этой доработки. Такой результат означает снижение времени отклика системы на действия пользователей, что увеличивает степень удовлетворённости наших клиентов.

В следующей статье я постараюсь продемонстрировать в действии дополнительное ускорение One Nio, получаемое за счёт использования класса sun.reflect.MagicAccessorImpl. К сожалению IBM JRE не поддерживает самых главных свойств этого класса, а, значит, весь потенциал One Nio на этой версии JRE ещё не раскрыт. Продолжение следует.

© Habrahabr.ru