Темная сторона protobuf

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

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

Все последующее изложение касается только реализации protobuf на платформе Java. Также в основном описана версия 2.6.1, хотя в уже выпущенной версии 3.0.0 принципиальных изменений я также не увидел.

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

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

maven-проект с уже подключенными зависимостями для самостоятельного исследования можно взять на guthub.

0. Необходимость препроцессинга


Это наименьшая проблема, даже не хотел включать ее в перечень, но для полноты пусть будет упомянута. Для того чтобы получить java-код необходимо запустить компилятор protoc. Некоторая проблема есть в том, что этот компилятор представляет собой нативное приложение и на каждой из платформ исполняемый файл будет своим, поэтому обойтись простым подключением maven-плагина не получится. Как минимум нужна переменная окружения на машинах разработчиков и на CI-сервере, которая будет указывать на исполняемый файл, и после этого его уже можно запускать из maven/ant сценария.

Как вариант, можно сделать maven-pluging, который держит в ресурсах все бинарники и распаковывает из себя нужный под текущую платформу в временную папку, откуда и запускает его. Не знаю, может такой кто-то уже и сделал.

В общем, невелик грех, поэтому простим.

1. Непрактичный код


К сожалению, для платформы Java генератор protoc производит очень непрактичный код. Вместо того, чтобы сгенерировать чистенькие anemic-контейнеры и отдельно сериализаторы к ним, генератор упихивает все в один большой класс с подклассами. Генерируемые бины нельзя ни внедрить в свою иерархию, ни даже банально заимплементировать интерфейс java.util.Serializable для спихивания бинов на куда-нибудь сторону. В общем они годятся только в качестве узкоспециализированных DTO. Если вас это устраивает — то это и не проблема вовсе, только не заглядывайте внутрь.

2. Излишнее копирование — низкая производительность


Собственно вот тут у меня начались уже совершенно объективные проблемы. Генерируемый код для каждой описываемой сущности (назовем ее «Bean») создает два класса (и один интерфейс, но он не важен в данном контексте). Первый класс — это immutable Bean который представляет собой read-only слепок данных, второй класс — это mutable Bean.Builder, который уже можно править и устанавливать значения.

Зачем так сделано, осталось непонятным. Кто-то говорит, что авторы входят в секту адептов ФП; кто-то утверждает что так они пытались избавится от циклических зависимостей при сериализации (как это им помогло?); кто-то говорит, что protobuf первой версии работал только с mutable-классами, а глупые люди стреляли при этом себе в ноги.

Можно было бы сказать, что на вкус и цвет архитектуры разные, но при таком дизайне для того чтобы получить байтовое представление вам нужно создать Bean.Builder, заполнить его, затем вызвать метод build (). Для того чтобы изменить бин, нужно создать его билдер через метод toBuilder (), изменить значение и затем вызвать build ().

И все ничего, только при каждом вызове build () и toBuilder () происходит копирование всех полей из экземпляра одного класса в экземпляр другого класса. Если все что вам нужно — это получить байтовый массив для сериализации или изменить пару полей, то это копирование сильно мешает. Кроме того, в этом методе похоже (я сейчас выясняю) присутствует многолетняя проблема, которая приводит к тому, что копируются даже те поля, значения которых даже не были установлены в билдере.

Вы вряд ли заметите это, если у вас мелкие бины с небольшим количеством полей. Однако мне в наследство досталась целая библиотека, количество полей в отдельных бинах которой достигало трех сотен. Вызов метода build () для такого бина занимает около 50 мкс в моем случае, что позволяет обработать не более 20000 бинов в секунду.

Ирония в том, что в моем случае другие тесты показывают, что сохранение подобного бина через Jackson/JSON в два-три раза быстрее (в случае если проинициализированы не все поля и большую часть полей можно не сериализовать).

3. Потеря ссылочности


Если у вас есть графоподобная структура, в которой бины ссылаются друг на друга, то у меня для вас плохая новость — protobuf не подходит для сериализации таких структур. Он сохраняет бины по-значению, не отслеживая факт того, что этот бин уже был сериализован.

Другими словами если у вас есть bean1 и bean2, которые ссылаются друг на друга, то при сериализации-десериализации вы получите bean1, который ссылается на бин bean3;, а также bean2, который ссылается на бин bean4.

Уверен, что в подавляющем большинстве случаев такая функциональность не нужна и даже противопоказана в простых DTO. Однако эта проблема проявляется и в более естественных случаях. Например, если вы добавите один и тот же бин в коллекцию 100 раз, он будет сохранен все 100 раз, а не одиножды. Или вы сериализуете список лотов (товаров). Каждый из лотов представляет собой мелкий бин с описанием (количество, цена, дата), а также со ссылкой на развесистое описание продукта. Если сохранять в лоб, то описание продукта будет сериализовано столько раз, сколько существует лотов, даже если все лоты указывают на один и тот же продукт. Решением этой проблемы будет отдельное сохранение продуктов в виде словаря, но это уже дополнительные действия — и при сериализации, и при десереализации.

Описанное поведение является абсолютно ожидаемым и естественным для текстовых форматов типа JSON/XML. Но вот от бинарного формата ожидаешь несколько другого, тем более, что штатная сериализация Java в этом плане работает ровно так, как и ожидается.

4. Компактность под вопросом


Бытует мнение, что protobuf является суперкомпактным форматом. На самом деле компактность сериализации обеспечивается всего несколькими факторами:
  • Реализованы и используются по-умолчанию типы var-int и var-long — как знаковые, так и для беззнаковые. Поля таких типов позволяют сэкономить место, в случае если реальные значения в этих полях невелики. Иными словами, если распределение по всему диапазону значений неравномерно и основная масса значений сконцентрирована около нуля. Например, при сохранении значения 23L оно займет всего лишь один байт вместо восьми. Но с другой стороны, если вы сохраните Long.MAX_VALUE, то такое значение займет уже все десять байт.
  • Вместо полных метаданных (имен полей) сохраняются только числовые идентификаторы полей. Собственно ради этого мы и указываем идентификаторы в proto-файлах и именно поэтому они должны быть уникальными и неизменными. Идентификаторы сохраняются в полях типа var-int, поэтому есть смысл начинать их именно с 1.
  • Не сохраняются поля, для которых не было установки значений через сеттеры. Для этого protobuf при установке значений через сеттеры также устанавливает в отдельной битовой маске соответствующий полю бит. Тут не обошлось без проблем, поскольку при установке значения 0L такой бит все равно взводится, хотя очевидно, что сохранять такое поле нет необходимости, поскольку в большинстве языков 0 — это значение по-умолчанию. Например, Jackson при сериализации, когда решает сериализовать это поле или нет, смотрит на непосредственное значение поля.

И все это замечательно, но вот только если мы посмотрим на байтовое представление DTO среднего (но за всех говорить не буду) современного сервиса, то увидим, что большую часть места будут занимать строки, а не примитивы. Это логины, имена, названия, описания, комментарии, URI ресурсов, причем часто в нескольких вариантах (разрешениях для картинок). Что делает protobuf со строками? В целом ничего особого — просто сохраняет их в поток в виде UTF-8. При этом помним, что национальные символы в UTF-8 занимают по два, а то и по три байта.

Предположим, приложение генерирует такие данные, что в процентном соотношении в байтовом представлении строки занимают 75%, а примитивы занимают 25%. В таком случае, даже если наш алгоритм оптимизации примитивов сократит необходимое для их хранения место до нуля, мы получим экономию всего в ¼.

В некоторых случаях компактность сериализация является весьма критичной, например для мобильных приложений в условиях плохой/дорогой связи. В таких случаях без дополнительной компрессии поверх protobuf не обойтись, иначе мы будем впустую гонять избыточные данные в строках. Но тогда вдруг выясняется, что аналогичный комплект [JSON+GZIP] при сериализации дает несильно больший размер по сравнению с [PROTOBUF+ZIP]. Конечно, вариант [JSON+GZIP] будет также потреблять больше ресурсов CPU при работе, но в тоже время, он зачастую также является еще и более удобным.

protoc v3


В protobuf третьей версии появился новый режим генерации «Java Nano». Его еще нет в документации, а runtime этого режима еще в стадии alpha, но пользоваться им можно уже сейчас при помощи переключателя »--javanano_out».

В этом режиме генератор создает анемичные бины с публичными полями (без сеттеров и без геттеров) и с простыми методами сериализации. Лишнего копирования нет, поэтому проблема #2 решена. Остальные проблемы остались, более того при наличии циклических ссылок сериализатор выпадает в StackOverflowError.

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

protostuff


Альтернативная реализация протокола protobuf. В бою не испытывал, но на первый взгляд выглядит очень добротно. Не требует proto-файлов (однако умеет с ними работать, если это необходимо), поэтому решены проблемы #0, #1 и #2. Кроме этого умеет сохранять в свой собственный формат, а также в JSON, XML и YAML. Также интересной является возможность перегонять данные из одного формата в другой потоком, без необходимости полной десериализации в промежуточный бин.

К сожалению, если отдать на сериализацию обычный POJO без схемы, аннотаций и без proto-файлов (так тоже можно), protostuff будет сохранять все поля объекта подряд, в независимости от того были они проинициализированы значением или нет, а это снова сильно бьет по компактности в случае, когда заполнены не все поля. Но насколько я вижу, такое поведение при желании можно подправить, переопределив пару классов.

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

  • 14 сентября 2016 в 23:50

    –10

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


    Не хотиnе ли попробовать реализовать сериализатор в формат tree? Он с одной стороны читаем человеком, с другой — весьма гибок, а с третьей позволяет передавать бинарные данные без экранирования (только сплит по сепаратору). В сериализованном виде может получиться что-то типа такого:


    schema Bean Bean id int64
    
    schema InnerBean Bean
        value1 int64
        value2 int64
        value3 int64
        value4 int64
        value5 int64
        value6 InnerBean id
    
    schema OuterBean Bean
        descr string
        bean1 InnerBean id    
        bean2 InnerBean id    
        bean3 InnerBean id    
    
    InnerBean
        id \1
        value1 \3lmt8r
        value2 \i23dbi
        value3 \eui2f3b
        value4 \ewviubi4
        value5 \342
        value4 \2
    
    InnerBean
        id \2
        value1 \3lmt8r
        value2 \i23dbi
        value3 \eui2f3b
        value4 \ewviubi4
        value5 \7vvvvvvvvvv
        value4 \2
    
    InnerBean
        id \3
        value1 \3lmt8r
        value2 \i23dbi
        value3 \eui2f3b
        value4 \ewviubi4
        value5 \342
        value4 \1
    
    OuterBean
        id \4
        descr
            \this is multiline description
            \of bean #4
        bean1 \1
        bean2 \2
        bean2 \3

    Разбирается это тривиальным парсером. Избыточность нивелируется простейшим алгоритмом сжатия. Числа я тут записал в base32, но можно и сырыми данными.

    • 15 сентября 2016 в 08:14

      +1

      В данный момент мне нужно решить проблемы с производительностью в том, что уже есть и работает. Насчет tree — честно говоря я конформист в техническом плане и предпочел бы более распространенные форматы.
    • 15 сентября 2016 в 08:35

      0

      При чем здесь Веб? Есть много других областей где применяется передача данных по сети.
      • 15 сентября 2016 в 08:49

        –2

        При чём здесь передача данных по сети? Есть много других областей, где применяется сериализация объектов.


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

        • 15 сентября 2016 в 08:53

          +1

          Часто встречаю в своей работе что не нужен гибкий протокол и ему не нужна куча различных применений и распространенности и такие протоколы используем в С++ коде. А как можно говорить о стабильности у универсального протокола типа JSON? Что даёт ему +10 к стабильности или скорости работы?
          • 15 сентября 2016 в 09:40

            –2

            Часто встречаю в своей работе что не нужен гибкий протокол и ему не нужна куча различных применений и распространенности и такие протоколы используем в С++ коде.

            То, что гибкость в конкретный момент не востребована, не означает, что она не потребуется в будущем (пример из статьи — внезапно выясняется, что из 100 возможных полей заполнены лишь 10 и требуется «адаптивная схема») и уж тем более не означает, что нужно намеренно выбирать наиболее дубовый протокол имеющий лишь один вариант применения.


            А как можно говорить о стабильности у универсального протокола типа JSON?

            Tree — это формат представления AST. Так что он ближе к LISP и XML, чем к JSON. Впрочем, гибкость протокола не исключает использования схем. В примере выше, схема данных прилагается к самим данным.


            Что даёт ему +10 к стабильности или скорости работы?

            Предельная простота, поддержка потоковой обработки и отсутствие необходимости декодировать строки, например.

            • 15 сентября 2016 в 09:46

              0

              «дубовый протокол» — быстр, чрезвычайно прост, занимает минимум места, не принуждает копировать/декодировать строки, применяется только там где он необходим.
              • 15 сентября 2016 в 10:45

                0

                Было бы странно, если бы бинарный протокол не обладал этими характеристиками. Однако, вы забыли упомянуть:


                1. Требует кодогенерации.
                2. Ограниченный набор поддерживаемых языков.
                3. Ограниченная система типов.
                4. Ограниченные возможности отладки.
  • 15 сентября 2016 в 00:05

    +1

    0. https://www.xolstice.org/protobuf-maven-plugin/ уже давно все есть

    1. начиная с protobuf v3 одной командой в протофайле option java_multiple_files = true; отключаем генерацию большого файла, получаем набор class=>file. Править полученные протофайлы вам не запрещается, при большом желании можно расширить компилятор и он будет генерить то что вам нужно и прописывать интерфейсы какие хотите

    2. как вы предлагаете изменять byte[] в котором по определенным смещениям лежат значения изменяемой длинны? (ваш же пункт 4, чтобы что-то поменять нам нужно будет хвостик сдвинуть влево-вправо и изменить размер самого массива) Если объект в системе вроде как и готов, но возможно еще будет меняться, то перекидывается ссылка на билдер, а вот если нужно отправить в wire клиенту, то тут уже и build () вызывается и получаем готовый слепок.

    3. сами же и сказали решение проблемы, dto не должно думать как упаковать граф, а потом его распаковывать, или может JSON уже научился такое делать? с учетом того что proto v3 еще ближе приблизился к json, то упаковка графа в руках того кто упаковывает, а совсем не стандартная операция.

    4. [PROTOBUF+ZIP] vs [JSON+GZIP] это спор на уровне: у нас есть SQL и у нас есть NoSQL, в одном схема прописана и если говно пришло то оно сразу отбросится, во втором мы что-то как-то запишем-прочитаем. И далеко не у всех текста гоняются, зачастую только ID нужных элементов. К тому же сами признали увеличенную нагрузку на CPU, что для мобильных приложений очень критично. Хотите еще быстрее, с меньшей нагрузкой на CPU и без схемы, то добро пожаловать в MessagePack, только потом не жалуйтесь, что клиенты прислали очередную кашу.

    В общем у вас пожелания: я хочу бинарный формат, который работает быстро, является компактным, сохраняет и проверяет схему, сохраняет ссылочность, позволяет без копирования изменять поля прямо в byte[] и т.д.

    Лично я не знаю таких форматов и вижу противоречия в требованиях: компактный vs изменения сразу упакованного массива, компактный-быстрый vs сохраняем целиком ссылки-граф.

    • 15 сентября 2016 в 08:03

      0

      0. Спасибо. Но я вижу что ему надо указывать путь к protoc, то сам он его не содержит, поэтому лично мне проще protoc запустить встроенными средстваи ant/maven

      1. Да, я про версию 3 немного написал. К сожалению править сгенерированные файлы конечно нельзя — максимум положить рядом diff.patch и применять его после генерации.

      2. Нене. Проблема излишнего копирования в текущей реализации заключается в копировании данных между Bean.Builder и Bean. Такое копирование можно исключить, так как это сделал protostuff и javanano@protobuf.v3. А что еще более сильной оптимизации, там есть варианты, поскольку формат не очень сложный. Например наши инженеры написали конвертор byte[] → byte[], который позволяет поменять список значений полей без необходимости полной десериализации.

      В целом меня все устраивает. За исключением проблемы #2, которую сейчас пофиксим пул-реквестом. Цель статьи — напомнить, что серебрянной пули не существует :)

      • 15 сентября 2016 в 08:14

        0

        0. да, можно указать, но он по умолчанию из окружения вытягивается, а в окружение почти все знакомые ставят apt-get/yum/port install protobuf-java

        Я пока не видел людей которые считают protobuf серебрянной пулей =) чаще встречаю которые считают, что json это верх совершенства и пихают его везде, а потом ловят несогласованность форматов в рантайме. В свое время лично для меня proto решил много проблем, так как позволяет высунуть контракт протокола на сервисы и гарантировать его соблюдения.

        Со строками чаще проблемы не в том что копируются туда-сюда, а в самом кодировании-декодировании в UTF8, так как в java локальное представление Unicode, вот тут CPU и проседает частенько =(

  • 15 сентября 2016 в 01:20

    –4

    >«В среде разработчиков часто бытует мнение, что протокол сериализации protobuf и его реализация — это особая, выдающаяся технология, способная решить все реальные и потенциальные проблемы»

    Ни один нормальный разработчик никогда полностью не доверяет какой-то технологии и всегда относится к ней с сомнением.
    Если это не так — присмотритесь к такому разработчику: не пора ли его уволить

  • 15 сентября 2016 в 03:05

    0

    Формат/протокол сам по себе мало о чем говорит, все чаще решает конкретная реализация и распространенность. Все писали, что на андроиде не надо использовать встроенную джавовскую сериализацию, строго говоря не так уж и медленно она работала, однако попалась либа https://github.com/RuedigerMoeller/fast-serialization, которая для меня упростила жизнь многократно. Долгое время на флеше использовал AMF, прекрасный протокол: сжатие, упаковка, циклические ссылки, бинарный, потом на одном проекте надо было json, флешовый нативный json парсил строки мгновенно, меньше миллисекунды, надо было только потом замапить на модель, чуть мелденнее AMF, но не критично. А потом soap на мобильном устройстве и в принципе ничего страшного, немного оптимизаций :) Один раз попробовали заменить RMI отправкой данных (фактически байт массивов) руками по сокету, и никакого выигрыша не удалось достичь сходу, не все так просто. CORBA ну тот же ваш протобуф :) ничем не запомнился, кроме мучительного его изучения. Снова андроид — AIDL… А можно, между прочим, и парсеры XML повыбирать и подходы разные попробовать.

  • 15 сентября 2016 в 08:03

    +2

    Главное преимущество protobuf что вы пишите один код для всех языков программирования. Если вам легче написать json parser на пяти языках для пяти различных бэкенд то пожалуйста. Для меня мое время важнее. Также не стоит рассматривать protobuf messages как бины — это прежде всего обертка для передаваемых данных между серверами. Ну и конечно большим плюсом protobuf — недавний релиз grpc.
    • 15 сентября 2016 в 08:08

      –1

      Я вот не знаю ни одного языка/платформы для которой не существовал бы готовый парсер JSON, поэтому максимум, что нужно сделать — это нарисовать мэппинг между полями бина и полями JSON, и то как правило, это требуется не всегда.

      Но идея, конечно, верная. Это не сущности, а чистые DTO, только вот иногда хочется эти данные быстро перегнать в другой формат и с текущей реализацией это требует дополнительных усилий. Ну или сразу использовать protostuff, который умеет все из коробки.

      • 15 сентября 2016 в 10:33

        0

        Простите, я другое имел в виду. Вам для каждой платформы нужно создать свой класс обертку и настроить умный маппинг, который не будет фейлиться когда в один бэкенд разработчик добавляет новое поле. А это все возможности появления новых багов, а новые баги это время разработчика + большие деньги.
  • 15 сентября 2016 в 08:58

    –3

    Спасибо. Теперь знаю что не стоит тратить на него время.
    • 15 сентября 2016 в 09:13

      0

      Я такого не говорил :) Вообще это очень неплохая технология. Просто есть отдельные проблемы, плюс не надо ее сильно переоценивать.
      • 15 сентября 2016 в 09:35

        0

        Ну проблема с циклическими ссылками и, насколько я понял, дополнительная сложность в разработке (т.е. нельзя просто настаивть аннотации и сериализовать бин) нивелируют для меня все возможные преимущества.
        • 15 сентября 2016 в 10:31

          0

          Аннотированные POJO в формат protobuf умеет сериализовать библиотека protostuff. Со ссылочностью в protobuf, да, все непросто — как минимум придется все запихивать в отдельные словари, а ссылочность обеспечивать через идентификаторы. Или городить даже что-то более серьезное.
  • 15 сентября 2016 в 10:20

    +1

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

    Нет идеальных вещей, нужно уметь вещи использовать и адаптировать под себя.

© Habrahabr.ru