FlutBuffers в 2024 году: сможем ли мы воссоздать старый успех? Оптимизация производительности занимает центральное место!
22 января 2024 г.Привет!
О чем эта статья?
В 2021 году я работал над проектом, где практически на всё не было лишних средств — не было ресурсов от AWS, а мобильные телефоны, для которых мы разрабатывали ПО, были очень простыми, выполнявшими только необходимые функции и предоставлявшими доступ в Интернет. . Иногда это были планшеты от нашего поставщика, которые тоже не были флагманскими устройствами и стабильно отставали.
В этой статье я расскажу, как мы существенно оптимизировали скорость наших сервисов, а позже в 2024 году напишем новые версии и протестируем их!
Приятного чтения!
Начнем.
Клиент обратился к нам и предложил трехмесячный график запуска MVP для тестирования реальными пользователями. Нашей задачей было разработать относительно простой бэкенд для мобильного приложения. Клиент с самого начала предоставил подробные требования, спецификации и модули интеграции. Основной целью было собрать данные из мобильного приложения, просмотреть их и отправить в указанные интеграции. По сути, наша роль заключалась в том, чтобы действовать как проверяющий прокси-сервис, который также записывал события.
С какой проблемой мы обычно сталкиваемся? Это либо быстрый микросервис, либо набор сервисов, которые будут перехватывать запросы от приложения. Чаще всего наши клиенты пользуются первоклассным оборудованием и флагманскими устройствами.
А что, если наш случай:
- Слабый кластер AWS, которому необходимо вместить более 10 логических сервисов плюс мониторинг.
- Наши телефоны — это специальные Android-гаджеты с оперативной памятью не более 4 ГБ, чаще всего планшеты.
- Мы часто делаем снимки из приложения на серверную часть.
- Нам необходимо проверить часть данных, прежде чем продвигать их дальше по бизнес-потоку. ол>
- REST (JSON)
- gRPC (прото, двоичный)
- «Специальный гость» (также в двоичном формате) ол>
- Сохраните документы и подтвердите код отдела, компанию доставки и адрес.
- Найти все с разбивкой по страницам с ограничением/смещением. ол>
BenchmarkCreateAndMarshal-10
: это строка вывода, предоставляемая инструментом тестирования Go.168706
: это количество итераций, выполненных во время теста.7045 нс/оп
: это среднее время, необходимое для одной итерации в наносекундах. Здесьns/op
означает наносекунды на операцию.- 08 представляет тег 1 (поле
name
), за которым следует длина поля. - 4A 6F 68 6E 20 44 6F 65 представляет коды ASCII для строки «Джон Доу».
- 10 представляет тег 2 (поле
id
), за которым следует значение123
в кодировке переменной длины (Varint). - 1A представляет тег 3 (поле
email
), за которым следует длина строки20
и коды ASCII для строки «john@example.com». ли> - Эффективность: двоичный формат обеспечивает более компактное представление данных, уменьшая объем передаваемой информации по сети.
- Скорость: операции сериализации и десериализации выполняются быстрее, поскольку двоичные данные могут обрабатываться эффективно.
- Если вам нужно быстро сериализовать данные, используйте FlatBuffers.
- Если у вас много сервисов и вам необходимо передавать запросы между ними, используйте gRPC — ничто не сравнится с его скоростью.
- Если вам просто нужен переводчик JSON с телефона в базу данных, выберите REST + JSON.
- Если вам нужно сэкономить память на устройстве и вы можете немного подождать обработки на сервере, используйте FlatBuffers. ол>
КАК ЕСТЬ
Итак, вот в чем дело — слабый бэкенд, слабые устройства, 1 MVP и 3 разработчика. Миссия — расширяться как можно меньше, не тратя лишних денег на AWS, пока мы находимся в зоне MVP.
Если MVP справится с задачей, ресурсы потекут.
В противном случае проект может быть приостановлен.
Звучит как вызов?
Мы засучили рукава и начали экспериментировать. Что есть на рынке и что мы делаем с услугами:
Еще раз о спецификации: в качестве хорошего примера наш документ должен выглядеть так:
{
"docs": {
"name": "name_for_documents",
"department": {
"code": "uuid_code",
"time": 123123123,
"employee": {
"name": "Ivan",
"surname": "Polich",
"code": "uuidv4"
}
},
"price": {
"categoryA": "1.0",
"categoryB": "2.0",
"categoryC": "3.0"
},
"owner": {
"uuid": "uuid",
"secret": "dsfdwr32fd0fdspsod"
},
"data": {
"transaction": {
"type": "CODE",
"uuid": "df23erd0sfods0fw",
"pointCode": "01"
}
},
"delivery": {
"company": "TTC",
"address": {
"code": "01",
"country": "uk",
"street": "Main avenue",
"apartment": "1A"
}
},
"goods": [
{
"name": "toaster v12",
"amount": 15,
"code": "12312reds12313e1"
}
]
}
}
Например, у нас есть компактный сервис всего с двумя методами:
Этап 1. REST-сервис
Ничего особенного, мы собираемся создать небольшой сервис с Gin gonic и http lib. Хороший пример: «Golang RESTful API»: ==нажмите здесь==
Давайте напишем что-нибудь вот так. Полный код здесь: ==Github json==
const (
post = "/report"
get = "/reports"
TTL = 5
)
func main() {
router := gin.Default()
p := ginprometheus.NewPrometheus("gin")
p.Use(router)
sv := service.NewReportService()
gw := middle.NewHttpGateway(*sv)
router.POST(post, gw.Save)
router.GET(get, gw.Find)
srv := &http.Server{
Addr: "localhost:8080",
Handler: router,
}
}
Этот код представляет собой тест для функции BenchmarkCreateAndMarshal, измеряющий производительность операций создания и маршалинга.
// BenchmarkCreateAndMarshal-10 168706 7045 ns/op
func BenchmarkCreateAndMarshal(b *testing.B) {
for i := 0; i < b.N; i++ {
doc := createDoc()
_ = doc.Docs.Name // for tests
bt, err := json.Marshal(doc)
if err != nil {
log.Fatal("parse error")
}
parsedDoc := new(m.Document)
if json.Unmarshal(bt, parsedDoc) != nil {
log.Fatal("parse error")
}
_ = parsedDoc.Docs.Name
}
}
Таким образом, результат показывает, что функция BenchmarkCreateAndMarshal
выполняется примерно за 7045 наносекунд на операцию за 168706 итераций.
Отсюда мы начали свой путь, а сейчас рассматриваем первую ключевую точку на нашем пути. Хватило ли этого для запуска? Ответ – да! Но как долго? Ответ — нет.
Мы успешно завершили первую часть и запустили MVP на тестирование, в том числе с синтетическими нагрузками. Каков был результат? У нас закончилась память; при передаче пакета отчетов наше окружение покраснело от нагрузки, и мы работали, но не так быстро, как могли.
Отсюда открывается новая ветка нашего исследования. Зачем добавлять память, если мы можем использовать некоторые процессы более эффективно? Да, мы говорим о сериализации, и начинается вторая глава, значительно ускоряющая нашу обработку.
Этап 2. Переход на gRPC
Иногда: не беспокойтесь, если вы новичок в gRPC! Делать это шаг за шагом — отличный подход. Я помню, что был в одной лодке: копирование и вставка документации — обычная практика при погружении в новые технологии. Это фантастический способ понять концепции и понять, как все работает. Продолжайте изучать руководство и не стесняйтесь обращаться к нему, если у вас возникнут вопросы. Приятного кодирования! 🚀 Подробнее здесь: https://protobuf.dev/overview/
Итак, gRPC:
gRPC обеспечивает более эффективную и компактную двоичную связь по сравнению с текстовой природой HTTP.
* Тип: ориентирован на передачу двоичных данных и структурированных сообщений. * Протокол: поддерживает состояние и дуплексную связь. * Формат данных: Протокольные буферы (protobuf) — формат сериализации двоичных данных. * Транспорт: в качестве транспортного протокола используется HTTP/2.
При быстрой разработке это крайне важно, потому что приходится думать об обратной совместимости и тому подобном. Если вы просто меняете все на лету, никуда не сохраняя информацию — это путь к катастрофе. В конечном итоге вы будете гоняться за ошибками, связанными с обратной совместимостью! Кроме того, вам необходимо четко определить версии.
В простом мире REST мы используем инструменты Swagger или OAS3, такие как Apicurio и тому подобные — это экономит время и делает процесс более прозрачным, но все равно требует времени. И знаете что, давайте еще раз взглянем на Protobuf — он уже поставляется со схемой, и есть его версия (если мы храним ее в Git) — огромный плюс. Поделитесь им с командой, и теперь эта спецификация будет у всех.
Хорошо, как это работает?
Ну, простой пример, напишем файл example.proto:
syntax = "proto3";
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
}
И сгенерируйте его:
protoc - python_out=. example.proto
При этом будет создан файл `example_pb2.py`, который содержит сгенерированный код для работы с данными, определенными в `example.proto`. Использование в Python:
import example_pb2
# Create a Person object
person = example_pb2.Person()
person.name = "John"
person.id = 123
person.email = "john@example.com"
# Serialize to binary format
serialized_data = person.SerializeToString()
# Deserialize from binary format
new_person = example_pb2.Person()
new_person.ParseFromString(serialized_data)
Здесь мы создаем объект Person, устанавливаем его поля, сериализуем его в двоичный формат, а затем десериализуем обратно. Примечание. `example_pb2` — это сгенерированный модуль, созданный компилятором protobuf. Буферы протокола обеспечивают формат двоичных данных, который является компактным и эффективным для передачи. Он также поддерживает различные языки программирования, что делает его удобным для использования в различных частях вашего технологического стека.
Все еще не знаете, как это работает? Давайте воспользуемся предыдущим примером Person. Представьте, что у нас есть объект Person с заполненными полями:
Person person = {
name: "John Doe",
id: 123,
email: "john@example.com"
};
Когда этот объект сериализуется в двоичный формат, каждое поле будет представлено как тегированный элемент. В данном случае тегами являются числа 1, 2 и 3. После сериализации поток двоичных данных может выглядеть примерно так (в упрощенной форме):
08 4A 6F 68 6E 20 44 6F 65 10 7B 1A 14 6A 6F 68 6E 40 65 78 61 6D 70 6C 65 2E 63 6F 6D
Давайте разберемся:
Таким образом, теги и их порядок позволяют анализировать сериализованный поток данных и определять, какое поле какую информацию содержит. Преимущества использования двоичного формата и тегов:
Пришло время создать наш прототип сервиса со спецификацией:
syntax = "proto3";
package docs;
option go_package = "proto-docs-service/docs";
service DocumentService {
rpc GetAllByLimitAndOffset(GetAllByLimitAndOffsetRequest) returns (GetAllByLimitAndOffsetResponse) {}
rpc Save(SaveRequest) returns (SaveResponse) {}
}
message GetAllByLimitAndOffsetRequest {
int32 limit = 1;
int32 offset = 2;
}
message GetAllByLimitAndOffsetResponse {
repeated Document documents = 1;
}
message SaveRequest {
Document document = 1;
}
message SaveResponse {
string message = 1;
}
message Document {
string name = 1;
Department department = 2;
Price price = 3;
Owner owner = 4;
Data data = 5;
Delivery delivery = 6;
repeated Goods goods = 7;
}
message Department {
string code = 1;
int64 time = 2;
Employee employee = 3;
}
message Employee {
string name = 1;
string surname = 2;
string code = 3;
}
message Price {
string categoryA = 1;
string categoryB = 2;
string categoryC = 3;
}
message Owner {
string uuid = 1;
string secret = 2;
}
message Data {
Transaction transaction = 1;
}
message Transaction {
string type = 1;
string uuid = 2;
string pointCode = 3;
}
message Delivery {
string company = 1;
Address address = 2;
}
message Address {
string code = 1;
string country = 2;
string street = 3;
string apartment = 4;
}
message Goods {
string name = 1;
int32 amount = 2;
string code = 3;
}
После этого нам нужно собрать его с помощью простого скрипта:
# if it is your first downloading:
brew install protobuf
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2
export PATH="$PATH:$(go env GOPATH)/bin"
# only generator
cd .. && cd grpc
mkdir "docs"
protoc --go_out=./docs --go_opt=paths=source_relative
--go-grpc_out=./docs --go-grpc_opt=paths=source_relative docs.proto
И в результате у нас должна получиться папка с файлами.
Как вы проводите локальное тестирование? Я предпочитаю использовать BloomRpc (к сожалению, он устарел :D; Postman может делать то же самое). На этот раз я пропущу детали реализации сервера и логику обработки документов. Однако еще раз напишем тест. Естественно, мы ожидаем беспрецедентного роста!
// BenchmarkCreateAndMarshal-10 651063 1827 ns/op
func BenchmarkCreateAndMarshal(b *testing.B) {
for i := 0; i < b.N; i++ {
doc := CreateDoc()
_ = doc.GetName()
r, e := proto.Marshal(&doc)
if e != nil {
log.Fatal("problem with marshal")
}
nd := new(docs.Document)
if proto.Unmarshal(r, nd) != nil {
log.Fatal("problem with unmarshal")
}
_ = nd.GetName()
}
}
Этот код представляет собой тест под названием BenchmarkCreateAndMarshal
, который измеряет производительность операций создания и маршалинга. Результаты показывают, что в среднем тест выполняет эти операции за 1827 наносекунд на итерацию из 651063 итераций. Итак, полный код здесь: ==нажмите, пожалуйста==
Это может показаться успехом, и мы могли бы остановиться, но почему? Кажется, мы еще можем выжать из сервиса больше производительности и добиться лучших результатов, но как? И вот последняя глава истории...
Этап 3. Переход на плоские буферы.
А теперь давайте представим нашего гостя среди протоколов — большинство из вас, вероятно, даже не слышали о нем — это FlatBuffers.
FlatBuffers выходит на сцену с другой развязностью по сравнению с другими. Представьте себе: на стороне клиента не требуется никакого синтаксического анализа. Почему? Поскольку доступ к данным осуществляется напрямую, распаковка не требуется. Это делает его очень эффективным для мобильных устройств и сред с ограниченными ресурсами. Это все равно, что передать готовую еду вместо того, чтобы заставлять клиента ее готовить. Схема минималистична, и вы сразу получаете плоский двоичный файл. Кроме того, вам не придется беспокоиться о версиях, поскольку вы можете добавлять новые поля, ничего не нарушая — это победа в игре с обратной совместимостью. Пример:
Person person;
person.id = 123;
person.name = "John Doe";
person.age = 30;
Конечно, давайте представим сериализованные байты в шестнадцатеричном формате для данной структуры Person
:
// Serialized bytes (hexadecimal representation)
// (assuming little-endian byte order)
1B 00 00 00 // Data size (including this byte)
7B 00 00 00 // ID (123 in little-endian byte order)
09 00 00 00 // Name string length (including null-terminator)
4A 6F 68 6E // Name ("John" in ASCII, including null-terminator)
20 00 00 00 // Age (30 in little-endian byte order)
В этом примере:
* Первые 4 байта представляют размер данных, включая этот байт. В данном случае размер составляет 27 байт (0x1B).
* Следующие 4 байта представляют собой id
(123 в порядке байтов с прямым порядком байтов).
* После этого 4 байта представляют длину строки имени (9 байт).
* Последующие 9 байтов представляют строку имени «Джон Доу», включая нулевой терминатор.
* Последние 4 байта представляют возраст
(30 в порядке байтов с прямым порядком байтов).
Обратите внимание, что это всего лишь иллюстрация структуры данных в двоичной форме, а конкретные значения могут различаться в зависимости от платформы, порядка байтов и других факторов.
На этот раз мы не сможем просто так пройти, потому что нам придется писать весь код сериализации самостоятельно. Однако мы ожидаем от этого и положительных моментов.
Конечно, нам придется испачкать руки кодированием сериализации вручную, но потенциальная выгода того стоит. Это своего рода компромисс — больше усилий на начальном этапе, но контроль и потенциальное повышение производительности могут сделать это выгодной сделкой в долгосрочной перспективе. В конце концов, иногда нужно глубоко погрузиться в код, чтобы творить чудеса, верно?
Пример кода здесь: GitHub
// BenchmarkCreateAndMarshalBuilderPool-10 1681384 711.2 ns/op
func BenchmarkCreateAndMarshalBuilderPool(b *testing.B) {
builderPool := builder.NewBuilderPool(100)
for i := 0; i < b.N; i++ {
currentBuilder := builderPool.Get()
buf := BuildDocs(currentBuilder)
doc := sample.GetRootAsDocument(buf, 0)
_ = doc.Name()
sb := doc.Table().Bytes
cd := sample.GetRootAsDocument(sb, 0)
_ = cd.Name()
builderPool.Put(currentBuilder)
}
}
Поскольку мы находимся в режиме «оптимизации своими руками», я решил собрать небольшой пул сборщиков, который очищаю после использования. Таким образом, мы можем перерабатывать их, не выделяя память снова и снова.
Это немного похоже на набор инструментов, который мы убираем после каждого использования — он сохраняет порядок и эффективность. Зачем тратить ресурсы на создание новых строителей, если мы можем перепрофилировать уже имеющиеся, верно? Все дело в эффективности работы своими руками.
const builderInitSize = 1024
// Pool - pool with builders.
type Pool struct {
mu sync.Mutex
pool chan *flatbuffers.Builder
maxCap int
}
// NewBuilderPool - create new pool with max capacity (maxCap)
func NewBuilderPool(maxCap int) *Pool {
return &Pool{
pool: make(chan *flatbuffers.Builder, maxCap),
maxCap: maxCap,
}
}
// Get - return builder or create new if it is empty
func (p *Pool) Get() *flatbuffers.Builder {
p.mu.Lock()
defer p.mu.Unlock()
select {
case builder := <-p.pool:
return builder
default:
return flatbuffers.NewBuilder(builderInitSize)
}
}
// Put return builder to the pool
func (p *Pool) Put(builder *flatbuffers.Builder) {
p.mu.Lock()
defer p.mu.Unlock()
builder.Reset()
select {
case p.pool <- builder:
// return to the pool
default:
// ignore
}
}
Пришло время проверить результаты
Теперь давайте углубимся в результаты наших тестов, и вот что мы видим:
| протокол | итерации | скорость | |----|----|----| | json | 168706 | 7045 нс/оп | | прото | 651063 | 1827 нс/оп | | квартира | 1681384 | 711,2 нс/оп |
Ну-ну-ну — похоже, Флэт здесь является демоном скорости, оставляя остальных в пыли с коэффициентом T. Цифры не лгут, и похоже, что наша самодельная оптимизация окупается с лихвой!
Что ж, тестирование — это хорошо, но давайте попробуем написать сервисы по этим протоколам и посмотрим, какие результаты мы получим!
Технические требования:
Язык: Golang, http framework: Gin gonic, база данных: mognodb
Теперь пришло время применить наши протоколы к настоящему тестируем — раскрутим сервисы, подключим их к метрикам Prometheus, добавим подключения к MongoDB и вообще сделаем из них полноценные сервисы. Мы можем пока пропустить тесты, но это не приоритет.
В классической настройке, как упоминалось ранее, у нас будет два метода — сохранить и найти по пределу и смещению. Мы реализуем их для всех трех реализаций и проведем стресс-тестирование всего процесса с помощью Яндекс Танк + Пандора.
Для простоты графика я использую сервис Яндекса Overload и оставляю ссылки на наши тесты. Перейдем к делу!
Метод сохранения, 1000 об/с, 60 сек, профиль:
rps: { duration: 60s, type: const, ops: 1000 }
Результаты:
| JSON | первый | второй | |----|----|----| | 99% | 1.630 | 1.260 | | 98% | 1.160 | 1.070 | | 95% | 1 | 0,920 |
Ссылки: первый тест и секунду.
| ПРОТО | первый | второй | |----|----|----| | 99% | 1.800 | 2.040 | | 98% | 1.380 | 1.540 | | 95% | 1.160 | 1,220 |
Ссылки: первый тест и секунду.
| КВАРТИРА | первый | второй | |----|----|----| | 99% | 3.220 | 3.010 | | 98% | 2.420 | 2.490 | | 95% | 1.850 | 1,840 |
Ссылки: первый тест и секунда
А теперь давайте добавим еще один метод, который охватывает тот самый случай, о котором я упоминал вначале, — нам нужно быстро извлечь поле из запроса и проверить его. Если есть какие-либо проблемы, мы отклоняем запрос; если все хорошо, продолжим.
Метод проверки, 1000 об/с, 60 секунд, тот же профиль:
rps: { duration: 60s, type: const, ops: 1000 }
| JSON | первый | второй | |----|----|----| | 99% | 1.810 | 1,980 | | 98% | 1.230 | 1.290 | | 95% | 0,970 | 1,070 |
Ссылки: первый тест и секунду.
| ПРОТО | первый | второй | |----|----|----| | 99% | 1.060 | 1.010 | | 98% | 0,700 | 0,660 | | 95% | 0,550 | 0,530 |
Ссылки: первый тест и секунду.
| КВАРТИРА | первый | второй | |----|----|----| | 99% | 2.920 | 3.010 | | 98% | 2.170 | 2.490 | | 95% | 1.540 | 1.510 |
Ссылки: первый тест и секунда
Заключение
Эксперименты проводились с целью усовершенствовать наше приложение на несколько шагов — именно это вдохновило на создание этой статьи. Мы перешли с JSON на Proto2, затем на Proto3, но настоящий прирост производительности произошел только с FlatBuffer. Перенесемся на два года вперед: разработчики и сообщество значительно улучшили Protobuf. Теперь в нашем стеке мы используем высокопроизводительный язык Go вместо Kotlin с сопрограммами и Spring Boot. В моем проекте, запущенном в 2021 году, мы получили беспрецедентный прирост производительности, и вся эта история органично интегрировалась с нашей логикой и процессами.
Итак, если вы когда-нибудь окажетесь в ситуации, когда быстрая сериализация имеет решающее значение, рассмотрите FlatBuffer.
Результаты весьма неоднозначные. Сериализация с помощью FlatBuffer, как и ожидалось, происходит быстрее, чем простой JSON. Случай с валидацией тоже удивил — мы ожидали, что FlatBuffer победит, но gRPC вышел на первое место. Давайте углубимся в то, почему мы получили такие результаты. Но почему в нагрузочных тестах победил другой протокол?
В качестве примеров я написал пару достаточно простых сервисов, которые только обрабатывают входящие сообщения или «проверяют» их. Как мы можем заменить, теперь победителями являются gRPC и протокол Protocol Buffer. Кстати, считайте этот эксперимент базовым, и если у вас возникнет вопрос об ускорении сериализации и передачи сообщений по цепочке, стоит протестировать его на своем процессе. Не забывайте, что это тоже важно. принять во внимание язык программирования и стек, который вы используете. И еще раз хочу подчеркнуть важность проведения MVP проекта, если вы все же хотите перейти на другие протоколы сериализации.
Если говорить о сериализации:
JSON: В стресс-тестах метода save с нагрузкой 1000 запросов в секунду JSON демонстрирует стабильные результаты, время выполнения примерно 7045 наносекунд на одну операцию.
Protobuf: Protobuf демонстрирует высокую эффективность, превосходя JSON, со временем выполнения около 1827 наносекунд на операцию в том же тесте.
FlatBuffers: FlatBuffers выделяется среди других, демонстрируя значительно меньшее время выполнения — около 711,2 наносекунд на операцию в том же стресс-тесте.
Эти результаты подчеркивают, что FlatBuffers обеспечивает значительное преимущество в производительности по сравнению с JSON и Protobuf. Несмотря на то, что требуется более сложное обучение и использование, его реальная эффективность подчеркивает, что инвестиции в оптимизацию производительности могут окупиться в долгосрочной перспективе.
Итак, подведем итоги:
Судя по тестам, JSON все равно работает быстрее остальных — цифры не врут, правда? Действительно, мы создали сервис, который сохраняет все в базу данных. Но что, если помимо базы данных нам понадобится еще какое-то сетевое соединение? У gRPC больше преимуществ, поскольку он работает по протоколу HTTP/2.
Глядя на наши показатели, мы говорим о 1–3 мс — только подумайте, насколько это быстро!
Я надеюсь, что это было полезно для вас. Спасибо! 🙏
Как хороший шанс поделиться своими статьями о нагрузочном тестировании:
Также опубликовано здесь.
Оригинал