Обычно при работе с такими библиотеками, как 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.
Спасибо!
Александр Бацуев, Дмитрий Самсонов