Start Debugging

gRPC vs REST vs SignalR для вызовов между сервисами в .NET 11

Для внутренних вызовов между сервисами в .NET 11 по умолчанию выбирайте gRPC, когда вы владеете обеими сторонами контракта и вызов идёт точка-точка. Используйте REST с JSON, когда сервис должен вызывать что-то, что вы не контролируете. SignalR не является RPC-транспортом между сервисами: обращайтесь к нему только тогда, когда один производитель должен разослать сообщение многим долгоживущим потребителям.

Если сервис A вызывает сервис B и больше никто B не вызывает, используйте gRPC. Вы владеете обеими сторонами, поэтому сгенерированный клиент и бинарный контракт не стоят вам ничего, а дают полезную нагрузку примерно вдвое меньше JSON-эквивалента плюс настоящую передачу дедлайнов. Используйте REST с JSON, как только сервис должен вызывать что-то, что вы не контролируете: браузер, партнёра, команду curl в инструкции по эксплуатации. SignalR здесь выбивается из ряда, и самая частая ошибка в этом сравнении — считать его третьим вариантом RPC. Это не так. SignalR — это слой управления соединениями и рассылки, и он оправдывает своё место только тогда, когда один производитель должен отправлять сообщения многим долгоживущим потребителям. Всё изложенное ниже нацелено на .NET 11 (Preview 6, SDK 11.0.100-preview.6.26359.118, GA ожидается в ноябре 2026 года) и C# 14, с Grpc.AspNetCore 2.83.0.

Решение в одной таблице

ХарактеристикаgRPCREST с JSONSignalR
Форма вызоваRPC точка-точкаЗапрос/ответ точка-точкаОдин производитель, много потребителей
КонтрактОбязателен, .protoНеобязателен, OpenAPIОтсутствует, имена методов строками
ПротоколHTTP/2 (обязательно)HTTP/1.1, HTTP/2, HTTP/3WebSockets, SSE, long polling
Полезная нагрузкаProtobuf, бинарнаяJSON, текстJSON или MessagePack
КлиентГенерируется из .protoНаписан вручную или сгенерирован из OpenAPIНаписан вручную, строки для имён методов
СтримингКлиентский, серверный, двунаправленныйСерверный (chunked / SSE)Серверный, клиентский, двунаправленный
Отмена от вызывающего доходит до вызываемогоДа, плюс встроенный дедлайнТолько как разрыв соединенияДа начиная с .NET 11, для вызовов без стриминга
Можно вызвать из браузераНет, нужен gRPC-Web или транскодированиеДаДа, в этом и смысл
Работает за балансировщиком L4ПлохоДаНужны sticky-сессии или backplane
Читаемо человеком в трафикеНетДаДа с JSON, нет с MessagePack
Входит в состав ASP.NET CoreНет, отдельный пакет NuGetДаДа

Две строки решают почти каждый реальный случай. “Форма вызова” отделяет SignalR от двух остальных, а “контракт” отделяет gRPC от REST. Если вы взвешиваете строки ниже по таблице, скорее всего вы уже приняли решение и ищете подтверждения.

Почему SignalR постоянно попадает в это сравнение и почему обычно проигрывает

SignalR появляется в поисковых запросах про взаимодействие сервисов потому, что метод хаба выглядит в точности как RPC:

// .NET 11, C# 14 -- looks like RPC, is not built for it
public sealed class PricingHub : Hub
{
    public Task<decimal> GetPrice(string sku) => _pricing.LookupAsync(sku);
}

Вызывающая сторона вполне может выполнить InvokeAsync<decimal>("GetPrice", sku) из другого сервиса и получить ответ. Это работает. Однако то, что вы построили, — это RPC-канал поверх технологии, весь центр проектирования которой — управление временем жизни соединений для клиентов, которые приходят и уходят. Вы наследуете издержки этого проектирования, не нуждаясь ни в одном из его преимуществ.

Конкретные издержки: имена методов — это строки, разрешаемые рефлексией во время диспетчеризации, поэтому переименование даёт ошибку во время выполнения, а не ошибку сборки. Схемы нет, поэтому ничто не генерирует клиента и ничто не проверяет форму полезной нагрузки. Горизонтальное масштабирование означает, что каждый сервер в пуле должен дотянуться до каждого соединения, а это требует Redis backplane или Azure SignalR Service плюс sticky-сессии, если вы не на WebSockets. И соединение с хабом имеет состояние: вызывающей стороне теперь нужно рассуждать о конечном автомате переподключения там, где раньше был запрос без состояния.

SignalR — правильный ответ, когда трафик действительно представляет собой рассылку многим. Сервис цен, который должен отправлять обновления котировок сорока рабочим процессам, — это задача для SignalR, потому что у SignalR есть группы, широковещательная рассылка и backplane, а у gRPC нет ничего из этого. Собственное сравнение gRPC и HTTP API от Microsoft говорит об этом прямо: gRPC поддерживает стриминг, но не имеет понятия рассылки зарегистрированным соединениям, поэтому каждый вызов gRPC вынужден передавать данные своему клиенту по отдельности.

Различие — в рассылке многим, а не в “реальном времени”. Двунаправленный стриминг gRPC работает в реальном времени. Он просто точка-точка.

Что каждый вариант реально кладёт в трафик

Аргумент производительности в пользу gRPC обычно формулируют как “Protobuf меньше JSON” без единой цифры. Вот цифра для сообщения в форме типичного внутреннего ответа:

// proto3
message OrderStatus {
  string order_id   = 1;  // "8f14e45f-ceea-467a-9c1d-2b7f2f0c3a11"
  int32  status     = 2;  // 3
  int64  updated_at = 3;  // 1786060800
  double total      = 4;  // 129.95
  string currency   = 5;  // "EUR"
}
КодированиеБайт в сообщенииБайт с обрамлениемДоля от JSON
JSON (System.Text.Json, параметры по умолчанию)116116100%
MessagePack (бинарный протокол хабов SignalR)66нет56.9%
Protobuf (Google.Protobuf 3.35.1)606551.7%
Вызов по JSON-протоколу хабов SignalRнет165142%

Методика: каждое кодирование одних и тех же пяти полей было сериализовано с подсчётом байт, измерено на Windows 11 со средой выполнения .NET 10.0.5 (SDK 10.0.201), Google.Protobuf 3.35.1 и MessagePack 3.1.8. Форматы передачи специфицированы независимо от версии среды выполнения, поэтому на .NET 11 количество байт идентично; отличается только среда выполнения, выполняющая кодирование. “Байт с обрамлением” добавляет пятибайтовый префикс длины gRPC (один байт флага сжатия плюс четыре байта длины в big-endian) и, для SignalR, JSON-конверт вызова плюс разделитель записей 0x1E.

Прочитайте эту таблицу внимательно, прежде чем что-либо ею обосновывать. Protobuf экономит 56 байт на сообщении в 116 байт. На сервисе, обрабатывающем десять тысяч вызовов в секунду, это 560 КБ/с исходящего трафика, что важно, если вы платите за трафик между зонами, и является шумом, если не платите. Интересна строка SignalR: конверт JSON-протокола хабов делает один вызов больше, чем простой REST-эквивалент, потому что вы платите за type, target и arguments вдобавок к полезной нагрузке. Перевод хаба на MessagePack возвращает большую часть этого, ценой человеческой читаемости, которая и была причиной рассматривать текстовый протокол.

Размер сериализации к тому же — самое слабое из преимуществ gRPC. Более сильные — сгенерированный клиент и дедлайн.

Когда выбирать gRPC

// .NET 11, C# 14 -- Grpc.AspNetCore 2.83.0
// Server
builder.Services.AddGrpc();
app.MapGrpcService<OrderService>();

// Client: register through the factory so channels are reused.
builder.Services
    .AddGrpcClient<Orders.OrdersClient>(o => o.Address = new Uri("https://orders"))
    .AddStandardResilienceHandler();

// Call site: the deadline is the point.
var reply = await client.GetStatusAsync(
    new OrderRequest { OrderId = id },
    deadline: DateTime.UtcNow.AddSeconds(2),
    cancellationToken: ct);

В коде приложения используйте AddGrpcClient, а не GrpcChannel.ForAddress. Создание канала на каждый вызов каждый раз заставляет открывать новый сокет, выполнять TCP-рукопожатие, согласование TLS и преамбулу соединения HTTP/2, а фабрика переиспользует канал за вас. Если вы накладываете сверху повторные попытки, здесь применим тот же обработчик устойчивости, который оборачивает HttpClient, потому что канал gRPC внутри — это SocketsHttpHandler.

Когда выбирать REST с JSON

// .NET 11, C# 14 -- minimal API + typed client
app.MapGet("/orders/{id}", async (string id, IOrderStore store, CancellationToken ct)
    => await store.FindAsync(id, ct) is { } o
        ? Results.Ok(o)
        : Results.NotFound());

// Caller
builder.Services
    .AddHttpClient<OrdersClient>(c => c.BaseAddress = new Uri("https://orders"))
    .AddStandardResilienceHandler();

Для чего-то более структурированного возврат типизированного объединения Results даёт проверку форм ответа во время компиляции и корректный документ OpenAPI без написанных вручную атрибутов, что возвращает часть контрактной дисциплины, делавшей gRPC привлекательным.

Когда SignalR действительно правильный выбор

.NET 11 заметно улучшает SignalR для долгоживущих соединений по двум направлениям. Конечная точка /refresh вместе с EnableAuthenticationRefresh означает, что соединение с хабом больше не обрывается при истечении bearer-токена, а это был крупнейший единичный источник ложных переподключений в развёртываниях с аутентификацией по токену. И клиенты SignalR наконец могут отменить выполняющийся метод хаба, поэтому отмена CancellationToken, переданного в InvokeAsync, действительно доходит до сервера. Обе возможности в Preview 6 доступны только для клиента .NET; поддержка JavaScript-клиента и Azure SignalR Service ещё в работе.

Детали, которые решают за вас

Балансировщики L4 ломают gRPC. Канал gRPC — это одно соединение HTTP/2, и каждый вызов мультиплексируется поверх него. Балансировщик L4 распределяет TCP-соединения, поэтому каждый вызов этого канала навсегда попадает на один и тот же бэкенд. В вашем парке появляется один горячий экземпляр и множество простаивающих. Исправление означает клиентскую балансировку нагрузки или прокси L7, такой как Envoy, Linkerd или YARP, и это решение обычно принадлежит платформенной команде, а не вам. Если вы не можете внести это изменение, сравнение закончено и побеждает REST. Тот же класс инфраструктурного трения возникает при запуске gRPC в контейнерах, где прокси, говорящий только на HTTP/1.1, порождает сбои, совершенно не похожие на несовпадение протоколов.

gRPC выпускается вне цикла .NET, и список TFM это доказывает. Grpc.AspNetCore 2.83.0, опубликованный 2026-08-03, нацелен на net8.0, net9.0 и net10.0. Целевой платформы net11.0 нет, и в примечаниях к выпуску Что нового в ASP.NET Core в .NET 11 вообще нет раздела про gRPC. Это не пробел в поддержке: сборка net10.0 загружается и работает на .NET 11. Это разница в ритме выпусков. gRPC на .NET поддерживается в grpc/grpc-dotnet по собственному графику релизов, поэтому возможность .NET 11, полезная для gRPC, появится тогда, когда её выпустит grpc-dotnet, а не в ноябре. Планируйте заметки об обновлении соответственно.

HTTP/2 обязателен для gRPC и необязателен для всего остального. Это реальное ограничение на любом участке, где вы не контролируете посредников. Это также означает, что gRPC сегодня не выигрывает от HTTP/3, тогда как конечная точка REST выигрывает: настройка Kestrel для обслуживания HTTP/3 — это однострочное изменение конечной точки, а Kestrel в .NET 11 теперь начинает обрабатывать запросы HTTP/3, не дожидаясь управляющего потока и кадра SETTINGS, сокращая задержку первого запроса на новых соединениях.

Горизонтальное масштабирование SignalR — это зависимость, а не настройка. Более одного экземпляра сервера означает Redis backplane или Azure SignalR Service, а транспортам, отличным от WebSocket, дополнительно нужны sticky-сессии. Сравните это с конечной точкой REST без состояния за балансировщиком round-robin, прежде чем решить, что рассылка многим того стоит.

Наблюдаемость не одинакова. Все три испускают трассировки ActivitySource, которые проходят через OpenTelemetry, поэтому подключение трассировок к бесплатному бэкенду покрывает их все. Отличается то, что вы видите в сетевом дампе: JSON читаем, Protobuf и MessagePack требуют схемы и инструментов.

Рекомендация, повторно

Проведите границу сначала по рассылке многим. Если сервис должен уведомлять множество долгоживущих потребителей, это SignalR, и ни один из двух других вариантов не заменяет группы и backplane. Всё остальное — точка-точка, и там вопрос в том, кому принадлежит контракт. Если вы владеете обеими сторонами и можете перегенерировать клиентов в том же pull request, который меняет схему, gRPC окупается за счёт сгенерированного клиента и переданных дедлайнов, а меньшая полезная нагрузка — бонус, а не причина. Если сервис вызывает кто-то вне вашей сборки, отдавайте REST с JSON и перестаньте оптимизировать байты, за которые вы не платите.

Режим отказа, которого стоит избегать, — выбрать gRPC для сервиса с тремя вызовами в минуту, потому что бенчмарк показал 51.7% размера полезной нагрузки, а затем обнаружить, что ваш балансировщик L4 закрепляет каждый вызов за одним подом. Пятьдесят шесть байт на сообщение не стоят миграции платформы.

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад