Обычно при работе с такими библиотеками, как Google Protobuf, ты не задумываешься о том, что они могут сделать что-то не так, ты просто им доверяешь.

Мы использовали Google Protobuf на протяжении десятилетий, но когда мы начали использовать Golang с Google Protobuf в производственной среде, мы столкнулись с ужасными проблемами производительности, хотя делали все как обычно.

Создавая наше хранилище данных, мы изобрели nerve, нашу небольшую библиотеку Golang для эффективной работы с огромными объемами данных, представленных в виде очереди. В настоящее время мы можем читать около 650 000 сообщений в секунду на MacBook, используя nerve. Наш абсолютный рекорд скорости чтения на prod-сервере — 2,3 миллиона сообщений в секунду. Однако, когда мы попытались работать со всеми этими сообщениями, мы столкнулись с ужасным и резким падением скорости. Вместо обработки миллионов сообщений мы смогли обработать только 20 000, и основной причиной этой проблемы оказалась скорость разбора сообщений protobuf.

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

Итак, наша первая идея заключалась в том, чтобы просто попытаться парсить одновременно, но мы не могли получить более 500 тысяч сообщений в секунду, и этот процесс вызывал очень высокую загрузку ЦП. Есть две причины, почему это произошло. Первая причина — отражение; однако вторая причина была весьма неожиданной и заключалась во внутренних замках. Если сообщения имеют переменную структуру, это приводит к блокировке каждого нового сложного поля.

Именно в этот момент мы начали искать альтернативы, которые решат вышеуказанную проблему, а также решат следующую проблему с протоколом:

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

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

Забегая вперед, мы достигли скорости анализа 30 000 000 сообщений в секунду и скорости сериализации 87 000 000 сообщений в секунду. По сравнению с Google он примерно в 60 раз быстрее при чтении и в 20 раз быстрее при записи.

Как мы это сделали?

1. Разбор

Первый ключевой момент был довольно очевиден — исключить рефлексию и блокировки. Отсутствие отражения означает отсутствие накладных расходов и сложной логики.

Но можем ли мы сделать лучше?

Да мы можем. Мы можем сделать весь синтаксический анализ ленивым.

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

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

Как мы можем предотвратить это?

Мы можем избежать этой сложности, разделив синтаксический анализ и сериализацию.

В итоге для каждого типа сообщения protobuf мы создали две структуры:

  • Чтение сообщений
  • Сообщение

MessageReader используется для отложенного синтаксического анализа, тогда как Message используется только для сериализации.

В ридере нам нужно просто хранить исходные байты и смещения для всех полей, нам не нужно «парсить» все сообщение целиком.

Например, для следующего определения protobuf:

message Example {
  string name = 1;
}

использование читателя будет выглядеть так:

reader := proto.NewExampleReader();
if err := reader.Unmarshal(data); err != nil {
  // handle err
}
reader.GetName() // goes to the underlying offset and reads the name just once.

2. Сериализация

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

Изучая сортировку Google protobuf Golang (и gogofast), мы обнаружили, что существует много распределений. В нашем случае — 14 аллоков на операцию. Но на самом деле нам нужен только один. Мы всегда можем выделить массив байтов предсказуемого размера для всего сообщения, включая все дочерние элементы, и сериализовать все структуры всего в один []byte. Это еще один переломный момент: вместо 428 нс/оп у нас 156 нс/оп.

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

writer := &proto.Example{
  Name: "foo",
}
messageBytes := writer.Marshal()

вместо гугл протобуфа:

writer := &proto.Example{
  Name: "foo",
}
messageBytes, err := proto.Marshal(writer)
if err != nil {
  // ????
}

3. Снижение сложности

При работе с protobuf мы обнаружили тенденцию смешивать протокол связи с языком описания данных и бизнес-логикой.

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

Поэтому, чтобы сделать наше решение более простым и стабильным, мы просто опускаем:

  • все плагины и расширения protobuf
  • grpc
  • один из
  • все, что устарело, например «группы»
  • протокол

Полученные результаты

В результате мы не только разработали молниеносно быструю и простую библиотеку protobuf Golang с возможностью добавления других языков, но и заметно снизили загрузку ЦП на наших рабочих серверах, в некоторых случаях до 30%.

Как использовать

gremlin, а также nerve доступны как часть нашего фреймворка octopus.



Спасибо!
Александр Бацуев, Дмитрий Самсонов