1. 先让四个概念各就各位:它们本来就不在同一层
上周处理接口方案时,同事拿着新需求来找我,开口就是一句:“这次我们是不是要把 JSON 换成 gRPC?”我愣了两秒,因为这句话里藏着一个典型的层级错位。JSON 是一种数据编码格式,gRPC 是一整套 RPC 调用框架;真正能和 JSON 放在同一个赛道上比较的是 Protobuf,而真正能和 gRPC 放在同一个赛道里比较的往往是 REST 这类接口风格,不是 JSON。这个错位如果不纠正,后面所有讨论都会变成鸡同鸭讲。
类似场景在团队里出现频率很高。大家聊着聊着,就把 HTTP、gRPC、Protobuf、JSON 四个词揉成一团,仿佛它们是四种可以互相替代的“接口方案”。实际上它们根本不是一回事,只是平时总绑在一起出现,导致很多人误以为它们是同层概念。要把关系理清楚,第一步不是背定义,而是先把每个名字放进它该待的位置。
1.1 HTTP 是运输协议,不管 body 里装什么
HTTP 最核心的身份是“应用层传输协议”。它规定了客户端和服务端之间怎么建立请求、怎么返回响应、请求头和响应头里放什么、状态码代表什么意思,以及在 HTTP/1.1 里文本形式的请求行和头部怎么组织、在 HTTP/2 里二进制的帧和流怎么复用。
但 HTTP 完全不关心 body 里装的是什么。你可以在 body 里放纯文本、放 JSON、放 XML、放 Protobuf 编码后的二进制字节流,也可以放图片、放视频分片。HTTP 协议本身的职责是“可靠地把一段数据从 A 送到 B”,并且让双方都能理解请求的意图,比如 GET 表示获取、POST 表示提交、PUT 表示整体替换。它就像一条公路,公路上跑小轿车、跑卡车、跑冷链车都行,交通规则负责的是车辆怎么并线、怎么让行,不关心车厢里拉的是服装还是生鲜。
很多人之所以把“JSON 改成 gRPC”这句话说出口,其实是把“HTTP+JSON 的 REST 接口”整体当成了一个叫“JSON”的东西,然后再把“gRPC+Protobuf”的接口整体叫成“gRPC”。真正发生改变的是整个调用风格和传输组合,而不是单纯换了一个数据格式。如果只把 body 从 JSON 换成 Protobuf、其他地方不动,那得到的是一个“HTTP+Protobuf”的接口,并不是 gRPC。
1.2 Protobuf 和 JSON 才是同一赛道的两个选手
如果非要在四个概念里找一对真正的竞争对手,那就是 Protobuf 和 JSON。它们回答的是同一个问题:一份结构化数据,比如一个用户对象、一个订单列表,到底用什么形式编码后放进网络包或文件里。
JSON 的方案是文本化、自描述。字段名原样写在数据里,字符串用引号包起来,对象用花括号,数组用方括号,层次结构肉眼可见。优点是调试方便、跨语言支持极广、人类可以直接阅读;缺点是同样的字段名要在每个数据条目里反复出现,体积上天然冗余,而且它没有内建类型系统,数字精度、日期时间这些语义全靠各方约定。
Protobuf 的方案是二进制化、契约先行。它不把字段名放进每个消息里,而是把字段名编成编号,在 .proto 文件里统一约定。序列化后的数据是一串紧凑的字节,字段编号和值按特定规则排列。同样的数据,体积通常比 JSON 小不少,编解码性能也通常更快。代价是二进制不可读,没有 .proto 契约或者对应的反序列化代码,你面对一串字节基本等于面对天书。
打个比方,JSON 是快递单上直接写着“收货人:张三,电话:138xxxx,地址:某市某区某路几号”,所有信息贴在包裹外面,谁路过都能看懂。Protobuf 则是包裹上只有一个编号“A001”,具体名单和地址存在快递公司的系统里,系统里一查编号才知道这包裹送给谁。自描述有自描述的好处,但它在数据量放大之后必然付出更多重复成本;契约化有契约化的好处,但前提是收寄双方都维护好那份“系统”。
1.3 gRPC 是整套服务调用框架,不是单纯的数据格式
gRPC 的身份比前三个更重。它不只是数据怎么编码,也不只是数据怎么传输,而是把“服务的方法定义”“客户端怎么调用”“服务端怎么实现”“消息怎么序列化”“流式传输怎么做”“错误怎么返回”“超时和取消怎么传递”全部打包在一起的一套框架。
gRPC 的起点是 .proto 文件。你在这个文件里定义服务接口,例如一个 UserService,里面有一个 GetUser 方法,方法接收 GetUserRequest,返回 User。然后通过 protoc 工具生成客户端桩和服务端骨架代码。客户端代码看起来就像调用本地方法一样,实际上请求会经过 gRPC 库处理后,通过 HTTP/2 发送给服务端。
“通过 HTTP/2”这几个字很关键。gRPC 并没有发明一套全新的网络协议,它是建立在 HTTP/2 之上的。HTTP/2 提供了多路复用、二进制分帧、头部压缩这些能力,gRPC 在此基础上定义了消息帧格式、方法路径规则、状态码规范、元数据传输方式等。所以准确说,gRPC 与 HTTP 的关系不是替代,而是“寄生”和“扩展”。
如果还是用前面那道公路来类比,HTTP 本身只是公路和交规,JSON 是一辆普通厢式货车,Protobuf 是一辆标准集装箱车。gRPC 不是某辆车,而是一家物流公司。这家物流公司规定了自己的订单号格式、自己的装卸流程、自己的签收回执,然后它把货装进集装箱,用标准集装箱车在公路上跑。你问“这条公路是不是要改成集装箱车”?公路还是那条公路,只是跑在上面的运输体系从普通货车换成了物流公司的整套系统。
1.4 用一次快递场景把四者关系叠起来看
把抽象概念一次性全部装进大脑有点困难,我用快递场景做个整体折叠。HTTP 就是那套交通运输规则:规定卡车从哪个入口上高速、怎么出高速、超时了怎么处理。Protobuf 是一种标准集装箱,货物必须先按规则装进箱内,箱体本身紧凑、不怕风吹雨淋,但箱子里具体是什么,必须查箱单。JSON 是另一种包装方式,类似透明塑料袋加纸质面单,里面装着什么东西一眼就能看见,但体积大、易损坏、每件都要贴详细面单。gRPC 则是一家物流公司:它有固定的下单接口、有自己的仓库管理系统、有自己的运输车队调度,它选择用标准集装箱封装货物,调用公路运输体系完成配送。
所以你问“塑料袋能不能换成物流公司”,这句话本身是说不通的。你能做的选择是:在公路运输体系下,继续用透明塑料袋自己送,还是引入一家物流公司、统一用它的集装箱体系来送。现实中更常见的混合方案是:外部客户看到的仍然是一辆辆贴着透明面单的包裹,但包裹进了你自己仓库以后,内部流转全部换成标准集装箱。
到这里,四个概念的分层已经清楚了。下面我们顺着一次真实请求,看看这些角色在实际链路上到底是怎么协作的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 顺着一次调用链路看:谁在做运输,谁在做序列化
概念讲再多,不如打开一条真实链路看一遍。我把两种典型接口并排拆开,一种是平时最常见的 REST+JSON,另一种是 gRPC+Protobuf,看看从客户端发起请求到服务端返回响应,每一层分别发生了什么。
2.1 经典的 REST + JSON 请求里,四个角色怎么分布
假设客户端要查一个用户信息,REST 接口长这样:
code复制GET /v1/users/123
Host: api.example.com
Accept: application/json
服务端返回:
code复制HTTP/1.1 200 OK
Content-Type: application/json
{"id":123,"name":"Tom","age":30}
这条链路上 HTTP 干了什么?它定义了请求方法 GET、路径 /v1/users/123、Host 头、状态码 200。JSON 又干了什么?它只是把返回的数据对象编码成一个文本字符串放在 body 里。链路里的每个角色各管一段:HTTP 负责把整个请求和响应送过去,保证语义正确;JSON 负责把结构化数据变成文本;业务代码负责把“id 等于 123”这个意图映射成 GET 请求的路径参数。
如果数据库里查出来的是一个 Go 结构体,服务端要做的就是把结构体 json.Marshal 成文本,再交给 HTTP 响应写入。客户端收到 body 后,先看 Content-Type 是 application/json,再调用 json.Unmarshal 还原成对象。整个过程中,HTTP 对 body 里的 JSON 内容完全无感知,它只把 body 当成一堆字节流。
2.2 在 gRPC 链路里,各个环节怎么组装
再看一个 gRPC 接口。先有一个 .proto 文件:
protobuf复制syntax = "proto3";
package user;
service UserService {
rpc GetUser(GetUserRequest) returns (User);
}
message GetUserRequest {
int32 user_id = 1;
}
message User {
int32 id = 1;
string name = 2;
int32 age = 3;
}
用 protoc 生成客户端代码后,调用方代码写出来大概是这样的伪代码:
javascript复制const client = new UserServiceClient('grpc://api.example.com:9090');
const reply = await client.GetUser({ userId: 123 });
这段代码背后发生了什么?首先,gRPC 库读取 .proto 生成的描述信息,知道要调用的是 user.UserService/GetUser 这个方法。接着,它把请求对象 { userId: 123 } 用 Protobuf 序列化成二进制字节。然后,它把这次调用包装成 HTTP/2 请求,HTTP/2 的 path 会被设置成 /user.UserService/GetUser,也就是说 gRPC 把“服务名+方法名”直接放进了 URL 路径里。请求头里会有 content-type: application/grpc+proto,还会有 gRPC 自定义的超时设置。body 里则先放一个 5 字节的 gRPC 消息帧头,表示后面这个消息有没有压缩、长度是多少,再放 Protobuf 编码出来的二进制内容。
服务端收到后,gRPC 框架根据路径把请求路由到 UserService 的 GetUser 方法,再把二进制内容反序列化成一个请求对象,交给业务代码处理。处理完成后,返回结果同样先 Protobuf 序列化,再塞进 gRPC 消息帧里,作为 HTTP/2 响应返回。
注意到没有,这条链路上 HTTP 仍然存在,只是从 REST 里常见的 HTTP/1.1 纯文本请求,变成了隐藏在 gRPC 内部、由库代码自动打理的 HTTP/2 请求。你在应用层写的代码里几乎看不到 HTTP 的影子,但字节流在网络上跑的时候,HTTP/2 的帧结构实实在在存在。这也是很多初学者误以为“gRPC 不走 HTTP、gRPC 比 HTTP 快所以不需要 HTTP”的原因——实际上它只是把 HTTP 藏到了框架内部。
2.3 直观对比:Protobuf 的七字节与 JSON 的二十二字节
用一个最简单直观的对比感受两者差异。假设要传输一条用户数据:名字叫 Tom,年龄 30。如果按上面那个 proto 定义,User 消息序列化成十六进制是什么样?
code复制0A 03 54 6F 6D 10 1E
拆开看:0A 表示字段 1(name)、wire type 为 2(length-delimited),03 表示后面字符串长度是 3,54 6F 6D 就是 ASCII 码的 Tom;10 表示字段 2(age)、wire type 为 0(varint),1E 是十进制 30。整条消息只有 7 个字节。
同样内容用 JSON 表示:
json复制{"name":"Tom","age":30}
不算任何多余空格,这个字符串就有 22 字节。差距主要来自哪里?JSON 必须把字段名字符串一个一个写在数据里,Protobuf 完全不写字段名,只用一个字段编号来指代。当数据条目非常多、字段名又很长的时候,Protobuf 的体积优势会越来越明显;如果数据里还有大量重复的嵌套结构,差距还会继续放大。
但这不意味着 JSON 一无是处。22 字节的 JSON 谁都能看懂,7 字节的 Protobuf 直接贴到终端里就是乱码。物联网上报、服务间高频调用这类场景,省下几个字节可能意味着省下大量带宽和延迟,二进制序列化优势很大;浏览器调试、第三方开放接口这类场景,可读性反而是第一位的,JSON 的地位无法被取代。
2.4 抓包后最容易看出的问题:Protobuf 不是自描述的
真正把两个链路跑起来抓包,你会更直观感受到什么叫“自描述”和“非自描述”。抓 REST+JSON 的包,直接在 Wireshark 里跟踪 HTTP 流,或者用 Charles、Fiddler 一类的代理工具,就能看到完整的 JSON 文本,哪怕你完全不知道服务端代码,也能把接口文档反推出来。
抓 gRPC+Protobuf 的包就完全是另一番景象。Wireshark 能识别出 HTTP/2 帧,能告诉你这是 HEADERS 帧还是 DATA 帧,能解析出 content-type: application/grpc+proto,但 DATA 帧里的那串字节它无法直接给你翻译成人话。它会显示类似 ... 的二进制内容,或者告诉你这是 Protobuf 但缺少 .proto 描述信息。你要真正读懂内容,必须把 .proto 文件喂给工具,或者让服务端开启 gRPC reflection 服务,才能把二进制字节对应回字段名。
这一点直接决定了团队协作方式。用 JSON 的时候,前后端对齐接口靠的是一份可能过期的文档,或者干脆抓个包看看线上返回。用 Protobuf 的时候,.proto 文件就是唯一真相,谁改了字段编号、谁删了字段、谁改了类型,都会直接冲击所有依赖方。这也是为什么 gRPC 项目里 code review 一定会盯着 .proto 文件看兼容性,因为这文件就是整个系统的骨骼。
3. 可存在的组合不只一种,选型究竟在选什么
四者分层清楚之后,很多组合就自然浮现了。它们不是非此即彼,而是可以灵活搭配。但搭配不是乱搭,每一组组合背后都牵涉到调试成本、生态成熟度和团队协作模式的差异。
3.1 常见排列组合表
下面这张表基本覆盖了日常开发里能见到的组合形态:
| 组合形态 | 实际表现 | 典型场景 |
|---|---|---|
| HTTP/1.1 + JSON | 最常见 REST 接口 | 浏览器前端、开放 API、第三方对接 |
| HTTP/2 + JSON | 多路复用减少连接数,但 body 仍是 JSON | 部分自建网关、移动端长连接优化 |
| HTTP + Protobuf | body 直接用 Protobuf,不走 gRPC 框架 | 部分内部服务、物联网、游戏通信 |
| HTTP/2 + Protobuf + gRPC 框架 | 完整 gRPC 调用 | 微服务内部通信、多语言服务间高频调用 |
| gRPC 框架 + JSON 编解码 | 理论可行但不主流 | 极端兼容场景,很少见于生产 |
| Protobuf 不搭 HTTP,自己封装协议 | 裸 TCP/UDP 上跑自定义协议 | 游戏服务器、嵌入式设备通信、高并发网关 |
有人会觉得奇怪,为什么会有“HTTP+Protobuf 但不走 gRPC”这种形态?gRPC 虽然强大,但它引入了 HTTP/2、引入了框架级约束,在一些环境里并不方便。比如某些嵌入式设备或者老旧的网关只能走 HTTP/1.1,你没法完整跑 gRPC,但你又想用 Protobuf 来压缩 body 体积、规范字段兼容性,那就可以自己约定 Content-Type: application/x-protobuf,把 Protobuf 序列化后的字节塞进 HTTP body 里。服务端用自己的反序列化代码还原结构体。这种方式牺牲了 gRPC 的一大堆便捷能力,比如服务发现、流式调用、跨语言桩代码自动生成,但至少拿到了数据体积小的收益。
3.2 “把 JSON 改成 gRPC”这个说法哪里不准确
现在可以正面拆解这个高频错误说法了。说“把 JSON 改成 gRPC”,至少犯了两个错。
第一个错,是把数据格式和调用框架对立起来。JSON 对应的替代者是 Protobuf,不是 gRPC。如果只是想把数据格式换了,你完全可以在现有 HTTP 接口里把 response body 序列化成 Protobuf,但这不会让你获得 gRPC 的任何框架能力。第二个错,是忽略了 gRPC 背后那一整套东西。换成 gRPC 不只是序列化格式变了,还包括 HTTP/1.1 升级到 HTTP/2、接口风格从 REST 资源路径变成 RPC 方法调用、错误模型从 HTTP 状态码变成 gRPC 状态码、服务治理上要重新考虑负载均衡和超时传播。这一整套变化,远远大于“换一种 body 格式”。
正确的表述应该是:“我想把这组接口从 HTTP+JSON 的 REST 风格,切换成基于 HTTP/2+Protobuf 的 gRPC 调用。”这样一说,团队才知道工作量到底有多大,才知道要改的不只是序列化工具,而是整个调用链路的编码方式、服务端框架和运维监控。
3.3 一些看起来反直觉但实际存在的做法
实际工程里还有一些“反直觉”的组合。比如 gRPC 流式调用,它的消息承载格式是 Protobuf,但如果你只想快速传一段未结构化的文本,也可以直接把 string 类型放进 proto 消息里。再比如,有些团队对外暴露 REST+JSON,对内服务间调用是 gRPC+Protobuf,中间用网关做转换,这是目前非常主流的“双模”架构。
还有一个容易忽略的方向:消息队列里的场景。很多团队在微服务间引入 Kafka 或 RabbitMQ,消息体可以直接用 JSON,也可以先用 Protobuf 序列化成字节再塞进消息队列。这时候“HTTP”“gRPC”都不参与,只有 Protobuf 在干活。因为消息队列本身已经承载了“传输”职责,你需要的只是把数据压缩、规范化,让生产和消费双方共享同一个 schema。把 Protobuf 和 gRPC 强行绑定,在这种场景下就是没分清楚序列化层与传输层。
4. 真正解耦:对内 gRPC、对外 JSON 的双出口设计
标题里的“解耦”不是抽象概念,它能在架构设计中落地成非常具体的方案。这里分享一个我实际做过的模式:同一套业务逻辑,内部服务之间走 gRPC+Protobuf,外部客户端拿到的却依然是 HTTP+JSON。这样可以兼顾内部效率和外部易用性。
4.1 从一份 .proto 契约长出两套 API
做法不复杂。核心业务服务仍然只暴露 gRPC 接口,服务代码里不写任何 HTTP 路由。但在 .proto 文件里,给每个方法加上 HTTP 映射注解,例如:
protobuf复制import "google/api/annotations.proto";
service UserService {
rpc GetUser(GetUserRequest) returns (User) {
option (google.api.http) = {
get: "/v1/users/{user_id}"
};
}
}
这份 .proto 文件同时喂养两条产物线。第一条产物线是 gRPC 服务端代码,业务逻辑直接实现这个接口。第二条产物线是通过 protoc-gen-grpc-gateway 生成一个反向代理层,这个代理层能接收外部 HTTP 请求,路径 /v1/users/123 会命中代理里的规则,代理把请求转换成 gRPC 调用,转给内部服务,再把 gRPC 返回的 User 消息序列化成 JSON 返回给外部调用方。
我在实际项目里用类似方案做过一个后台管理系统。前端页面不需要关心背后是 gRPC 还是 Protobuf,它只发正常的 HTTP 请求,收到正常的 JSON 响应,出问题的时候还能直接用浏览器开发者工具看返回内容。而服务端多个模块之间互相调用则全部走 gRPC,省去了大量重复的 HTTP 路由定义,字段变更也通过 .proto 管理得更严格。前端团队拿到手的依然是一份 OpenAPI 文档,但那份文档实际上是网关根据 .proto 自动导出的,不会出现手写文档和真实接口不一致的问题。
4.2 网关转码后,链路里的每个角色依然清晰
有人会担心,加了网关之后整个链路是不是变复杂了?实际上链路依然清晰。外部请求先到达网关进程,网关解析 HTTP 请求,取出路径参数和 JSON body,根据注解规则知道该调用哪个 gRPC 方法,然后它作为 gRPC 客户端发起内部调用。内部服务处理完后把结果返回给网关,网关把 Protobuf 消息转成 JSON,再作为 HTTP 响应返回。
整个过程里,gRPC 的调用被限制在内网范围,HTTP 的调用被限制在边界入口。运维上只需要在网关这一层做 HTTP 相关的限流、鉴权、日志,内部 gRPC 服务不需要重复处理这些逻辑,内部接口的网络模型也简单很多。吞吐量要求高、需要双向流或者服务端推送的内部逻辑,直接在 gRPC 里做;外部浏览器不支持的某些 gRPC 特性,则在网关层被隔离掉。
我在实际项目中体会到这种模式最大的收益不是性能,而是避免“选型绑定”。你不需要因为内部某个模块用了 gRPC,就要求所有外部调用方也掌握 gRPC;也不需要因为要兼容外部 JSON,就强迫内部所有服务都写成 HTTP+JSON。把通信风格和外部表达解耦之后,两边演进互不拖累。
4.3 解耦的核心是把“契约”当作单独一层抽出来
再往深挖一层,解耦的关键其实是“契约”的抽取。HTTP、gRPC、Protobuf、JSON 这四个东西最终都在围绕同一份数据进行协作,而这份数据长什么样,由 schema 决定。
在 REST+JSON 的世界里,schema 往往撑不起“独立一层”的地位。很多团队的项目里根本没有 JSON Schema 文件,接口文档是手写的,字段名靠口头对齐,后端加一个字段前端不知道,前端传一个多余字段后端忽略。JSON 的自描述特性反而助长了这种散漫,因为数据结构看起来太直观了,大家觉得没必要额外定义一份契约。
在 Protobuf 的世界里,契约被强制前置。你必须有 .proto 文件,必须运行代码生成工具,必须提交 generated code 到仓库。这份 .proto 文件可以独立于具体语言存在,于是它成为所有参与方的共同参照点。gRPC 服务端根据它生成骨架,gRPC 客户端根据它生成桩代码,JSON 网关根据它生成路由和转换逻辑,文档系统根据它生成 OpenAPI。
把契约当作独立资产后,HTTP 和 gRPC 只是不同出口,JSON 和 Protobuf 只是不同投影。外部客户看到的 JSON 是契约的一种人类可读投影,内部服务处理的 Protobuf 是契约的一种高效二进制投影。四者关系和而不同,谁也不绑架谁。
5. 概念分层之后,再看几类高频故障就豁然开朗
理论建设得差不多后,回头去看那些搜索频率极高的问题,会发现许多线上故障和开发困惑都源于几个概念层之间互相混淆。这里列出几类典型问题,逐一说明真正原因。
5.1 浏览器开发者工具里看不到 gRPC 返回内容,正常吗
搜索热词里经常出现一种声音:为什么我在浏览器面板里发请求调 gRPC 接口,返回一堆看不懂的东西?又或者我用 axios 请求一个 http:// 地址,结果报 415、400,根本调不通。
原因在于浏览器原生环境并不直接面向 gRPC 的完整能力设计。gRPC 依赖 HTTP/2 的很多特性,比如主动推送、双向流、trailer 头,浏览器中的普通 XHR/fetch 并不能像常见网络框架那样自由操控这些底层能力。所以现代浏览器调用 gRPC 服务,通常需要通过 gRPC-Web 协议或者其他 HTTP 网关代理。也就是说,你让前端直接拿 axios 请求一个纯 gRPC 监听端口,属于跨层使用,失败是正常的。
解决这个问题的思路也很清晰:对外暴露 HTTP 面,内部保留 gRPC 面。浏览器请求的是 JSON 网关,网关再转成 gRPC。前端不需要感知 gRPC 的存在。
5.2 丢了一纸契约,再好的二进制也白费
另一个高频事故是:线上某个服务返回的响应体变成一串看不懂的二进制,日志里全是乱码,运维同事截图发群里问是不是被黑客攻击了。排查半天发现,是某个内部接口把响应格式从 JSON 切成了 Protobuf,但调用方没有同步更新 .proto 文件,或者说调用方根本不认识这个新的二进制格式。
这正是 Protobuf 最需要警惕的坑。JSON 自描述,出问题的时候你抓个包至少能看懂是哪个字段不对;Protobuf 不自描述,一旦契约丢失、版本错配或者字段编号变更,字节流对你来说就像一堆没有密钥的密文。所以在引入 Protobuf 的团队里,.proto 文件必须纳入版本管理,必须走评审流程,必须保留兼容性设计规则,比如字段编号一旦占用就不能删除、只能用 reserved 标记。这些规则不是教条,是无数个“线上乱码事故”换来的经验。
5.3 用 HTTP 状态码理解 gRPC 状态,行不通
调试 gRPC 服务时还容易出现一类问题:拿 HTTP 状态码的直觉来判断 gRPC 调用是否正常。比如我在本地用 grpcurl 调用接口,业务逻辑明明校验失败返回错误,但 HTTP 层看状态码是 200,于是以为调用成功,白白排查了很久。
原因是 gRPC 的状态模型和 HTTP 状态码是两套体系。gRPC 定义了 OK、Canceled、InvalidArgument、NotFound、Internal、Unavailable 等状态码,这些状态在 HTTP/2 传输层通常不直接映射成 4xx/5xx。很多时候 HTTP/2 的帧返回 200,真正的 gRPC 状态码却放在 trailer 里,需要客户端库解析才知道这次调用到底成没成功。用 curl 直接看 HTTP 层状态,根本无法反映真实业务状态。
这类问题只在真正理解“gRPC 是构建在 HTTP/2 之上的一套新语义层”之后,才能彻底看透。它借用 HTTP 的传输能力,但定义了自己的一套应用层语义,这套语义不是 HTTP 状态码能概括的。
5.4 还有一部分问题是“层混”导致的设计冲突
最后一类问题我在代码评审里见过很多次,可归纳为“层混”。比如有人提出“为了减少体积,接口文档里要求调用方把 JSON 压缩一下”,却不知道 gzip 本身已经能把 JSON 文本压掉一大半,盲目的预处理只会破坏调试体验。又比如有人觉得“Rest 是性能瓶颈,全项目换成 gRPC”,却没先量化 Rest 接口的瓶颈到底在序列化、网络往返、数据库还是业务逻辑。再比如有人纠结 WebSocket 和 gRPC 怎么选,却没意识到 WebSocket 是一个传输层方案,gRPC 是可以跑在 WebSocket 之上的一整套 RPC 体系,两者也不是同一个层级的概念。
把四者分层吃透后,讨论问题时会自然多问一句:“你说的这个优化,到底是在优化数据格式、优化传输协议、优化接口风格,还是在优化框架选型?”这句话一问出来,一半以上的技术争议会自动消散。
6. 遇到具体项目时,怎么用这套认知做决策
概念层面的东西讲了不少,落到实际项目里,最终还是要回答选型问题。我自己的经验是不要背“哪个更好”的结论,而是用几个针对性问题把项目条件测一遍,答案自然浮出来。
6.1 用一组问题快速判断方向
第一问:你的调用方是谁?如果主要是浏览器前端、第三方开发者,JSON 几乎是必选项,因为可读性好、跨语言门槛低、生态工具遍地都是。你可以在内部再用 gRPC,但对外暴露层要保留 JSON 能力。如果调用方都是你自己的后端服务,且都是可控的技术栈,gRPC+Protobuf 的收益就会明显放大。
第二问:数据量到底有多大?如果只是几十个字段、调用量一天几千次,Protobuf 带来的体积优势根本感知不到,强行引入只会增加排查难度和代码生成成本。但如果场景是每秒几万次调用、网络带宽吃紧、数据字段多且嵌套深,Protobuf 的体积优势和编解码性能就值得认真考虑。
第三问:接口是否涉及流式场景?比如要做服务端推送、要做持续返回大量事件、要做客户端上传大文件的流式处理,gRPC 的双向流模型非常顺手。如果只用普通请求响应就能覆盖,REST 的简单性反而更好维护。
第四问:团队对这套协议的熟悉程度和维护能力如何?引入一个新技术,不只是代码能跑,还要求团队能解决线上问题。比如 protoc 工具链版本更新、.proto 文件兼容性评审、grpcurl 调试、网关配置,每个环节都需要经验积累。一个没人真正用过 gRPC 的团队贸然全量迁过去,排障成本会远高于预期。
6.2 我个人在实际项目里沉淀下来的一套默认选择
经过几个项目的对比之后,我基本形成了一套默认选择,这套选择不一定适合所有人,但适合大多数常规业务系统。对外的、需要被很多人调用的接口,我默认用 HTTP+JSON,哪怕觉得不够“极客”也没关系,因为开放接口的易用性远比单字节体积重要。几个内部服务之间高频调用、共享一套严格 schema、需要流式交互的时候,我默认用 gRPC+Protobuf,把契约放在仓库的独立目录里统一管。内部数据在服务间流转但不需要同步调用时,比如发消息给 Kafka,我倾向于把 Protobuf 当成序列化工具单独使用,这和 gRPC 没有必然关系。
这套默认选择把四者各自放在最合适的位置,也是我想强调的核心认知:HTTP 和 gRPC 解决“怎么把请求送过去”的问题,Protobuf 和 JSON 解决“数据长什么样”的问题,而这一切之上,真正需要你投入精力维护的是那份独立于语言和传输方式的契约。项目刚开始的时候,这可能只是多写几个文件、多跑几条命令的差别;等项目规模滚起来之后,契约独立、分层清晰、按需组合这三个原则,会让你在改接口、迁移服务、新增渠道时少踩非常多的坑。
