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.
Решение в одной таблице
| Характеристика | gRPC | REST с JSON | SignalR |
|---|---|---|---|
| Форма вызова | RPC точка-точка | Запрос/ответ точка-точка | Один производитель, много потребителей |
| Контракт | Обязателен, .proto | Необязателен, OpenAPI | Отсутствует, имена методов строками |
| Протокол | HTTP/2 (обязательно) | HTTP/1.1, HTTP/2, HTTP/3 | WebSockets, 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, параметры по умолчанию) | 116 | 116 | 100% |
| MessagePack (бинарный протокол хабов SignalR) | 66 | нет | 56.9% |
Protobuf (Google.Protobuf 3.35.1) | 60 | 65 | 51.7% |
| Вызов по JSON-протоколу хабов SignalR | нет | 165 | 142% |
Методика: каждое кодирование одних и тех же пяти полей было сериализовано с подсчётом байт, измерено на 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
- Внутренний вызов точка-точка, и вы владеете обоими репозиториями. Файл
.proto— это контракт, обе стороны генерируют код из него, и переименованное поле ломает сборку с обеих сторон в одном и том же pull request. В этом весь аргумент, и он стоит больше, чем подсчёт байт. - Вам нужны дедлайны, доходящие до вызываемой стороны. Дедлайн gRPC путешествует вместе с вызовом, поэтому сервис B знает, сколько ещё готов ждать сервис A, и может отказаться от собственного запроса к базе данных. У HTTP эквивалента нет: отмена запроса
HttpClientразрывает соединение и сервер видитHttpContext.RequestAborted, но ничто не сообщает серверу исходный бюджет времени. - Вызывающие стороны на разных языках. Сервис на Go или Python, потребляющий ваш
.proto, бесплатно получает настоящего клиента. Выдать той же команде документ OpenAPI и пожелать удачи — худший опыт. - Разговорчивые горячие пути. Как только двунаправленный поток открыт, сообщения идут по существующему запросу HTTP/2, вместо того чтобы платить за новый на каждый вызов. Руководство по производительности gRPC от Microsoft явно рекомендует это как продвинутую технику для путей с высокой пропускной способностью, с оговоркой, что
RequestStream.WriteAsyncне потокобезопасен и вам нуженChannel<T>для упорядочивания записей.
// .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
- Сервис вызывает всё, для чего вы не можете перегенерировать клиента. Браузеры вообще не говорят на gRPC, а gRPC-Web и транскодирование JSON — это реальные дополнения к топологии развёртывания. Если в ответе на вопрос “кто это вызывает” есть кто-то вне вашей сборки, отдавайте JSON.
- Вызов редкий. Ночная задача сверки, обращающаяся к одной конечной точке, не оправдывает файл
.proto, шаг генерации кода в CI и второй протокол в вашей service mesh. - Вы хотите отлаживать тем, что у вас уже есть. Protobuf в трафике непрозрачен без схемы. Ошибку 500 в три часа ночи проще диагностировать, когда запрос можно воспроизвести с помощью curl.
- Ваш балансировщик нагрузки работает на L4. Это не вопрос предпочтений, и он разбирается ниже.
// .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 действительно правильный выбор
- Один производитель, много долгоживущих потребителей, и каждому потребителю нужно одно и то же сообщение. Тики цен, состояние очереди задач, инвалидация конфигурации. Группы и широковещательная рассылка — вот те возможности, которые вы покупаете.
- Набор потребителей меняется во время выполнения. SignalR обрабатывает подключение, отключение и переподключение. Заново реализовать это поверх потоков gRPC — отдельный проект.
- Часть потребителей — браузеры. Если один и тот же поток данных нужен и панели мониторинга, и набору рабочих сервисов, один хаб обслуживает и тех и других, а никакая конфигурация gRPC не обслужит браузер без прокси.
.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 закрепляет каждый вызов за одним подом. Пятьдесят шесть байт на сообщение не стоят миграции платформы.
Связанные материалы
- gRPC в контейнерах кажется сложным в .NET 9 и .NET 10: 4 ловушки, которые можно устранить
- Клиенты SignalR наконец могут отменить выполняющийся метод хаба в .NET 11 Preview 6
- Как настроить Kestrel для обслуживания HTTP/3 в ASP.NET Core 11
- Polly vs обработчики устойчивости в .NET 11: что использовать?
- Minimal API vs контроллеры в ASP.NET Core 11
- Как использовать OpenTelemetry с .NET 11 и бесплатным бэкендом
Источники
- Compare gRPC services with HTTP APIs, Microsoft Learn
- Performance best practices with gRPC, Microsoft Learn
- Overview of ASP.NET Core SignalR, Microsoft Learn
- What’s new in ASP.NET Core in .NET 11, Microsoft Learn
- Grpc.AspNetCore 2.83.0, NuGet
- SignalR Hub Protocol specification, dotnet/aspnetcore
- gRPC over HTTP/2 protocol specification, grpc/grpc
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.