运维知识
悠悠
2026年9月19日

微服务通信别只看性能: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 本身为查询接口。重点看 statustotal: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

重点核对 porttargetPort、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=180s

rollout 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 porttargetPort、EndpointSlice 和进程监听端口。DNS 正常、TCP 可达并不能证明协议正确。
  • 不要同时在客户端、SDK、Sidecar 和网关开启无上限重试。多层重试会呈乘法放大,下游越慢,收到的请求反而越多。
  • gRPC 调用务必设置 deadline,并让下游感知剩余时间。没有截止时间的请求可能长期占用连接、线程和内存。
  • 修改代理、Service 或超时配置前必须保存原配置。先在单 Pod、测试命名空间或小流量环境验证,同时写清楚回滚命令。
  • 故障现场不要只截一张监控图。至少保留调用时间、请求 ID、版本、Pod、错误码、延迟、代理日志和配置快照,复盘才有证据链。
  • 上线前务必验证新旧客户端与新旧服务端的兼容组合。只测“新对新”,很容易在滚动发布期间暴露问题。
  • 不要只监控 HTTP 5xx。gRPC 的 OKUnavailableDeadlineExceededResourceExhausted 等状态码必须分开统计。

REST 与 gRPC 的选择,本质上不是一道单选题。开放边界、浏览器和第三方接入更看重通用性;受控的内部高频调用、强契约和流式场景更适合发挥 gRPC 的能力。同一个系统同时使用两者,完全正常。

现在就可以做一件事:选出一条最关键的服务调用链,把客户端、网关、Service、Pod 监听端口、超时和重试配置画在一张图上,再用 curl 或 grpcurl 逐段验证。很多隐藏问题,在真正出故障前就能被发现。

如果这篇文章帮你理清了 REST 与 gRPC 的选型边界,欢迎点个赞和在看,也可以转发给正在做微服务改造的同事。后面我还会继续分享 Linux、云原生、容器、故障排查和稳定性建设中的生产实践,欢迎关注。

公众号:耕云躬行录

个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码