REST vs gRPC 选型对比与迁移实践:从原理到落地
微服务做接口,选 REST 还是 gRPC?这篇文章不站队,给你一张决策表和一条能直接落地的迁移路径。
一、为什么现在要重新聊这个话题
很多团队从单体拆成微服务后,第一反应还是"全部用 REST + JSON"------因为熟悉、好调试、浏览器直接能调。
但很快会撞上三堵墙:
- 性能墙:内部服务高频调用,JSON 序列化 + HTTP/1.1 短连接/队头阻塞,吞吐上不去。
- 契约墙:接口字段靠口头约定或 Swagger 文档,字段一改就"悄悄漂移",联调全靠猜。
- 流式墙:需要服务端推送、双向实时通信(日志、行情、AI 生成式输出)时,REST 只能靠轮询或 WebSocket 另起炉灶。
gRPC 正是为解决这些问题而生,但它也有自己的代价。下面从原理到落地一次讲清。
二、本质区别:不只是"快一点"
REST 和 gRPC 表面都是"远程调用",但底层设计哲学完全不同。
| 维度 | REST | gRPC |
|---|---|---|
| 传输协议 | HTTP/1.1(可升级 HTTP/2) | 强制 HTTP/2 |
| 数据格式 | JSON(文本) | Protobuf(二进制) |
| 接口契约 | 松散,靠 OpenAPI/Swagger 描述 | 强类型,.proto IDL 生成代码 |
| 调用模式 | 请求/响应 | 一元 + 服务端流 + 客户端流 + 双向流 |
| 浏览器支持 | 原生 | 需 gRPC-Web(能力受限) |
| 调试成本 | curl 直接调 | 需 grpcurl / grpcui 等工具 |
| 生态工具 | 极成熟,缓存/CDN 友好 | 云原生内成熟,对公网较弱 |
关键差异可以拆成四层理解:
1. 协议层:HTTP/2 是隐藏的胜负手
gRPC 强制 HTTP/2,带来两个直接好处:
- 多路复用:一个 TCP 连接上并发跑多个请求,不再受 HTTP/1.1 队头阻塞(Head-of-Line Blocking)拖累。
- 头部压缩(HPACK):重复的头字段只传一次,长连接下开销极低。
REST 在 HTTP/1.1 下每个请求要么新建连接(握手成本),要么排队等前面的慢请求,这也是它高并发下"越压越慢"的根源之一。
2. 序列化层:JSON 是"重"不是"笨"
JSON 文本对人类友好,但对机器是负担:
- 字段名重复传输(每条消息都带
"user_name":)。 - 解析是 CPU 密集操作,反射 + 字符串处理远慢于 Protobuf 的二进制读取。
实测中,同样的业务数据,gRPC 的吞吐通常是 REST+JSON 的 5~10 倍,消息体积可小一个数量级。对于内部高频、小消息场景,差距尤其明显。
3. 契约层:从"文档"到"代码"
这是架构层面最重要的差异。
- REST 的契约是"文档先行":Swagger/OpenAPI 写了,但没人保证实现和文档一致,字段悄悄改、悄悄坏。
- gRPC 的契约是"代码先行":
.proto定义即真理,protoc直接生成强类型客户端/服务端桩(stub)。字段名错了编译期就报错,而不是运行时才发现。
4. 流式层:gRPC 的原生能力
gRPC 原生支持四种调用:
text
一元调用 : 1 请求 → 1 响应
服务端流式 : 1 请求 → N 响应(如日志订阅)
客户端流式 : N 请求 → 1 响应(如批量上传)
双向流式 : N 请求 ↔ N 响应(如实时对话、行情推送)
这是 REST + JSON 无法自然表达的能力,也是 AI/实时场景选 gRPC 的核心理由。
三、性能差距到底有多大
不堆砌数据,说结论性的方向(不同压测环境数值会有差异,但趋势一致):
- 吞吐:gRPC 通常比 REST+JSON 高 5~10 倍,Protobuf 编解码耗时约为 JSON 的 1/6 ~ 1/10。
- 延迟:长连接复用 + 二进制,端到端延迟显著更低,尤其在需要大量小请求的场景。
- CPU:JSON 序列化是高 CPU 占用大户,换成 Protobuf 后服务端 CPU 明显下降,同等资源能扛更多请求。
- 带宽:二进制消息体积更小,内网节省不明显,但跨地域/跨机房时有实际意义。
一句话:瓶颈在"调用频率"和"消息数量"时,gRPC 优势最大;瓶颈在网络带宽本身时,两者差距收窄。
四、选型决策:给你一张能直接用的表
没有银弹,只有"场景合适"。建议按以下规则判断:
优先用 gRPC 的场景:
- 服务到服务(内部)高频、低延迟调用。
- 需要强类型契约、多语言团队协作。
- 需要流式传输(推送、实时、AI 输出)。
- 消息量大、对序列化 CPU 敏感。
- 已经拥抱云原生(K8s + Service Mesh)的体系。
优先用 REST 的场景:
- 对外开放的公共 API,需要第三方集成。
- 浏览器/前端直接调用(REST 原生友好)。
- 需要简单调试、快速联调、curl 一把梭。
- 依赖 HTTP 缓存、CDN、网关鉴权等成熟中间件。
- 团队对 gRPC 陌生、维护能力有限。
最佳实践其实是"混合":
内部微服务间用 gRPC,对外暴露的接口保留 REST,通过 grpc-gateway 自动桥接------下面重点讲这条路径。
五、迁移实践:从 REST 到 gRPC 的正确姿势
不建议"推倒重来",推荐绞杀者模式(Strangler Fig):新能力长在 gRPC 上,老接口逐步收编,网关层做协议转换,让两套协议长期共存。
5.1 分阶段迁移路线
text
阶段 1:新服务直接上 gRPC,老服务维持 REST,网关统一对外
阶段 2:给老服务逐步补充 .proto 定义,暴露 gRPC 接口
阶段 3:用 grpc-gateway 自动生成 REST 反向代理,对外保持 HTTP/JSON 不变
阶段 4:内部流量全部走 gRPC,REST 仅作为对外的"翻译层"
阶段 5:业务稳定后,评估是否下线旧 REST 实现
核心原则:对外契约不破坏,对内通信逐步 gRPC 化。
5.2 grpc-gateway:一条 proto 同时服务两种协议
grpc-gateway 是这条路径的杀手锏:在 .proto 里用注解声明 HTTP 映射,一条定义自动生成 gRPC 服务 + REST 反向代理,避免维护两套代码。
第一步:定义 proto
protobuf
// hello.proto
syntax = "proto3";
package example.v1;
import "google/api/annotations.proto";
option go_package = "example.com/hello/v1;hellov1";
service HelloService {
rpc SayHello(SayHelloRequest) returns (SayHelloResponse) {
option (google.api.http) = {
get: "/v1/hello/{name}"
};
}
}
message SayHelloRequest {
string name = 1;
}
message SayHelloResponse {
string message = 1;
}
option (google.api.http) 这行就是 REST 映射的声明,{name} 自动绑定到 SayHelloRequest.name。
第二步:生成代码
bash
protoc -I . \
--go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
--grpc-gateway_out=. --grpc-gateway_opt=paths=source_relative \
hello.proto
一次生成:gRPC 服务桩、客户端桩、以及 REST 网关的 Handler。
第三步:实现 gRPC 服务
go
// server.go
type server struct {
hellov1.UnimplementedHelloServiceServer
}
func (s *server) SayHello(ctx context.Context, req *hellov1.SayHelloRequest) (*hellov1.SayHelloResponse, error) {
return &hellov1.SayHelloResponse{
Message: "hello, " + req.GetName(),
}, nil
}
第四步:同时启动 gRPC 端口和 REST 网关端口
go
// main.go(示意)
lis, _ := net.Listen("tcp", ":50051")
grpcServer := grpc.NewServer()
hellov1.RegisterHelloServiceServer(grpcServer, &server{})
go grpcServer.Serve(lis) // gRPC 端口 :50051
// REST 网关端口 :8080,把 HTTP/JSON 转成 gRPC 调用
mux := runtime.NewServeMux()
ctx := context.Background()
hellov1.RegisterHelloServiceHandlerServer(ctx, mux, &server{})
http.ListenAndServe(":8080", mux)
第五步:验证两种协议都能通
bash
# gRPC 调用
grpcurl -plaintext -d '{"name":"world"}' localhost:50051 example.v1.HelloService/SayHello
# REST 调用(等价于上面)
curl http://localhost:8080/v1/hello/world
两个请求打到同一个服务实现,这就是"一条定义、两种协议"。
5.3 迁移中的高频踩坑清单
这几个坑几乎是每个迁移团队都会踩的,提前记住能省一周:
1. 错误码映射要提前约定
gRPC 的 status code 和 HTTP 状态码不是一一对应,grpc-gateway 有默认映射,但业务错误语义要自己定清楚:
| gRPC Code | HTTP 状态 |
|---|---|
| OK | 200 |
| InvalidArgument | 400 |
| Unauthenticated | 401 |
| PermissionDenied | 403 |
| NotFound | 404 |
| Internal / Unknown | 500 |
业务自定义错误建议用 google.rpc.Status 或 error details 传递,避免只给一个笼统的 500。
2. 超时与 Deadline 传播
gRPC 的 deadline 会通过 context 在调用链上传播,REST 侧的 HTTP 超时要正确转换到 gRPC 的 context.WithTimeout,否则会出现"网关超时了、下游还在跑"的僵尸请求。
3. 元数据映射
HTTP Header 与 gRPC metadata 通过 Grpc-Metadata- 前缀映射:
text
HTTP Header: Grpc-Metadata-Authorization: Bearer xxx
对应 gRPC metadata: authorization = "Bearer xxx"
鉴权(JWT)统一走 metadata 传递,网关层负责从 HTTP Header 拆出来。
4. 字段命名与类型转换
- proto 的
snake_case字段会映射成 JSON 的lowerCamelCase(user_name→userName)。 int64/uint64在 JSON 里默认转成字符串,避免 JS 端精度丢失------前端同学要留意类型。
5. HTTP/2 与负载均衡的关系
gRPC 走 HTTP/2 长连接,传统四层 LB(L4)按连接转发会导致流量不均衡。要么用支持 HTTP/2 的七层 LB(L7),要么用 gRPC 客户端侧负载均衡(grpc.WithDefaultServiceConfig 或 Service Mesh)。这是把 gRPC 上 K8s 时最容易漏掉的一环。
6. 可观测性要跟上
REST 时代靠抓包 + curl 就能排查,gRPC 二进制 + 长连接难直接抓。提前接入链路追踪(OpenTelemetry)+ 拦截器记录 metadata 和耗时,否则线上排障会抓瞎。
六、总结与建议
一句话结论:
- 内部服务间通信 → gRPC,强契约 + 高性能 + 流式,长期收益远大于学习成本。
- 对外/浏览器/第三方 → REST,生态成熟、调试友好、缓存网关齐备。
- 想要两全 → grpc-gateway,一条 proto 同时服务两种协议,用绞杀者模式平滑迁移。
给你的行动清单:
- 选一个新建的内部服务先试 gRPC,跑通工具链和可观测性。
- 老服务逐个补
.proto,用 grpc-gateway 保持对外 REST 不变。 - 提前定好错误码映射、超时、鉴权传递三件事。
- 把 gRPC 的 LB 策略和链路追踪作为上线前置条件,别等出了事再补。
如果这篇对你有帮助,欢迎点赞收藏;你们团队在 REST→gRPC 迁移中踩过什么坑?评论区聊聊。