¿Cómo funciona gRPC realmente por debajo — Protocol Buffers, streaming sobre HTTP/2, y por qué es más rápido que REST+JSON?
La fama de velocidad de gRPC en realidad son tres mecanismos separados apilados entre sí — separarlos te da una respuesta genuinamente senior en lugar de un benchmark memorizado.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo funciona gRPC realmente por debajo — Protocol Buffers, streaming sobre HTTP/2, y por qué es más rápido que REST+JSON?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Tres mecanismos separados, apilados
"gRPC es más rápido que REST+JSON" es cierto lo suficientemente seguido como para ser la respuesta esperada en una entrevista, pero una respuesta senior separa el por qué en tres capas independientes que resulta que vienen empaquetadas juntas: Protocol Buffers como formato de transmisión, HTTP/2 como transporte, y un contrato basado en generación de código en lugar de un cliente mantenido a mano. Cada una aporta algo por su cuenta, y desagregarlas es lo que le muestra a un entrevistador que entendés el mecanismo en vez de repetir un benchmark que leíste una vez.
#Protocol Buffers: un esquema decide los bytes, no los nombres de campo
Un archivo .proto define tanto la forma de los mensajes como el contrato de RPC. protoc (o un plugin específico de lenguaje) lo convierte en clases tipadas y stubs de cliente/servidor en tiempo de compilación, así que no hay reflexión en tiempo de ejecución sobre nombres de campo ni código de serialización escrito a mano que se pueda romper.
syntax = "proto3";package orders.v1;service OrderService {rpc GetOrder (GetOrderRequest) returns (Order);rpc ListOrders (ListOrdersRequest) returns (stream Order);rpc UploadOrders (stream Order) returns (UploadSummary);rpc StreamOrderUpdates (stream OrderUpdate) returns (stream Order);}message GetOrderRequest {string order_id = 1;}message ListOrdersRequest {string customer_id = 1;int32 page_size = 2;}message Order {string id = 1;string customer_id = 2;int64 total_cents = 3;OrderStatus status = 4;repeated LineItem items = 5;}message LineItem {string sku = 1;int32 quantity = 2;int64 unit_price_cents = 3;}message OrderUpdate {string order_id = 1;OrderStatus status = 2;}message UploadSummary {int32 accepted = 1;int32 rejected = 2;}enum OrderStatus {ORDER_STATUS_UNSPECIFIED = 0;ORDER_STATUS_PENDING = 1;ORDER_STATUS_PAID = 2;ORDER_STATUS_SHIPPED = 3;ORDER_STATUS_CANCELLED = 4;}
Ese único archivo ya muestra las cuatro formas de RPC que soporta gRPC: GetOrder es unario (una petición, una respuesta, el caso parecido a REST), ListOrders es streaming del servidor (una petición, un stream de respuestas), UploadOrders es streaming del cliente (un stream de peticiones, una respuesta), y StreamOrderUpdates es streaming bidireccional (los dos lados transmiten de forma independiente sobre la misma llamada).
En el cable, cada campo se codifica como una etiqueta —(número_de_campo << 3) | tipo_de_wire— seguida de su valor. El tipo de wire 0 es un varint (enteros, usando tan pocos bytes como el valor necesite), el 2 es delimitado por longitud (strings, bytes, mensajes anidados, campos repetidos), y hay tipos dedicados para valores fijos de 32 y 64 bits. Compará eso con JSON, donde {"customer_id": "cus_481"} manda el nombre literal del campo como texto en cada mensaje; protobuf manda el número de campo 2 en uno o dos bytes y el valor en binario, sin comillas, sin llaves, sin espacios en blanco, y sin parseo de strings al decodificar.
Eso también explica un detalle que a los entrevistadores les gusta indagar: los números de campo, no los nombres, son la identidad real en el cable. Sacar el campo 3 y después reutilizar el 3 para un tipo no relacionado no es solo un descuido de versionado: es corrupción silenciosa de datos, porque un binario viejo sigue decodificando esos bytes bajo el tipo que asumía el campo anterior.
#HTTP/2 por debajo: una conexión, muchas llamadas concurrentes
gRPC mapea cada llamada RPC a un stream de HTTP/2, y HTTP/2 multiplexa muchos streams sobre una única conexión TCP, cada uno identificado por un stream ID. Una petición se convierte en un frame HEADERS (:method: POST, :path: /orders.v1.OrderService/GetOrder, content-type: application/grpc) seguido de frames DATA que llevan payloads de protobuf con longitud prefijada; un RPC de streaming simplemente sigue mandando más frames DATA sobre ese mismo stream a lo largo del tiempo, y un frame HEADERS final (trailer) lleva el grpc-status y el grpc-message cuando la llamada termina.
Por eso gRPC no necesita un pool de conexiones como sí lo necesita un cliente REST malabareando muchas llamadas HTTP/1.1 simultáneas: los tres RPCs concurrentes de arriba son tres streams sobre una conexión, no tres sockets. La compresión de headers HPACK hace que los headers repetidos (método, content-type, metadata de autenticación) a lo largo de muchas llamadas sobre esa conexión cuesten casi nada después de la primera. Nada de eso es invención propia de gRPC —es lo que HTTP/2 le da a cualquier cosa construida sobre él—, pero la ventaja real de gRPC es meter HTTP/2 siempre activo y streaming directamente en el contrato de la API, así que ListOrders devolviendo un stream de mensajes Order no le cuesta al cliente N idas y vueltas de request/response como sí le cuesta la paginación en REST.
#Bosquejando el stub (Go)
// server.gotype orderServer struct {pb.UnimplementedOrderServiceServerstore OrderStore}func (s *orderServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {order, err := s.store.Find(req.GetOrderId())if err != nil {return nil, status.Errorf(codes.NotFound, "order %s not found", req.GetOrderId())}return order.ToProto(), nil}func (s *orderServer) ListOrders(req *pb.ListOrdersRequest, stream pb.OrderService_ListOrdersServer) error {orders, err := s.store.ListByCustomer(req.GetCustomerId(), int(req.GetPageSize()))if err != nil {return status.Errorf(codes.Internal, "list failed: %v", err)}for _, o := range orders {if err := stream.Send(o.ToProto()); err != nil {return err // el cliente se desconectó, o el backpressure señaló un error}}return nil}func main() {lis, _ := net.Listen("tcp", ":50051")grpcServer := grpc.NewServer()pb.RegisterOrderServiceServer(grpcServer, &orderServer{store: NewOrderStore()})grpcServer.Serve(lis)}
// client.goconn, err := grpc.Dial("orders.internal:50051", grpc.WithTransportCredentials(creds))if err != nil {log.Fatalf("dial falló: %v", err)}defer conn.Close()client := pb.NewOrderServiceClient(conn)ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)defer cancel()order, err := client.GetOrder(ctx, &pb.GetOrderRequest{OrderId: "ord_123"})if err != nil {st, _ := status.FromError(err)log.Fatalf("GetOrder falló: %s: %s", st.Code(), st.Message())}stream, err := client.ListOrders(ctx, &pb.ListOrdersRequest{CustomerId: "cus_1", PageSize: 50})for {o, err := stream.Recv()if err == io.EOF {break}if err != nil {log.Fatalf("error de stream: %v", err)}fmt.Println(o.GetId(), o.GetTotalCents())}
El context.WithTimeout del lado del cliente no es solo un resguardo local: gRPC lo serializa en un header grpc-timeout, el servidor lo hace cumplir, y si el servidor reenvía ese mismo contexto a una llamada downstream, el deadline se propaga automáticamente. Esa es una ventaja operativa real frente a REST, donde propagar timeouts a lo largo de una cadena de llamadas es una convención que cada equipo tiene que inventar por su cuenta.
#Dónde gana, y dónde no
La velocidad por la que gRPC es conocido en realidad son tres ganancias separables apiladas una sobre otra: la codificación binaria reduce los payloads y se salta el parseo de texto, la multiplexación de HTTP/2 evita abrir una conexión por cada llamada concurrente y hace posible el streaming real, y los stubs generados eliminan toda una clase de bugs de cliente y serialización escritos a mano, además de dar verificación del contrato en tiempo de compilación. Nada de eso lo convierte en un reemplazo universal de REST: los navegadores no pueden hablar gRPC crudo (no pueden leer trailers ni controlar frames de HTTP/2 directamente), así que los clientes de navegador necesitan grpc-web más un proxy que traduzca entre los dos; los payloads no son legibles por humanos, así que el debugging a ojo con curl desaparece; y el contrato .proto exige disciplina —nunca reutilizar un número de campo, solo agregar campos opcionales nuevos, y tener cuidado con los enums, ya que proto3 pone silenciosamente 0 por defecto en cualquier campo numérico no seteado, que es justo por qué 0 siempre debería ser un valor "no especificado" deliberado y no uno con significado propio.