Когда микросервисов больше пяти, межсервисное общение становится архитектурной проблемой. REST/HTTP с JSON — стандарт de facto, но gRPC активно набирает популярность. Оба подхода решают одну задачу — один сервис должен вызвать другой. Но философия, производительность и developer experience у них совершенно разные.
На нашем проекте — маркетплейс с 12 микросервисами, 3 млн запросов в час между сервисами — мы запустили оба протокола параллельно и сравнили. Вот результаты.
REST + JSON: привычный путь
REST (REpresentational State Transfer) — архитектурный стиль, который стал синонимом HTTP API. JSON — формат данных по умолчанию. Каждый микросервис предоставляет HTTP-эндпоинты, другие сервисы вызывают их через HTTP-клиент.
// REST endpoint (Ktor)
routing {
route("/api/v1/orders") {
get("/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest)
val order = orderService.findById(id)
?: return@get call.respond(HttpStatusCode.NotFound)
call.respond(OrderDto.fromDomain(order))
}
post("/{id}/items") {
val id = call.parameters["id"]?.toLongOrNull()!!
val request = call.receive<AddItemRequest>()
val updated = orderService.addItem(id, request)
call.respond(HttpStatusCode.OK, OrderDto.fromDomain(updated))
}
}
}
// HTTP-клиент (в другом микросервисе)
class OrderServiceClient(private val httpClient: HttpClient) {
suspend fun getOrder(orderId: Long): OrderDto {
val response = httpClient.get("http://order-service:8080/api/v1/orders/$orderId")
if (response.status != HttpStatusCode.OK) {
throw OrderServiceException("Order $orderId not found")
}
return response.body<OrderDto>()
}
suspend fun addItem(orderId: Long, request: AddItemRequest): OrderDto {
val response = httpClient.post("http://order-service:8080/api/v1/orders/$orderId/items") {
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status != HttpStatusCode.OK) {
throw OrderServiceException("Failed to add item: ${response.status}")
}
return response.body<OrderDto>()
}
}
REST прост в понимании и отладке. HTTP-запрос можно прочитать в Wireshark, Chrome DevTools, curl. JSON — человекочитаемый формат. Любой инструмент может вызвать REST API — от браузера до Postman и bash-скрипта.
Минусы REST: JSON — текстовый формат, избыточный (каждый ключ повторяется, нет схемы типов). Нет обязательного контракта между клиентом и сервером — контракт — это «документация», которая часто устаревает. HTTP/1.1 — одно соединение = один запрос (мультиплексирование только в HTTP/2). Нет стриминга «из коробки» (SSE работает, но ограниченно).
gRPC + Protobuf: контрактный подход
gRPC — фреймворк от Google, использующий Protocol Buffers (Protobuf) для описания контракта и HTTP/2 для передачи. Контракт определяется в .proto-файле, из которого генерируются клиентские и серверные классы.
// order_service.proto
syntax = "proto3";
package com.example.orderservice;
option java_multiple_files = true;
option java_package = "com.example.orderservice.grpc";
service OrderService {
rpc GetOrder(GetOrderRequest) returns (OrderResponse);
rpc AddItem(AddItemRequest) returns (OrderResponse);
rpc StreamOrderUpdates(StreamOrdersRequest) returns (stream OrderUpdate);
}
message GetOrderRequest {
int64 id = 1;
}
message OrderResponse {
int64 id = 1;
repeated OrderItem items = 2;
string status = 3;
double total_amount = 4;
string created_at = 5;
}
message OrderItem {
string product_id = 1;
int32 quantity = 2;
double unit_price = 3;
}
message AddItemRequest {
int64 order_id = 1;
string product_id = 2;
int32 quantity = 3;
}
message StreamOrdersRequest {
repeated int64 order_ids = 1;
}
message OrderUpdate {
int64 order_id = 1;
string event_type = 2; // CREATED, UPDATED, CANCELLED
OrderResponse order = 3;
int64 timestamp = 4;
}
// gRPC-сервер (Ktor + grpc-kotlin)
class OrderGrpcService(
private val orderService: OrderService
) : OrderServiceGrpcKt.OrderServiceCoroutineImplBase() {
override suspend fun getOrder(request: GetOrderRequest): OrderResponse {
val order = orderService.findById(request.id)
?: throw StatusException(Status.NOT_FOUND.withDescription("Order ${request.id} not found"))
return order.toGrpcResponse()
}
override suspend fun addItem(request: AddItemRequest): OrderResponse {
val updated = orderService.addItem(
orderId = request.orderId,
productId = request.productId,
quantity = request.quantity
)
return updated.toGrpcResponse()
}
override fun streamOrderUpdates(request: StreamOrdersRequest): Flow<OrderUpdate> {
return orderService.subscribeToUpdates(request.orderIdsList)
.map { it.toGrpcUpdate() }
}
}
// gRPC-клиент
class OrderServiceGrpcClient(private val channel: ManagedChannel) {
private val stub = OrderServiceGrpcKt.OrderServiceCoroutineStub(channel)
suspend fun getOrder(orderId: Long): OrderResponse {
return stub.getOrder(GetOrderRequest.newBuilder().setId(orderId).build())
}
suspend fun addItem(orderId: Long, productId: String, quantity: Int): OrderResponse {
return stub.addItem(AddItemRequest.newBuilder()
.setOrderId(orderId)
.setProductId(productId)
.setQuantity(quantity)
.build())
}
fun streamUpdates(orderIds: List<Long>): Flow<OrderUpdate> {
val request = StreamOrdersRequest.newBuilder()
.addAllOrderIds(orderIds)
.build()
return stub.streamOrderUpdates(request)
}
}
gRPC генерирует типобезопасные клиентские и серверные классы из .proto-файла. Изменили контракт — код не скомпилируется. Это надёжнее, чем REST API со спецификацией OpenAPI, которую нужно вручную поддерживать.
Protobuf — бинарный формат, в 3–10 раз меньше JSON. HTTP/2 даёт мультиплексирование (одно TCP-соединение, множество потоков), сжатие заголовков (HPACK) и двунаправленный стриминг. Это особенно важно при высокочастотном межсервисном общении.
Производительность: реальные цифры
Замеры для OrderResponse с 10 позициями (типичный ответ сервиса заказов):
Метрика | REST + JSON | gRPC + Protobuf
---------------------------|---------------|----------------
Размер полезной нагрузки | 1840 Б | 420 Б
Сериализация | 0,12 мс | 0,03 мс
Десериализация | 0,18 мс | 0,02 мс
Latency (внутри кластера) | 2,4 мс | 0,8 мс
Пропускная способность | 3 200 rps | 12 000 rps
(одно соединение) | |
P99 latency (100 соединений)| 8 мс | 1,5 мс
Загрузка CPU | 35% | 12%
(десериализация) | |
Выделение памяти | 45 КБ/запрос| 8 КБ/запрос
gRPC в 4 раза быстрее по latency и в 3 раза меньше по размеру полезной нагрузки. Сериализация Protobuf в 6 раз быстрее JSON. Для высокочастотного межсервисного общения (десятки тысяч запросов в секунду между сервисами) разница колоссальная.
Но есть нюанс: если вызов сервиса происходит раз в секунду, а сама бизнес-логика занимает 200 мс, разница между 2,4 мс и 0,8 мс latency незаметна. gRPC выигрывает при высокочастотном общении; при редких вызовах разница нивелируется.
Стриминг
Область, где gRPC безоговорочно выигрывает, — стриминг. gRPC поддерживает 4 режима:
Режим | REST | gRPC
----------------------|-------------------------|------------------
Unary (1 запрос) | HTTP GET/POST | Unary RPC
Server streaming | SSE (ограниченно) | server streaming RPC
Client streaming | Нет | client streaming RPC
Bidirectional | WebSocket (работает) | bidirectional RPC
Real-time обновления заказов: вместо опроса (каждые 5 секунд REST-запрос за статусом) — server streaming в gRPC. Клиент подписывается на обновления и получает уведомления мгновенно. На продакшене это сократило количество межсервисных запросов на 60%.
Отладка и наблюдаемость
REST проще отлаживать: curl, Postman, Chrome DevTools — всё «просто работает». JSON читаемый, HTTP-статусы стандартные. gRPC бинарный — нужен gRPCurl или gRPC reflection для отладки. Логирование gRPC-запросов требует интерсепторов, текст логов содержит бинарно-закодированные данные.
// Интерсептор для логирования gRPC
class LoggingInterceptor : ServerInterceptor {
private val logger = KotlinLogging.logger {}
override fun <ReqT : Any?, RespT : Any?> interceptCall(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>
): ServerCall.Listener<ReqT> {
val method = call.methodDescriptor.fullMethodName
logger.info { "gRPC request: $method from ${call.attributes[Grpc.TRANSPORT_ATTR_REMOTE_ADDR]}" }
return next.startCall(object : ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
override fun close(status: Status, trailers: Metadata) {
logger.info { "gRPC response: $method -> ${status.code} (${status.description ?: ""})" }
super.close(status, trailers)
}
}, headers)
}
}
Распределённая трассировка (Jaeger, Zipkin, OpenTelemetry) работает с обоими протоколами, но интеграция с gRPC сложнее — нужно передавать контекст трассировки через metadata gRPC (заголовки), а не через стандартные HTTP-заголовки.
Gateway: REST для внешних, gRPC для внутренних
На продакшене мы используем гибридный подход: внешние клиенты (frontend, mobile) общаются через REST/JSON, внутренние сервисы — через gRPC. API Gateway (Kong или Envoy) преобразует REST → gRPC и обратно.
Frontend ──REST/JSON──> API Gateway ──gRPC──> Order Service
Mobile ──REST/JSON──> (Envoy) ──gRPC──> Product Service
3rd Party ─REST/JSON──> ──gRPC──> Payment Service
──gRPC──> Notification Service
Envoy + трансодирование gRPC-HTTP/1.1 — стандартный паттерн. Это даёт лучшее из обоих миров: фронтенд видит привычный REST API, а внутренние сервисы общаются через быстрый бинарный протокол.
Мой вердикт
Для внешних API (фронтенд, мобильные приложения, сторонние сервисы) — REST. Человекочитаемость, простота отладки, универсальность, HTTP-кэширование. Не заставляйте фронтенд-разработчиков устанавливать protobuf-компилятор.
Для внутреннего общения между микросервисами — gRPC. Контракт, производительность, стриминг, типобезопасность. На 10+ сервисах разница в задержках и пропускной способности ощущается.
Если у вас 2–3 микросервиса с низкой частотой общения — REST достаточен. Не усложняйте архитектуру gRPC ради «потенциальной» производительности. Добавляйте gRPC там, где есть реальная потребность: высокочастотные вызовы, стриминг, подход «контракт прежде всего».
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье