Tim Bastin - Softwarearchitekt & Software Sicherheitsspezialist
Tim Bastin
Softwarearchitekt & Software Sicherheitsspezialist
28.2.2023

Performance comparison: REST vs gRPC vs asynchronous communication

Bonn, 28.2.20231 Minuten Lesezeit
Performance comparison: REST vs gRPC vs asynchronous communication

The communication method between microservices has a significant impact on various software quality factors within a microservice architecture (more information on the crucial role of communication inside a microservice network). The communication style influences functional requirements like the performance and the efficiency of the software, as well as non-functional requirements like changeability, scalability, and maintainability. Therefore, it is necessary to consider all pro and cons of the different methods to make a reasoned choice of the correct communication style in a concrete use case.

This article compares the styles: REST, gRPC and an asynchronous communication using a message broker (RabbitMQ), inside a microservice network regarding their performance impact on the software. Some of the most important properties of the communication style (which in turn are influencing the overall performance) are:

  1. Data transmission format

  2. Connection-Handling

  3. Message serialization

  4. Caching

  5. Load-Balancing

Data transmission format

While asynchronous communication using the AMQP protocol (Advanced Message Queuing Protocol) and gRPC communication is performed using binary protocols for data transfer, REST-APIs are usually transmitting data in text-format. Binary protocols are much more efficient compared to text-based protocols [1,2]. Thus, communication using gRPC and AMQP results in a lower network load, while one can expect a higher network load when using REST APIs.

Connection-Handling

REST-APIs are often built on top of the HTTP/1.1 protocol, while gRPC relies on the use of the HTTP/2 protocol. HTTP/1.1, HTTP/2, and also AMQP are using TCP at the transport level to ensure a stable connection. To establish such a connection, elaborate communication between client and server is required. These performance implications apply equally to all communication styles. However, the initial establishment of the communication connection only needs to be performed once for an AMQP or HTTP/2 connection, since requests can be multiplexed for both protocols. This means that existing connections can be reused for subsequent requests using the asynchronous or gRPC communication. A REST-API which is using HTTP/1.1, on the other hand, establishes a new connection for each request with the remote server.

Diagramm des TLS-Handshake-Prozesses zwischen Client und ServerMessage serialization

Typically, REST and asynchronous communication is performed using JSON for message serialization before transmitting messages over a network. gRPC on the other hand transmits data by default in protocol buffer format. Protocol buffers increase the speed of communication by allowing more advanced serialization and de-serialization methods to be used for encoding and consuming the message content [1]. Nevertheless, it is up to the engineer, to choose the right message serialization format. Regarding performance protocol buffers are having numerous advantages, but when one has to debug the communication between microservices, relying on the human-readable JSON format might be the better choice.

Caching

An efficient caching strategy can significantly reduce the load on servers and the necessary computing resources. REST-APIs are the only communication style which allow an efficient caching due to their architecture. REST-API responses can be cached and replicated by other servers and caching proxies like Varnish. This reduces the load on the REST service and allows large amounts of HTTP traffic to be processed [1]. However, this is only possible after deploying more services (caching proxies) on the infrastructure or using third-party integration. Neither the official gRPC documentation, nor the documentation of RabbitMQ, introduce any forms of caching.

Load-Balancing

In addition to temporary storage of responses, there are other technologies that can increase the speed of services. Load balancers such as mod_proxy can distribute HTTP traffic between services in an efficient and transparent way [1]. This enables horizontal scaling of the services which are using REST APIs. Kubernetes, as a container orchestration solution, performs load balancing of HTTP/1.1 traffic without any adjustments. For gRPC, on the other hand, another service (linkerd) needs to be provisioned on the network [3]. Asynchronous communication supports load balancing without further aids. The message broker itself takes the role of the load balancer, as it is capable of distributing requests to multiple instances of the same service. Message brokers are optimized for this purpose, and their design already took into account the fact that they must be particularly scalable [1].

Experiment

To be able to evaluate the individual communication methods in terms of their impact on the software quality characteristics, four microservices were developed to simulate the order scenario of an e-commerce platform.

Flussdiagramm, das den Bestellprozess in einem E-Commerce-System darstellt, beginnend beim Kunden, der eine Bestellung aufgibt, über die Bestandsprüfung und Rechnungserstellung im OrderService, bis hin zur Zahlungsabwicklung im CustomerService und dem Versand durch den ShippingService.The microservices are deployed on a self-hosted Kubernetes cluster consisting of three different servers. The servers are connected via a Gigabit (1000 Mbit/s) network and are located in the same data center, with an average latency between servers of 0.15ms. The individual services are deployed on the same servers for each experiment run. This behavior is achieved by a pod affinity.

All microservices are implemented in the GO programming language. The actual business logic of the individual services, such as communication with a database, was deliberately not implemented in order not to falsify the results of the experiment by other influences apart from the selected communication method. Thus, the results collected are not representative of a microservice architecture of this type, but make the communication methods within the experiment comparable. Instead, the implementation of the business logic was simulated by delaying the program flow by 100 milliseconds. Within the communication, there is consequently a total delay of 400 milliseconds.

The open-source software k6 is used to implement the load testing.

Implementation

The net/http module included in the Golang standard library is used to provide a REST interface. Requests are serialized and de-serialized using the encoding/json module also included in the standard library. All requests use the HTTP POST method.

"Talk is cheap. Show me the code."
- Linus Torvalds

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"

	"github.com/google/uuid"
	"gitlab.com/timbastin/bachelorarbeit/common"
	"gitlab.com/timbastin/bachelorarbeit/config"
)

type restServer struct {
	httpClient http.Client
}

func (server *restServer) handler(res http.ResponseWriter, req *http.Request) {
	// only allow post request.
	if req.Method != http.MethodPost {
		bytes, _ := json.Marshal(map[string]string{
			"error": "invalid request method",
		})
		http.Error(res, string(bytes), http.StatusBadRequest)
		return
	}

	reqId := uuid.NewString()

	// STEP 1 / 4
	log.Println("(REST) received new order", reqId)

	var submitOrderDTO common.SubmitOrderRequestDTO

	b, _ := ioutil.ReadAll(req.Body)

	err := json.Unmarshal(b, &submitOrderDTO)
	if err != nil {
		log.Fatalf(err.Error())
	}

	checkIfInStock(1)

	invoiceRequest, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/invoices", config.MustGet("customerservice.rest.address").(string)), bytes.NewReader(b))
	// STEP 2
	r, err := server.httpClient.Do(invoiceRequest)
	// just close the response body
	r.Body.Close()
	if err != nil {
		panic(err)
	}

	shippingRequest, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/shipping-jobs", config.MustGet("shippingservice.rest.address").(string)), bytes.NewReader(b))

	// STEP 3
	r, err = server.httpClient.Do(shippingRequest)
	// just close the response body
	r.Body.Close()
	if err != nil {
		panic(err)
	}

	handleProductDecrement(1)
	// STEP 5
	res.WriteHeader(201)
	res.Write(common.NewJsonResponse(map[string]string{
		"state": "success",
	}))
}

func startRestServer() {
	server := restServer{
		httpClient: http.Client{},
	}
	http.HandleFunc("/orders", server.handler)
	done := make(chan int)
	go http.ListenAndServe(config.MustGet("orderservice.rest.port").(string), nil)
	log.Println("started rest server")
	<-done
}

A RabbitMQ message broker is used for asynchronous communication and is deployed on the same Kubernetes cluster. Communication between the message broker and the individual microservices takes place using the github.com/spreadway/amqp library. This library is recommended by the official documentation for the GO programming language.

package main

import (
	"encoding/json"
	"log"

	"github.com/streadway/amqp"
	"gitlab.com/timbastin/bachelorarbeit/common"
	"gitlab.com/timbastin/bachelorarbeit/config"
	"gitlab.com/timbastin/bachelorarbeit/utils"
)

func handleMsg(message amqp.Delivery, ch *amqp.Channel) {
	log.Println("(AMQP) received new order")
	var submitOrderRequest common.SubmitOrderRequestDTO
	err := json.Unmarshal(message.Body, &submitOrderRequest)
	utils.FailOnError(err, "could not unmarshal message")

	checkIfInStock(1)

	handleProductDecrement(1)
	ch.Publish(config.MustGet("amqp.billingRequestExchangeName").(string), "", false, false, amqp.Publishing{
		ContentType: "application/json",
		Body:        message.Body,
	})

}

func getNewOrderChannel(conn *amqp.Connection) (*amqp.Channel, string) {
	ch, err := conn.Channel()
	utils.FailOnError(err, "could not create channel")

	ch.ExchangeDeclare(config.MustGet("amqp.newOrderExchangeName").(string), "fanout", false, false, false, false, nil)

	queue, err := ch.QueueDeclare(config.MustGet("orderservice.amqp.consumerName").(string), false, false, false, false, nil)

	utils.FailOnError(err, "could not create queue")

	ch.QueueBind(queue.Name, "", config.MustGet("amqp.newOrderExchangeName").(string), false, nil)
	return ch, queue.Name
}

func startAmqpServer() {
	conn := common.NewAmqpConnection(config.MustGet("amqp.host").(string))
	defer conn.Close()

	orderChannel, queueName := getNewOrderChannel(conn)

	msgs, err := orderChannel.Consume(
		queueName,
		config.MustGet("orderservice.amqp.consumerName").(string),
		true,
		false,
		false,
		false,
		nil,
	)

	utils.FailOnError(err, "could not consume")

	forever := make(chan bool)
	log.Println("started amqp server:", queueName)
	go func() {
		for d := range msgs {
			go handleMsg(d, orderChannel)
		}
	}()
	<-forever
}

The gRPC clients and servers use the google.golang.org/grpc library recommended by the gRPC documentation. Serialization of data is done using protocol buffers.

package main

import (
	"log"
	"net"

	"context"

	"gitlab.com/timbastin/bachelorarbeit/common"
	"gitlab.com/timbastin/bachelorarbeit/config"
	"gitlab.com/timbastin/bachelorarbeit/pb"
	"gitlab.com/timbastin/bachelorarbeit/utils"
	"google.golang.org/grpc"
)

type OrderServiceServer struct {
	CustomerService pb.CustomerServiceClient
	ShippingService pb.ShippingServiceClient
	pb.UnimplementedOrderServiceServer
}

func (s *OrderServiceServer) SubmitOrder(ctx context.Context, request *pb.SubmitOrderRequest) (*pb.SuccessReply, error) {
	log.Println("(GRPC) received new order")
	if s.CustomerService == nil {
		s.CustomerService, _ = common.NewCustomerServiceClient()
	}
	if s.ShippingService == nil {
		s.ShippingService, _ = common.NewShippingServiceClient()
	}

	checkIfInStock(1)

	// call the product service on each iteration to decrement the product.
	_, err := s.CustomerService.CreateAndProcessBilling(ctx, &pb.BillingRequest{
		BillingInformation: request.BillingInformation,
		Products:           request.Products,
	})

	utils.FailOnError(err, "could not process billing")

	// trigger the shipping job.
	_, err = s.ShippingService.CreateShippingJob(ctx, &pb.ShippingJob{
		BillingInformation: request.BillingInformation,
		Products:           request.Products,
	})

	utils.FailOnError(err, "could not create shipping job")

	handleProductDecrement(1)

	return &pb.SuccessReply{Success: true}, nil
}

func startGrpcServer() {
	listen, err := net.Listen("tcp", config.MustGet("orderservice.grpc.port").(string))
	if err != nil {
		log.Fatalf("could not listen: %v", err)
	}

	grpcServer := grpc.NewServer()

	orderService := OrderServiceServer{}
	// inject the clients into the server
	pb.RegisterOrderServiceServer(grpcServer, &orderService)

	// start the server
	log.Println("started grpc server")
	if err := grpcServer.Serve(listen); err != nil {
		log.Fatalf("could not start grpc server: %v", err)
	}
}

Collecting Data

The number of successful and failed order processes is examined regarding the time elapsed until their confirmation. An order process is interpreted as failed if the duration until confirmation exceeds 900 milliseconds. This duration was chosen because infinitely long waiting times can occur within the experiment, especially when using asynchronous communication. The number of failed and successful orders is reported for each trial.

A total of twelve different measurements are taken for each architecture, with different numbers of simultaneous requests and varying amounts of data transmitted in each case. First, each communication method is tested under low load, then under medium load, and finally under high load. A low load is simulated with ten, a medium load with 100 and a high load with 300 simultaneous requests to the system. After these six test runs, the amount of data to be transferred is increased to gain knowledge about the efficiency of the serialization method of the respective interface. The increase in the amount of data is achieved by placing an order for several products.

Results

The gRPC API architecture is the best performing communication method studied within the experiment. Under low load, it can accept 3.41 times as many orders as the system using REST interfaces. Also, the average response time is 9.71 milliseconds lower compared to REST-APIs and 9.37 milliseconds lower compared to AMQP-APIs.

BoxplotBar chart comparing average requests per second for gRPC, REST, and AMQP at low and high concurrency levels.Overall, this trend continues in the experiments with a larger amount of concurrent requests. Regarding 100 concurrent requests, 3.7 times as many orders can be processed using a gRPC-API architecture compared to a REST-API. The difference to AMQP is much smaller. GRPC is capable of processing 8.06% (1170 orders) more, than AMQP. While gRPC can process 95% of requests within 418.99 milliseconds, AMQP is only capable of doing so in 557.39 milliseconds and REST in 1425.13 milliseconds.

Boxplot illustrating request duration in milliseconds for gRPC, REST, and AMQP communication methods.

Boxplot showing request duration for gRPC, REST, and AMQP with millisecond measurements.Bar chart comparing average requests per second for gRPC, REST, and AMQP at low and high concurrency levels.In the third run under high load, the microservice architecture with gRPC APIs can successfully confirm 43122 orders. This is 4.8 times more than the same architecture with REST-APIs, and 2.02 times more than the architecture using asynchronous communication.

Boxplot comparing request duration in milliseconds for gRPC, REST, and AMQP methods.Boxplot depicting request durations for gRPC, REST, and AMQP with latency measurements in milliseconds.Bar chart comparing average requests per second for gRPC, REST, and AMQP at low and high concurrency levels.Looking at the size of the client request in JSON format compared to the size in protocol buffer format, for a single product, the amount of data transferred using gRPC is 34.202% less compared to REST. This difference can be justified by the optimized protocol-buffer serialization, which offers a more efficient encoding than JSON.

Conclusion

gRPC proved to be the most efficient API architecture, followed by AMQP with a message broker. gRPC offers a more efficient serialization method than JSON with protocol buffers. Overall, about 3.4–4.03 more orders can be processed compared to REST. Especially in situations with many concurrent connections, gRPC can offer an advantage over REST-APIs because TCP connections can be reused. Compared to asynchronous communication 1.0–2.2 more orders can be processed. Another advantage of gRPC that emerged through the experiment is that gRPC provides predictable performance without large outliers in response time. During the experiment, AMQP, and REST in particular, showed a large discrepancy between the average response time and the 95% quantile. The experiment examined how each communication style performed under a variable number of concurrent requests and transmitted data, but this study can only approximate the complexity of reality. Supporting technologies such as load balancers and caching proxies can have significant impacts, especially when using REST APIs. The results of this experiment must be evaluated considering the experiment setup.

A statement that gRPC allows the most efficient communication within a microservice network cannot be made.


References

[1] Sam Newman.Building Microservices: Designing Fine-Grained Systems. Vol. 2. O’ReillyMedia, Inc, 2015.

[2] Jim Webber, Savas Parastatidis, and Ian Robinson. REST in Practice: Hypermedia andSystems Architecture. O’Reilly Media, Inc., 2010, p. 448.

[3] William Morgan.gRPC Load Balancing on Kubernetes without Tears. https://kubernetes.io/blog/2018/11/07/grpc-load-balancing-on-kubernetes-without-tears/. (Accessed on 09/15/2021). Nov. 2018.