1. 首页
  2. >
  3. 编程技术
  4. >
  5. Java

从REST到gRPC:性能如何优化

"打破整体"。 这些是我在以前的实习过程中多次听到的话。 各地的公司都在意识到构建基于微服务的体系结构的好处。 从更低的成本,更好的性能到更少的停机时间,微服务相对于其先前的整体设计提供了无数的好处。 现在,所有这些微服务每秒都会互相交谈数千次,因此它们之间的通信需要快速而可靠。 执行此操作的传统方法是JSON支持的HTTP / 1.1 REST通信。 但是,诸如gRPC之类的替代方案在性能,成本和便利性方面提供了明显的好处。

问题

当单体服务中的类彼此通信时,它们将通过定义明确的接口进行通信。 这些接口带有语言本机对象,可用于传入和接受它们。 格式和用法上的大多数错误都将由编译器捕获,并且使用者不必创建新对象。 通过转换器和填充器在对象之间发生的任何转换都是在二进制级别完成的,而不是人类可读的格式。

将此与基于微服务的设计进行比较。 每当我们尝试使用新服务时,我们都需要使用其API文档来构建自己的对象,并确保字段类型和名称完全匹配。 然后,我们需要将数据转换为这个新对象。 接下来,我们需要使用一些转换器将该对象转换为JSON。 最后,当接受来自API的响应时,我们将再次反向执行整个过程。 这整个过程引起两个主要问题:性能不佳和开发缓慢。

比较方式

考虑到现状,即基于HTTP / 1.1的基于JSON的REST支持的问题,我将其与我认为更适合我们今天发现的微服务范例的解决方案:gRPC。 为了比较效果,我有三个主要限制:

· 与语言无关:我们希望能够灵活地使用最佳技术来完成工作

· 易于使用:开发速度至关重要

· 快速:从长远来看,每增加一毫秒就会失去客户并花费数千美元



语言和平台支持

REST和JSON

REST几乎支持所有类型的环境。 从后端应用程序到移动设备再到Web,REST和HTTP / 1.1都可以正常工作。

对于JSON,几乎所有存在的语言都存在库,这是许多基于REST的服务所假定的默认内容类型。 而且在最坏的情况下,您可以使用文本字符串构造JSON,因为JSON实际上只是以特定方式格式化的纯文本。

gRPC和协议缓冲区

从gRPC的官方网站[1]上,大多数流行的语言和平台都支持它:C ++,Java(包括Android),Python,Go,Ruby,C#,Objective-C(包括iOS),JavaScript(Node.js和浏览器)以及 更多。 但是,对其中许多平台的支持是新的,因此可能不足以用于生产用途。 例如,在使用grpc-web来支持浏览器gRPC时,我们遇到了许多问题,一些是由于缺少功能而有些是由于缺少文档。

同样对于协议缓冲区,针对许多受支持语言的库还没有针对C ++和Java的库开发得很好。 在寻找文档或尝试使用任何不那么流行的语言创建代码生成插件时,这一点非常明显:我们想要的功能要么埋藏在对GitHub问题的回应中,要么根本没有实现。

结果

尽管我们最终能够使用我们所使用的语言来构建我们想要使用gRPC和Protocol Buffers进行的所有操作,但是JSON在其中大多数语言中的确提供了更好的支持和文档。 这就是为什么我们决定每当以一种新语言启动一个项目时,我们都需要确认gRPC支持已经存在到我们需要的程度。



连接-HTTP / 2与HTTP / 1.1

注意:HTTP / 2是gRPC必需的,但也可用于REST。 这并不是真正的公平比较,因为HTTP / 2是为解决HTTP / 1.1的许多难点而构建的。 这是HTTP / 1.1的一些关键问题,以及它们在HTTP / 2中的解决方案:

并发请求

不支持HTTP / 1.1中的并发请求。 但是,由于这对于现代应用程序至关重要,因此HTTP / 1.1使用了几种解决方法来创建此功能。 客户通过在接收到响应之前向服务器发送多个请求来进行流水线化。 这允许服务器并行处理所有请求,并在完成后将响应发送回去。 但是,这样做的局限性在于,仍然必须按照与请求进入时相同的顺序将响应发送回去。这导致了所谓的"行头阻塞",这意味着稍后的请求将通过等待请求的发送而停止。 发送到他们之前完成。 Web消费者使用的其他变通方法示例包括spriting(将所有图像放入一个图像)和concatenation(合并JavaScript文件),这两种方法都减少了客户端必须发出的HTTP请求的数量。

但是,在HTTP / 2中,这些解决方法均不需要,而且实际上在许多情况下会适得其反。 HTTP / 2本机支持请求多路复用,它允许无限制地发出并同时和异步响应的请求。 通过允许在单个TCP连接上同时打开多个数据流来实现此目的。 然后,当数据帧通过此连接发送时,它们包含流标识符。 此标识符由客户端和服务器发送并使用,以标识每个帧用于的流。

请求框架

请求和响应HTTP / 1.1完全为纯文本格式。 一切都由换行符分隔,包括标题和有效负载的结尾。 加上可选的空格字符和基于请求和响应类型的不同终止模式,这会导致实现混乱,进而导致许多解析和安全错误。

HTTP / 2是不同的,因为标头和有效负载被分隔为它们自己的帧。 每个帧都以一个九字节的头开始,该头指定了帧的长度,类型,流和一些标志[3]。 标头和有效载荷的分离允许使用称为HPACK的新算法更好地压缩标头,该算法通过使用特定于标头的各种压缩方法(静态字典,动态字典和霍夫曼编码)来工作,产生的压缩效果是其两倍以上 比TLS使用HTTP / 1.1 [7]执行的gzip更高。 例如,"静态字典"将61个最常见的标头压缩为仅一个字节!

基准测试

我创建了一个简单的Go服务器,该服务器支持HTTP / 2和HTTP / 1.1,其端点支持GET请求。 由于仅通过TLS支持HTTP / 2,因此必须通过HTTPS公开端点。

package main

import (
"log"
"encoding/json"
"net/http"
"github.com/Bimde/grpc-vs-rest/pb"
)

func handle(w http.ResponseWriter, _ *http.Request) {
random := pb.Random{RandomString: "a_random_string", RandomInt: 1984}
bytes, err := json.Marshal(&random)

if err != nil {
panic(err)
}

w.Header().Set("Content-Type", "application/json")
w.Write(bytes)
}

func main() {
server := &http.Server{Addr: "bimde:8080", Handler: http.HandlerFunc(handle)}
log.Fatal(server.ListenAndServeTLS("server.crt", "server.key"))
}


然后,我编写了一个消费端点的客户端方法。

package main

import (
"log"
"net/http"
"io/ioutil"
"encoding/json"
)

var client http.Client

func init() {
client = http.Client{}
}

func get(path string, output interface{}) error {
req, err := http.NewRequest("GET", path, nil)
if err != nil {
log.Println("error creating request ", err)
return err
}

res, err := client.Do(req)
if err != nil {
log.Println("error executing request ", err)
return err
}

bytes, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Println("error reading response body ", err)
return err
}

err = json.Unmarshal(bytes, output)
if err != nil {
log.Println("error unmarshalling response ", err)
return err
}

return nil
}


最后,我使用Go内置的基准测试工具(使用HTTP / 1.1和HTTP / 2传输)创建了基准测试。 总体思路是测试特定传输可以执行特定数量的请求的速度(此数量由基准测试工具选择)。

请注意,由于证书是在本地创建的,而不是由受信任的证书颁发机构颁发的,因此需要自定义本地证书池。 下面的代码(我从在线教程[6]中获得):

// This code was taken from https://posener.github.io/http2/
func createTLSConfigWithCustomCert() *tls.Config {
// Create a pool with the server certificate since it is not signed
// by a known CA
caCert, err := ioutil.ReadFile("server/server.crt")
if err != nil {
log.Fatalf("Reading server certificate: %s", err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)

// Create TLS configuration with the certificate of the server
return &tls.Config{
RootCAs: caCertPool,
}
}


然后,对于HTTP / 2测试,我能够为每个调用启动新的goroutine(类似于新线程,但比新线程更轻量)并并行运行数千个请求。

func BenchmarkHTTP2Get(b *testing.B) {
client.Transport = &http2.Transport{
TLSClientConfig: createTLSConfigWithCustomCert(),
}

var wg sync.WaitGroup
wg.Add(b.N)
for i := 0; i < b.N; i++ {
go func() {
get("https://bimde:8080", &pb.Random{})
wg.Done()
}()
}
wg.Wait()
}


一次运行10000个请求时,每个请求平均大约需要350毫秒。 这很慢,因此我们稍后会解决。

但是,使用HTTP / 1.1尝试相同的操作会产生此错误:

Get https://bimde:8080: dial tcp 127.0.1.1:8080: socket: too many open files

HTTP / 1.1一次不支持这么多连接(因为HTTP / 1.1需要多个TCP连接来进行并发请求)。

因此,我实现了一个Job / Worker模式,以控制正在执行多少个并发请求。 这是通过让测试将作业添加到的队列(我正在使用Go中的通道)和工作人员的队列来完成的,这些工作人员会尽快从该队列中消耗作业。 并发请求的数量取决于创建的goroutine的数量,该goroutine的数量由下面的noWorkers变量定义。

type Request struct {
Path string
Random *pb.Random
}

const stopRequestPath = "STOP"

func startWorkers(requestQueue *chan Request, noWorkers int) func() {
var wg sync.WaitGroup
wg.Add(noWorkers)
for i := 0; i < noWorkers; i++ {
startWorker(requestQueue, &wg)
}
// Returns a function that stops as many workers as were just started
return func() {
stopRequest := Request{Path: stopRequestPath}
for i := 0; i < noWorkers; i++ {
*requestQueue <- stopRequest
}
wg.Wait()
}
}

func startWorker(requestQueue *chan Request, wg *sync.WaitGroup) {
go func() {
for {
request := <- *requestQueue
if (request.Path == stopRequestPath) {
wg.Done()
return
}
get(request.Path, request.Random)
}
}()
}


这是HTTP / 1.1测试:

const noWorkers = 2

func BenchmarkHTTP11Get(b *testing.B) {
client.Transport = &http.Transport{
TLSClientConfig: createTLSConfigWithCustomCert(),
}
requestQueue := make(chan Request)
defer startWorkers(&requestQueue, noWorkers)()
b.ResetTimer() // don't count worker initialization time
for i := 0; i < b.N; i++ {
requestQueue <- Request{Path: "https://bimde:8080", Random: &pb.Random{}}
}
}


这是更新的HTTP / 2测试:

func BenchmarkHTTP2GetWithWokers(b *testing.B) {
client.Transport = &http2.Transport{
TLSClientConfig: createTLSConfigWithCustomCert(),
}
requestQueue := make(chan Request)
defer startWorkers(&requestQueue, noWorkers)()
b.ResetTimer() // don't count worker initialization time
for i := 0; i < b.N; i++ {
requestQueue <- Request{Path: "https://bimde:8080", Random: &pb.Random{}}
}
}


使用这种模式,我终于能够为HTTP / 1.1和HTTP / 2获得合理的结果。

从REST到gRPC:性能如何优化

如果您注意到的话,使用单个goroutine的HTTP / 1.1请求的运行时开始比HTTP / 2更好(依次通过单个TCP连接一次请求一个)。 但是,随着处理需求开始增加并且同时工作的工人数量增加,HTTP / 1.1很快开始崩溃。 仅使用4个并发连接就可以使HTTP / 1.1崩溃。

另一方面,HTTP / 2仍在不断扩展。 即使有32个并发流,运行时/请求也会持续下降。 下面是另一个图表,这次测试的是HTTP / 2的限制。

从REST到gRPC:性能如何优化

如您所见,HTTP / 2实际上仅在单个TCP连接上的500多个并发流中开始崩溃。 与HTTP / 1.1的4个连接相比,这是一个巨大的改进。

应用

尽管现在几乎所有使用的设备浏览器都支持HTTP / 1.1,但只有约70%的客户端支持HTTP / 2。 这意味着我们需要同时支持两种协议才能支持所有客户端。 理想情况下,对于尚未升级的现有服务,我们所有的服务都可以支持HTTP / 2并回退到HTTP / 1.1。

结果

HTTP / 1.1的主要好处是被大众广泛采用。 由于大规模的大规模性能优势,HTTP / 2至少是内部通信的捷径。 这将我们的决定范围缩小到使用HTTP / 2的REST或仅支持HTTP / 2的gRPC。



易用性

编写代码

如前所述,REST API是一个非常通用的规范,可以从任何地方访问。 通常,这实际上使这些REST请求比所需的更加冗长。 考虑到需要将基于语言的对象转换为JSON,然后再将其从JSON转换回基于语言的对象,以便发出REST请求,这一点尤其正确。 这是一个最小的Go函数的示例,该函数使用一个结构作为输入来发出POST请求,并使用内置的HTTP和JSON库来另一个输出结构:

func Post(path string, input interface{}, output interface{}) error {
data, err := json.Marshal(input)
if err != nil {
log.Println("error marshalling input ", err)
return err
}

body := bytes.NewBuffer(data)
req, err := http.NewRequest("POST", path, body)
if err != nil {
log.Println("error creating request ", err)
return err
}

var client http.Client
res, err := client.Do(req)
if err != nil {
log.Println("error executing request ", err)
return err
}

bytes, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Println("error reading response body ", err)
return err
}

err = json.Unmarshal(bytes, output)
if err != nil {
log.Println("error unmarshalling response ", err)
return err
}

return nil
}


字数:103

这里尝试使用gRPC和协议缓冲区实现相同的目的:

syntax = "proto3";

package pb;

message Random {
string randomString = 1;
int32 randomInt = 2;
}

service RandomService {
rpc DoSomething (Random) returns (Random) {}
}


func random(c *context.Context, input *pb.Random) (*pb.Random, error) {
conn, err := grpc.Dial("sever_address:port")
if err != nil {
log.Fatalf("Dial failed: %v", err)
}

client := pb.NewRandomServiceClient(conn)
return client.DoSomething(c)
}


字数:53

如您所见,使用gRPC端点绝对比使用REST端点代码少(尤其是因为您只需要执行一次拨号)。 但是,仔细检查代码后,您会发现REST请求增加的许多复杂性来自将输入的Go结构序列化为JSON数据,然后再返回到Go结构进行输出。 实际上,它是字数的50%!

此外,在REST中,由于没有为客户端提供该API的任何语言本机对象,因此它们通常只能自己创建这些对象。 使用gRPC和协议缓冲区(为客户端提供语言本机对象)时,编译器会捕获许多与API相关的错误[9],这比查看REST API的错误代码要方便得多。

由于对象的创建甚至都不是上述字数差异的一部分,因此与REST相比,使用gRPC终结点最终实现起来更加简单,快捷。

调试与支持

由于JSON对象为纯文本格式,因此可以手动创建它们,这使它们作为开发人员非常容易使用。 如果遇到问题,您可以目视检查代码中的JSON对象并找出问题所在。 您甚至可以自己编辑JSON对象以添加或删除属性。 当使用以前从未使用过的新API时,此功能特别有用。

gRPC请求上的协议缓冲区使直接查看通过网络传输的数据变得更加困难,因为它只是被编码为二进制数据。 但是,由于发送的数据是本机语言对象的精确表示,因此我们可以仅使用预先存在的特定于语言的调试工具在发送gRPC请求之前查看对象的状态。 这也使得调试非常容易。

能够使用JSON查看通过网络传递的数据绝对有帮助。 但是,协议缓冲区与语言的强大集成提供了几乎一样简单的方法来确定请求的内容。

结果

尽管gRPC具有更大的学习曲线,更少的支持并且更难直接调试,但它在开发人员效率方面的改进(尤其是在客户端方面)显示了强大的优势。 我们认为,提供对特定语言的协议缓冲区的支持和文档很强大,我们应该能够使用基于语言的调试工具来克服调试问题,从而从gRPC的更快开发时间中受益。



整体表现

我们已经比较了HTTP / 1.1和HTTP / 2。 因此,现在让我们采用REST的最佳形式(基于HTTP / 2的REST),将其与gRPC所提供的一切相提并论。

现在,我们已经方便地编写了所需的所有客户端代码。 我们将在上面的"易用性"部分中比较简单的POST请求及其等效的gRPC的性能。

服务器实施

package main

import (
"log"
"net"

"golang.org/x/net/context"
"google.golang.org/grpc"

"github.com/Bimde/grpc-vs-rest/pb"
)

type server struct{}

func main() {
lis, err := net.Listen("tcp", ":9090")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterRandomServiceServer(s, &server{})
log.Println("Starting gRPC server")
if err := s.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}

func (s *server) DoSomething(_ context.Context, random *pb.Random) (*pb.Random, error) {
random.RandomString = "[Updated] " + random.RandomString;
return random, nil
}


package main

import (
"log"
"encoding/json"
"net/http"
"github.com/Bimde/grpc-vs-rest/pb"
)

func handle(w http.ResponseWriter, req *http.Request) {
decoder := json.NewDecoder(req.Body)
var random pb.Random
if err := decoder.Decode(&random); err != nil {
panic(err)
}
random.RandomString = "[Updated] " + random.RandomString

bytes, err := json.Marshal(&random)
if err != nil {
panic(err)
}

w.Header().Set("Content-Type", "application/json")
w.Write(bytes)
}

func main() {
server := &http.Server{Addr: "bimde:8080", Handler: http.HandlerFunc(handle)}
log.Fatal(server.ListenAndServeTLS("server.crt", "server.key"))
}


这两个服务器程序都非常简单,实现了客户端所需的ADT。 两个服务器都通过HTTP / 2在本地运行。

基准测试

由于我们已经有了HTTP / 1.1和HTTP / 2基准的Job / Worker实现,因此可以重用该代码。

现在,我们需要的只是各个基准。 首先,REST基准:

func BenchmarkHTTP2GetWithWokers(b *testing.B) {
client.Transport = &http2.Transport{
TLSClientConfig: createTLSConfigWithCustomCert(),
}
requestQueue := make(chan Request)
defer startWorkers(&requestQueue, noWorkers, startPostWorker)()
b.ResetTimer() // don't count worker initialization time

for i := 0; i < b.N; i++ {
requestQueue <- Request{
Path: "https://bimde:8080",
Random: &pb.Random{
RandomInt: 2019,
RandomString: "a_string",
},
}
}
}

func startPostWorker(requestQueue *chan Request, wg *sync.WaitGroup) {
go func() {
for {
request := <- *requestQueue
if (request.Path == stopRequestPath) {
wg.Done()
return
}
post(request.Path, request.Random, request.Random)
}
}()
}


接下来,gRPC基准测试:

func BenchmarkGRPCWithWokers(b *testing.B) {
conn, err := grpc.Dial("bimde:9090", grpc.WithInsecure())
if err != nil {
log.Fatalf("Dial failed: %v", err)
}
client := pb.NewRandomServiceClient(conn)
requestQueue := make(chan Request)
defer startWorkers(&requestQueue, noWorkers, getStartGRPCWorkerFunction(client))()
b.ResetTimer() // don't count worker initialization time

for i := 0; i < b.N; i++ {
requestQueue <- Request{
Path: "http://localhost:9090",
Random: &pb.Random{
RandomInt: 2019,
RandomString: "a_string",
},
}
}
}

func getStartGRPCWorkerFunction(client pb.RandomServiceClient) func (*chan Request, *sync.WaitGroup) {
return func(requestQueue *chan Request, wg *sync.WaitGroup) {
go func() {
for {
request := <- *requestQueue
if (request.Path == stopRequestPath) {
wg.Done()
return
}
client.DoSomething(context.TODO(), request.Random)
}
}()
}
}


请注意,getStartGRPCWorkerFunction函数返回其中带有RandomServiceClient的闭包。 这样一来,我们只需拨打一次gRPC服务器,即可完成整个测试,而仅执行一次TCP握手。

结果

从REST到gRPC:性能如何优化

尽管基于HTTP / 2的REST的扩展能力与gRPC差不多,但就纯性能而言,gRPC在整个工作负载范围内可将处理时间减少50%至75%。



结果

让我们回到原始标准:

· 语言中立

· 快速

· 易于使用

在语言支持方面,JSON支持的REST无疑是赢家。 在过去的几年中,gRPC的语言支持已大大改善,可以说对于大多数用例来说已经足够了。

我们的性能比较从所有用例中消除了HTTP / 1.1,但通过前端API服务支持旧版客户端。 在gRPC和HTTP / 2上的REST之间,性能差异仍然很大。 每当请求性能成为关键问题时,gRPC似乎都是正确的选择。

在易用性方面,与REST相比,开发人员只需编写更少的代码即可在gRPC中完成相同的任务。 调试是不同的,但不一定会更困难。 开发人员习惯于新范式的问题更多。

从我们的发现中,我们可以看到gRPC是内部服务与服务通信之间更好的解决方案。 它具有更好的性能,提高了开发速度,并且在语言方面足够中立。 我们可以得出结论,除非需要REST支持外部客户端或尚未支持gRPC的语言/平台,否则我们应该默认构建gRPC服务。

影响

· 减少客户的等待时间; 更好的用户体验

· 缩短请求处理时间; 降低成本

· 提高开发人员效率; 降低公司成本,开发更多新功能


源代码

私信译者获得本文代码的github地址


(本文翻译自Bimesh De Silva的文章《gRPC vs. REST: Performance Simplified》,参考:https://medium.com/@bimeshde/grpc-vs-rest-performance-simplified-fd35d01bbd4)