微服务通信别只看性能:REST 与 gRPC 的 6 个选型判断和一次 502 故障复盘
不少选型表会把 REST 写成 HTTP/1.1、JSON、不支持流式传输,再把 gRPC 写成 HTTP/2、Protobuf、天然高性能。这个对比方便记忆,但不够严谨。
REST 是一种架构风格,通常使用 HTTP 和 JSON,但并不限定只能使用 HTTP/1.1,也不强制 JSON。REST API 可以运行在 HTTP/2 上,也可以通过分块响应、SSE 等方式传输流式数据。
gRPC 通常基于 HTTP/2,默认使用 Protobuf,支持一元调用、服务端流、客户端流和双向流。它有明确的接口契约和代码生成机制,但这不意味着换成 gRPC,系统就一定更快。
序列化只是一次请求成本的一部分。数据库查询、网络跨区、连接池等待、重试、日志写入,往比 JSON 和 Protobuf 的差异更大。没有基准测试就宣称“快多少倍”,基本是在猜。
我更愿意把核心观点概括成一句话:
协议选型决定的是协作边界,而稳定性取决于这条边界有没有被完整治理。
REST 的优势是开放、直观、调试工具丰富。浏览器、移动端、第三方系统和不同语言的客户端,都能比较容易地接入。
gRPC 的优势是契约明确、类型约束强、连接复用效率高,尤其适合高频内部调用、低延迟链路和持续流式传输。
两者没有谁天然取代谁,关键是服务边界在哪里。
先问清楚这 6 个问题,再讨论用什么协议
调用方是不是你能控制的
如果接口要开放给浏览器、合作伙伴或第三方系统,我通常优先考虑 REST。
浏览器不能像后端程序那样直接使用完整的原生 gRPC 客户端,往需要 gRPC-Web 和 Envoy、Nginx 等代理配合。不同实现对客户端流和双向流的支持也有差异,整个接入链路会更复杂。
内部服务的语言、部署方式和版本节奏都由团队控制时,gRPC 的代码生成和强类型契约就更有价值。
“内部 gRPC、外部 REST”是常见做法,但它只是经验,不是强制规则。内部管理平台如果调用频率很低、排障依赖 curl,REST 可能反而更省事。
接口是资源操作,还是高频过程调用
REST 更适合表达资源:
GET /api/v1/users/123
POST /api/v1/orders
PUT /api/v1/orders/1001
DELETE /api/v1/sessions/abc路径、方法和状态码就能表达大部分语义,接口也便于缓存、审计和人工调试。
gRPC 更像明确的过程调用:
syntax = "proto3";
package inventory.v1;
service InventoryService {
rpc GetStock(GetStockRequest) returns (GetStockResponse);
rpc BatchGetStock(BatchGetStockRequest) returns (BatchGetStockResponse);
rpc WatchStock(WatchStockRequest) returns (stream StockEvent);
}
message GetStockRequest {
string sku_id = 1;
}
message GetStockResponse {
string sku_id = 1;
int64 available = 2;
int64 reserved = 3;
}这里最有价值的不是代码少,而是调用双方共享同一份契约。字段类型、RPC 名称和流式方向都能在编译或联调阶段被检查。
是否真的需要流式传输
日志订阅、指标推送、设备状态同步、大批量数据持续传输,比较适合 gRPC 流式调用。
普通的用户查询、订单创建、配置读取,即使 QPS 很高,也不代表必须使用流式接口。一元 gRPC 调用已经能复用 HTTP/2 连接,不需要为了“听起来高级”把所有接口改成流。
REST 同样可以通过 SSE 或分块响应实现服务端持续推送。选型时要比较客户端环境、代理支持、断线重连和背压处理,不能只看协议功能表。
团队能不能维护契约
gRPC 的强类型是一种收益,也是一种约束。
删除字段后,字段编号不能交给新字段重复使用。更稳妥的写法是保留编号和名称:
message User {
string id = 1;
string name = 2;
reserved 3;
reserved "email";
}新增字段通常比修改字段类型安全。上线时还要验证“新客户端调用旧服务端”和“旧客户端调用新服务端”两种组合。
如果团队没有 Protobuf 兼容性检查、生成代码版本管理和多语言发布流程,gRPC 很容易从强契约变成强耦合。
链路中的代理是否真正支持
gRPC 依赖端到端正确处理 HTTP/2。客户端、网关、Ingress、服务网格和服务端,只要有一段配置错误,就可能出现 502、RST_STREAM、连接重置或 Unavailable。
REST 对现有 HTTP 基础设施通常更友好,抓包、curl、日志检索也更直观。gRPC 并非不可观测,但需要提前接入指标、分布式追踪和状态码统计。
业务能承受怎样的失败语义
无论选 REST 还是 gRPC,都必须回答几个问题:
- 单次调用最多等待多久?
- 请求失败后能否安全重试?
- 写请求有没有幂等键?
- 下游变慢时怎样隔离?
- 谁负责把错误转换成对外语义?
gRPC 的 deadline 会沿调用链传递,但前提是应用正确设置并处理。REST 同样可以通过客户端超时和请求头传递剩余时间。
没有超时预算的重试,只会把一次慢请求放大成一场流量风暴。
接口能调用,不代表已经适合上线
REST 接口最基本的只读验证,可以从状态码、响应头和耗时开始:
curl --silent --show-error \
--output /tmp/user-response.json \
--write-out 'status=%{http_code} total=%{time_total}s\n' \
--connect-timeout 2 \
--max-time 5 \
'http://user-service.default.svc.cluster.local:8080/api/v1/users/123'
jq . /tmp/user-response.json这是只读检查,前提是目标 URL 本身为查询接口。重点看 status 和 total:2xx 表示 HTTP 层成功,但仍要检查业务错误字段;连接超时通常指向 DNS、路由、Service 或端口;总耗时过高则继续拆解连接、服务端处理和下游等待。
不要拿创建订单、扣库存之类的写接口做探测,否则一次排查可能制造新的业务数据。
gRPC 可以使用 grpcurl 验证。生产环境若没有启用 Server Reflection,应使用经过版本管理的 .proto 文件,而不是为了方便临时打开反射。
# 只读:查看工具版本
grpcurl -version
# 只读:仅在服务支持标准 gRPC Health Checking Protocol 时执行
grpcurl \
-connect-timeout 2 \
-max-time 5 \
-plaintext \
-d '{"service":"inventory.v1.InventoryService"}' \
inventory-service.default.svc.cluster.local:9090 \
grpc.health.v1.Health/Check-plaintext 仅适用于集群内明确使用 h2c 的场景。服务启用 TLS 时应移除该参数并配置受信任的 CA;不要为了跳过证书验证直接把 -insecure 当成生产标准。
正常结果通常包含:
{
"status": "SERVING"
}SERVING 只能说明健康检查通过,不代表某个业务 RPC 一定正常。若出现 DeadlineExceeded,要检查客户端期限、服务端处理耗时和下游依赖;若是 Unavailable,优先检查端口、协议、代理和连接状态;若返回 Unimplemented,则要核对服务名、方法名以及服务端版本。
Kubernetes 中还要确认 Service 到 Pod 的端口映射。下面这些都是只读检查:
kubectl -n production get svc inventory-service -o yaml
kubectl -n production get endpointslices \
-l kubernetes.io/service-name=inventory-service -o wide
kubectl -n production get pods -l app=inventory -o wide
kubectl -n production logs deploy/order-service \
--since=15m --tail=300重点核对 port、targetPort、EndpointSlice 中的实际端口,以及 Pod 是否全部 Ready。Service 有 ClusterIP、DNS 能解析,不代表流量一定进入了正确的应用端口。
一次 502 故障,根因藏在 targetPort 里
下面用一个脱敏后的典型场景说明,时间、名称和数据均为示例数据。
22:10,库存服务上线 gRPC 接口。原来的 REST 服务监听 8080,新增加的 gRPC Server 监听 9090。订单服务也同步发布,将库存查询从 HTTP 调用切换为 gRPC。
22:13,监控显示订单创建接口错误率升到 38%,P99 从 420 毫秒升到 2.8 秒。订单服务日志出现:
rpc error: code = Unavailable desc =
connection error: desc = "error reading server preface:
http2: frame too large"库存 Pod 的 CPU 只有 24%,内存和 GC 都正常。最初有人判断是 HTTP/2 连接数不足,也有人怀疑服务网格对 gRPC 支持不好。
我们先保留现场,没有重启 Pod。重启可能暂时改变连接状态,却会清掉部分现场信息,还可能让健康实例一起退出。
只读检查显示,订单 Pod 能解析库存 Service,TCP 端口也能连接。这一度让人认为网络没问题。但“端口能连通”只证明有进程接收连接,不能证明接收连接的是 gRPC Server。
继续查看 Service 配置:
apiVersion: v1
kind: Service
metadata:
name: inventory-service
namespace: production
spec:
selector:
app: inventory
ports:
- name: grpc
port: 9090
targetPort: 8080关键证据出现了:客户端连接 Service 的 9090,Service 却把流量转发到 Pod 的 8080。这个端口运行的是 REST HTTP Server。gRPC 客户端期待 HTTP/2 Server Preface,收到的却是普通 HTTP 响应,于是报出协议帧错误。
问题绕了一圈,最后还是回到了一个不起眼的端口配置。
当时的止损措施是将订单服务流量切回已经验证过的 REST 调用版本。回滚属于有状态变更,会影响在线流量,执行前保存当前 Deployment、Service 清单和发布版本,并确认上一版本镜像仍然可用:
# 只读:保存现场配置
kubectl -n production get deploy order-service -o yaml \
> order-service-before-rollback.yaml
kubectl -n production get svc inventory-service -o yaml \
> inventory-service-before-fix.yaml
# 修改状态:只有确认上一版本健康后才能执行
kubectl -n production rollout undo deployment/order-service
# 只读:观察回滚进度
kubectl -n production rollout status deployment/order-service \
--timeout=180srollout undo 会修改线上工作负载,不能因为命令短就直接执行。回滚期间要盯住错误率、P99、Pod Ready 数和库存调用成功率;如果上一版本也异常,应停止继续放量,而不是反复回滚。
流量恢复后,我们在测试命名空间修正 targetPort,验证通过再进入生产变更:
apiVersion: v1
kind: Service
metadata:
name: inventory-service
namespace: production
spec:
selector:
app: inventory
ports:
- name: grpc
port: 9090
targetPort: 9090这项修改会改变 Service 的转发目标。变更前必须备份旧 YAML,小范围验证时至少执行健康检查、一个只读业务 RPC 和持续数分钟的低流量压测。异常时立即恢复旧清单:
kubectl apply -f inventory-service-before-fix.yaml修复完成后,我们把 HTTP 与 gRPC 端口分开命名,在 CI 中校验 Service 的 targetPort 是否与容器声明一致,并在发布流水线增加 gRPC 健康检查。监控也从“只有 HTTP 5xx”扩展为按服务、方法和 gRPC 状态码统计调用次数、延迟与失败率。
临时恢复不等于选型正确
这次故障不是 gRPC 本身不稳定,而是迁移过程没有验证完整链路。即使端口改对了,还要处理超时、重试和错误映射。
我通常给一条同步调用链分配明确的延迟预算。假设入口请求总预算为 800 毫秒,不能给三个下游各配置 800 毫秒,否则最差耗时会层累积。
配置值没有通用答案,应根据基线数据确定。下面只是示意:
rpc:
inventory:
deadline: 250ms
retry:
maxAttempts: 2
perAttemptTimeout: 100ms
retryableCodes:
- UNAVAILABLE这会修改客户端行为,不能照抄进生产。上线前要确认写操作是否幂等、调用次数是否可能翻倍、100 毫秒是否覆盖正常 P99,以及应用重试和服务网格重试是否重复开启。
DeadlineExceeded 不等于服务端一定停止了工作。客户端超时后,服务端可能已经完成写入。如果没有幂等键,再次重试创建、扣减或付款请求,就可能产生重复数据。
对外 REST 状态码与内部 gRPC 状态码也不要机械地一转换。例如,InvalidArgument 可以映射为 400,NotFound 可以映射为 404,但 Unavailable 更适合映射为 503;具体还要结合业务语义和是否允许客户端重试。
这份选型与上线清单,建议保存
- 不要因为“Protobuf 是二进制”就直接认定 gRPC 更快。务必用真实报文、并发量、连接方式和完整调用链做基准测试,否则测到的可能只是压测工具差异。
- 不要把 REST 固定理解成 HTTP/1.1 和非流式通信。协议版本、数据格式与流式能力需要根据实际实现判断。
- 外部接口不要贸然暴露原生 gRPC。务必确认浏览器、合作方 SDK、代理和调试能力,否则接入成本会转移给调用方。
- 删除 Protobuf 字段后千万别复用编号。使用
reserved保留编号和名称,避免旧客户端把新数据解释成错误字段。 - 禁止用写接口做连通性探测。健康检查和发布验证应使用无副作用的方法,防止排障期间制造订单、扣减库存或重复任务。
- 务必同时检查 Service
port、targetPort、EndpointSlice 和进程监听端口。DNS 正常、TCP 可达并不能证明协议正确。 - 不要同时在客户端、SDK、Sidecar 和网关开启无上限重试。多层重试会呈乘法放大,下游越慢,收到的请求反而越多。
- gRPC 调用务必设置 deadline,并让下游感知剩余时间。没有截止时间的请求可能长期占用连接、线程和内存。
- 修改代理、Service 或超时配置前必须保存原配置。先在单 Pod、测试命名空间或小流量环境验证,同时写清楚回滚命令。
- 故障现场不要只截一张监控图。至少保留调用时间、请求 ID、版本、Pod、错误码、延迟、代理日志和配置快照,复盘才有证据链。
- 上线前务必验证新旧客户端与新旧服务端的兼容组合。只测“新对新”,很容易在滚动发布期间暴露问题。
- 不要只监控 HTTP 5xx。gRPC 的
OK、Unavailable、DeadlineExceeded、ResourceExhausted等状态码必须分开统计。
REST 与 gRPC 的选择,本质上不是一道单选题。开放边界、浏览器和第三方接入更看重通用性;受控的内部高频调用、强契约和流式场景更适合发挥 gRPC 的能力。同一个系统同时使用两者,完全正常。
现在就可以做一件事:选出一条最关键的服务调用链,把客户端、网关、Service、Pod 监听端口、超时和重试配置画在一张图上,再用 curl 或 grpcurl 逐段验证。很多隐藏问题,在真正出故障前就能被发现。
如果这篇文章帮你理清了 REST 与 gRPC 的选型边界,欢迎点个赞和在看,也可以转发给正在做微服务改造的同事。后面我还会继续分享 Linux、云原生、容器、故障排查和稳定性建设中的生产实践,欢迎关注。
公众号:耕云躬行录
个人博客:躬行笔记