Golang RPC и все-все-все…

от автора

Disclaimer: this is not another one gRPC hate article… Oh, whait…

Начнем издалека — знаете, всегда было интересно, а почему, собственно, для golang существует такое большое разнообразие библиотек, для каких-то часто используемых сущностей, как-то — роутеры http (fasthttprouter забыли, как подсказали в коментах) или cache?

С выбором RPC вроде все просто, gRPC — наше всё (вы, кстати, в курсе, что g здесь — это не Google внезапно). Но не тут-то было…

Все просто без ума от Мэри gRPC (нет).

Начнем с того, что в golang изначально реализовали net/rpc со своим сериализатором gob. Типа есть потребность — в golang есть решение из коробки (так же история, что и с роутером http — он есть, но все используют сторонние решения из-за параметризованных путей запросов). И тут засада — этот rpc можно только между golang приложениями использовать. Потом выкатили gRPC и все заверте… Вкратце — gRPC использует http/2 и protobuf для сериализации (запомним, rpc — это протокол обмена плюс сериализатор). Причем gRPC реализация доступна для многих языков, фактически нет привязки, на чем писать клиентскую и серверную часть. So far, so good…

Однако не все так гладко… Понятно стремление Google объять все возможные кейсы, но! К оригинальной реализации gRPC со временем появилось куча вопросов. Иначе как объяснить, что куча контор начали пилить свои собственные реализации RPC (и/или сериализаторов)? Также, внезапно, выяснилось, что требования к RPC внутри облака (читай между микросервисами) и RPC между клиентами за пределами облака/датацентра и сервисами внутри него (за ingress/proxy/load balancer — как хотите называйте) как бы «немножко» разные? Да и выбор http/2 в качестве транспорта — ну кто-же знал, что внедрёж пойдет не так (быстро), как ожидалось.

Начнем с сериализаторов, общепризнанный фаворит — gogo/protobuf (форк golang/protobuf), генерирует более быстрый код сериализации за счет переиспользования памяти и отказа о рефлексии/указателей, а так же других оптимизаций, но постойте — он же Deprecated (и теперь ищут new ownership)? А это потому, что после перехода Google на protobuf API v2, разработчики gogo предпочли забить на проект (это прискорбно), чем переписать его код почти целиком. Хотя вот пример, как с gogo на API v2 переходили — Things Learned From Trying to Migrate To Protobuf V2 API from gogoprotobuf (So Far).

Но есть еще энтузиасты — зацените vtprotobuf. Парни из Vitess заморочились, и таки написали свой сериализатор под protobuf API v2, причины и цифры смотрим в статье A new Protocol Buffers generator for Go.

Кстати — не protobuf единым, как говорится, например та же Google когда-то замутила flatbuffers сериализатор. Интересно то, что gRPC вообще-то поддерживает кастомные сериализаторы, а не только protobuf из коробки. Вот пример проекта Dgraph (которые начинали как раз с net/rpc с flatbuffers вместо gob), а потом перешли на gRPC, но тоже с flatbuffers — Custom encoding: Go implementation in net/rpc vs grpc and why we switched.

Вообще, как упоминалось ранее — есть 100500 разных реализаций отдельных сущностей (наверное, это все-таки не проблема конкретно golang), вот github репа, где сравнивается производительность всех (наверное) существующих сериализаторов для golang, правда результаты там довольно странные по состоянию на сейчас (gob медленнее JSON — это как вообще?), если сравнивать по годам:

2022/09/05 — Go 1.16.5 linux/amd64 i7-3630QM

benchmark

iter

time/iter

bytes/op

allocs/op

Json_Marshal-8

189709

6090

151

208

Json_Unmarshal-8

92833

12751

151

383

Gob_Marshal-8

71692

16463

163

1616

Gob_Unmarshal-8

14772

84385

163

7688

Goprotobuf_Marshal-8

1405010

854

53

64

Goprotobuf_Unmarshal-8

973688

1255

53

168

Gogoprotobuf_Marshal-8

3359550

354

53

64

Gogoprotobuf_Unmarshal-8

1908633

619

53

96

Musgo_Marshal-8

4294477

280

46

48

Musgo_Unmarshal-8

2498404

480

46

96

2021/06/21 — Go 1.16.5 linux/amd64 i7-3630QM

benchmark

iter

time/iter

bytes/op

allocs/op

Json_Marshal-8

501478

2538

151

208

Json_Unmarshal-8

226456

5023

151

383

Gob_Marshal-8

1320562

882

63

40

Gob_Unmarshal-8

1000000

1041

63

112

Goprotobuf_Marshal-8

3247056

378

53

64

Goprotobuf_Unmarshal-8

1839267

651

53

168

Gogoprotobuf_Marshal-8

5886194

204

53

64

Gogoprotobuf_Unmarshal-8

3464098

345

53

96

Musgo_Marshal-8

12882543

86

0

0

Musgo_Unmarshal-8

3381966

343

96

96

В другом месте нашлись более «релевантные» результаты:

2022/03/19 Go 1.17.8 Darwin/arm64 Apple M1 Max

benchmark

iter

time/iter

bytes/op

allocs/op

Json_Marshal-8

1440837

822

148

208

Json_Unmarshal-8

653754

1817

148

399

Gob_Marshal-8

2750721

440

63

40

Gob_Unmarshal-8

2918254

410

63

112

Goprotobuf_Marshal-8

6831308

176

53

64

Goprotobuf_Unmarshal-8

5746256

210

53

168

Gogoprotobuf_Marshal-8

16528346

72

53

64

Gogoprotobuf_Unmarshal-8

12764978

94

53

96

Musgo_Marshal-8

22535546

53

48

0

Musgo_Unmarshal-8

12952696

90

48

96

В общем, gogo быстрее в два раза реализации от Google. Кстати, можно заметить в таблице некий musgo — очень даже неплохо себя показывает (ибо codegen). Вероятно, в таблицу стоило вставить достаточно известный msgpack — проект от opensource сообщества, который все никак не взлетит как следует (но подвижки вроде есть). Для дополнительного чтения — Зоопарк в Golang MSA. Protobuf, MessagePack, Gob – что выбрать?

Идем дальше. Все чаще разрабы задаются вопросом, а чой-та golang gRPC такой монструозный в плане оверхеда на зависимости? И почему под капотом у него собственная реализация http/2 стека, а не переиспользование пакета «golang.org/x/net/http2» (ну да, типы и конфиги из него используются, но не более). И вообще — не так все гладко с пробросом http/2 через load balancers.

Дабы решить две упомянутые проблемы — зависимости от кода (читай, постоянной войны с багами и breaking changes, которые в Google, видимо — «нормальное» явление) и поддержки http 1.1, в Twitch запилили свой фреймворк Twirp (кстати, http/2 тоже поддерживается из стандартной библиотеки golang) — Twirp: a sweet new RPC framework for Go, о нем и на Habr тоже писалось — Twirp против gRPC. Стоит ли?

По тем же причинам в Storj тоже разработали свою альтернативу gRPC — DRPC, см. статью Introducing DRPC: Our Replacement for gRPC, причем они рассматривали Twirp, как возможное решение, но в нем не оказалось нужной фичи — стриминга (как в gRPC), которую в DRPC тоже реализовали.

Постойте-ка, до сих пор все разговоры велись о RPC между, условно говоря, облаком и клиентами на PC/Mobile. А зачем такие навороты для взаимодействия микросервисов? Почему не plain TCP (или даже UDP, в сетевых игрушках так делают иногда)? Ах, да — net/rpc же есть (что вам еще нужно-то, как бы спрашивает Google).

Нужно больше производительности и фич! Так появилась сначала библиотека valyala/gorpc, а затем и valyala/fastrpc от Александра Валялкина, автора fasthttp (читать про неё тут на Habr — Грехи оптимизации производительности).

При ближайшем рассмотрении оказывается, что на самом деле RPC реализаций много (например rpcx, kitex, arpc, сравнение их производительности с gRPC и net/rpc — 2022 Go Ecosystem rpc Framework Benchmark), но на слуху у всех gRPC как некая «серебряная пуля».

И про UDP based RPC — есть проект Hprose (High Performance Remote Object Service Engine) от китайских товарищей, он поддерживается для многих языков, и для golang тоже есть реализация, так вот — там есть поддержка UDP. Кроме того, вышеупомянутый rpcx поддерживает TCP, HTTP, QUIC (который под капотом UDP) and KCP (так сказать китайский вариант QUIC, тоже на UDP).

Ну и напоследок, к вопросу как работает gRPC под капотом… Оказывается, есть простой способ его ускорить. Тут вот какие-то слоупоки пишут в 2022 году-то The Mysterious Gotcha of gRPC Stream Performance, у нас такой трюк в PROD уже года 4 используется: как известно, в gRPC есть простые вызовы и стриминговые, так вот — если сделать пул стримов вместо простого вызова, то все работает быстрее приблизительно в два раза (с последовательными или конкурентными запросами — неважно), абстрактный пример:

api.proto

syntax = "proto3";  package pb  message Request {} message Response {}  service Service {   rpc Unary (Request) returns (Response);   rpc Stream (stream Request) returns (stream Response); }

server.go

func (s *grpcServer) Unary(ctx context.Context, req *pb.Request) (*pb.Response, error) { return &pb.ResponseDomain{}, nil }  func (s *grpcServer) Stream(stream pb.Service_StreamServer) error { ctx := stream.Context() for { select { case <-ctx.Done(): return ctx.Err() default: }  req, err := stream.Recv() if err == io.EOF { break }  if err != nil { return err }  resp, _ := s.Unary(ctx, req) if err := stream.Send(resp); err != nil { return err } } return nil }

client.go

func (c *grpcClient) Call(ctx context.Context, req *pb.Request) (*pb.Response, error) { if !c.streams { return c.client.Unary(ctx, req) }  stream := c.getStreamFromPool() if stream == nil { return nil, fmt.Errorf("no stream") }  if err := stream.Send(req); err != nil { stream, err = c.client.Stream(ctx) if err != nil { return nil, err } }  defer c.putStreamToPool(stream) return stream.Recv() }

Но никто не знает, когда это cломается (хотя это хак, как ни крути), иначе таких твитов бы не было, я думаю.


ссылка на оригинал статьи https://habr.com/ru/post/688756/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *