42.6 协议对比 (gRPC / Thrift / HTTP)
一句话
协议对比的维度不是“谁性能高”,而是连接模型、头部成本、生态代价:HTTP/1.1 每个请求一个往返、HTTP/2 多路复用、gRPC 还能长流;选错协议,多半不是慢在序列化,而是慢在“多等了一个 RTT”或“卡在浏览器不支持”。
五种选择,五个形状
| 协议 | 传输 | 编码 | 连接模型 | 流式 | 浏览器 | 调试 |
|---|---|---|---|---|---|---|
| HTTP/1.1 + JSON | TCP/TLS | 文本 | keep-alive,每连接串行 | 无(SSE 勉强) | ✅ 原生 | ✅ 肉眼/curl |
| HTTP/2 + JSON | TCP/TLS | 文本 | 单连接多路复用 | 半双工流 | ✅ | ⚠️ 需要工具 |
| gRPC | HTTP/2 | Protobuf | 多路复用 + 四种流 | ✅ 双向 | ❌(需 grpc-web) | ⚠️ 需要 IDL/工具 |
| Thrift | TCP/HTTP | Thrift Binary | 每连接多路复用(自研) | 有限 | ❌ | ⚠️ 需要 IDL |
| GraphQL | HTTP | JSON | 单请求按需取字段 | 订阅(WebSocket) | ✅ | ✅(Playground) |
🔑 先排除,再选优:要对外/给浏览器 → HTTP(JSON 或 GraphQL);内网服务间高频调用 → gRPC/Thrift;已有 Thrift 生态且稳定 → 不必迁。
代码:同一批请求,三种协议的字节与时间模型
package main
import (
"fmt"
"math"
)
type request struct {
method string
path string
host string
headers [][2]string
body []byte
}
// HTTP/1.1:请求行 + 头部 + 空行 + body,全部文本
func http11Bytes(r request) int {
n := len(r.method) + 1 + len(r.path) + 1 + len("HTTP/1.1") + 2
for _, h := range r.headers {
n += len(h[0]) + 2 + len(h[1]) + 2
}
return n + 2 + len(r.body)
}
// gRPC:5 字节帧头(压缩位+长度) + 消息体(这里用“字段号+长度+值”的简化 TLV 代表 Protobuf)
func grpcBytes(r request) int {
body := 0
body += 1 + 1 + len(r.path) // field 1: path
body += 1 + 1 + len(r.body) // field 2: payload
body += 1 + 1 + 8 // field 3: deadline/trace(示意)
// HPACK 之后 :method/:path/:authority 等头部约 20 字节(量级估算,非实测)
return 5 + body + 20
}
// 时间模型:N 个请求,单请求服务耗时 s,RTT 固定
// 每连接/每流串行:批次数 = ceil(N/并发数),每批 = RTT + s
func totalMs(n, concurrency int, rttMs, serviceMs float64) float64 {
batches := math.Ceil(float64(n) / float64(concurrency))
return batches * (rttMs + serviceMs)
}
func main() {
r := request{
method: "POST", path: "/order.v1.OrderService/Create", host: "order.default.svc",
headers: [][2]string{
{"content-type", "application/grpc"},
{"authorization", "Bearer eyJhbGciOi..."},
{"x-request-id", "7f3a2c1e-9b4d-4c8a"},
{"user-agent", "grpc-go/1.70.0"},
{"grpc-timeout", "250m"},
},
body: []byte(`{"sku":"sku-9","qty":2}`),
}
fmt.Printf("=== 同一个逻辑请求的编码大小 ===\n")
fmt.Printf("HTTP/1.1 文本:%d 字节\n", http11Bytes(r))
fmt.Printf("gRPC 帧: %d 字节(含 5 字节帧头与约 20 字节 HPACK 头)\n\n", grpcBytes(r))
const (
n = 1000
rttMs = 50.0 // 同城/邻区往返
serviceMs = 10.0 // 单请求服务端处理
)
fmt.Printf("=== %d 个请求的总耗时(RTT %.0fms,服务耗时 %.0fms)===\n", n, rttMs, serviceMs)
fmt.Printf("%-34s %-12s %s\n", "方式", "总耗时", "说明")
fmt.Printf("%-34s %-12.0fms %s\n", "HTTP/1.1(6 条连接,每连接串行)", totalMs(n, 6, rttMs, serviceMs), "每批一个 RTT")
fmt.Printf("%-34s %-12.0fms %s\n", "HTTP/2(单连接,100 路复用)", totalMs(n, 100, rttMs, serviceMs), "批次数减少 16 倍")
fmt.Printf("%-34s %-12.0fms %s\n", "gRPC 流式(单流连续发)", rttMs+float64(n)*0.1, "只在开头付一次 RTT")
fmt.Printf("\n(模型假设:服务端并发能力足够;流式的单消息处理按 0.1ms 估)\n")
}
本机实测(macOS,Apple M1,Go 1.27):
=== 同一个逻辑请求的编码大小 ===
HTTP/1.1 文本:221 字节
gRPC 帧: 91 字节(含 5 字节帧头与约 20 字节 HPACK 头)
=== 1000 个请求的总耗时(RTT 50ms,服务耗时 10ms)===
方式 总耗时 说明
HTTP/1.1(6 条连接,每连接串行) 10020 ms 每批一个 RTT
HTTP/2(单连接,100 路复用) 600 ms 批次数减少 16 倍
gRPC 流式(单流连续发) 150 ms 只在开头付一次 RTT
(模型假设:服务端并发能力足够;流式的单消息处理按 0.1ms 估)
读数:
| 读数 | 含义 |
|---|---|
| 按 1000 个请求算,HTTP/1.1 要 10 秒级,HTTP/2 只要 600ms 级 | 差距来自“每批一个 RTT”,不是编码:ceil(1000/6) × 60ms vs ceil(1000/100) × 60ms |
| gRPC 流式只要 150ms 级 | 流把“每批一个 RTT”压成“开头一个 RTT”,后续靠流水线 |
| 两种编码差 130 字节(2.4 倍) | 对比 RTT 的 50ms,编码优化在低频调用里几乎不可见 |
🔑 结论:协议选择的第一性问题是“每个请求要不要等一个 RTT”。请求越密、RTT 越大(跨地域、移动网络),多路复用与流式的收益越大;请求稀疏时,三者差别可以忽略,此时该按“生态与调试成本”选(HTTP 赢)。
决策表
| 你的情况 | 选什么 | 理由 |
|---|---|---|
| 对外 API、第三方接入 | HTTP/1.1 或 HTTP/2 + JSON | 生态最广、可调试、网关全支持 |
| 浏览器直连内部服务 | HTTP/2 + JSON 或 grpc-web | 浏览器不支持 gRPC 帧 |
| 内网服务间高频小请求 | gRPC | 多路复用 + 小头部 + IDL 契约 |
| 跨地域、RTT 高 | gRPC 或 HTTP/2 | 减少“每请求一个 RTT” |
| 大文件/流式传输 | gRPC streaming 或 HTTP 分块 | 边传边处理,内存有界(42.3) |
| 已有 Thrift 体系 | 保持 Thrift | 迁移收益通常小于改造成本 |
| 前端按需取字段、聚合多资源 | GraphQL 或 BFF(33.6) | 减少往返次数与过度获取 |
迁移成本:gRPC 不是免费的
| 代价 | 具体表现 | 缓解 |
|---|---|---|
| 浏览器不支持 | 需要 grpc-web + 代理转换 | 对外仍用 HTTP/JSON,内网用 gRPC |
| 调试门槛 | 抓包看到的是二进制,需要 IDL 与工具 | 保留 JSON 网关、用反射/grpcurl |
| 网关/负载均衡 | L7 设备要支持 HTTP/2 与 trailers | 用支持 h2 的网关(Envoy/Nginx) |
| 契约演进 | 改 proto 要同步生成代码 | CI 里做 breaking change 检查(53.5) |
| 团队技能 | 排障要懂 HTTP/2 帧与流控 | 至少一人熟悉(42.3 的流控与拦截器) |
反模式
| 反模式 | 后果 |
|---|---|
| 用 HTTP/1.1 发大量小请求(每请求一个 RTT) | 延迟随请求数线性增长,带宽却不是瓶颈 |
| 为“省字节”上 gRPC,却仍在跨地域同步调用 | RTT 没变,收益接近零 |
| 对外 API 用 gRPC | 浏览器/第三方无法直连,被迫加转换层 |
| 每请求新建 TCP 连接(不用 keep-alive) | 每次多一次握手 + 慢启动 |
| 用 HTTP/2 就以为没有队头阻塞 | TCP 层丢包仍会阻塞所有 stream |
| 在 gRPC 上套 JSON 序列化 | 既没有二进制收益,也丢了可读性 |
自检
Q1 为什么在本节模型里,HTTP/1.1 与 HTTP/2 的差距是 16 倍,而字节数只差 130 字节(2.4 倍)?
▸ 答案:因为瓶颈是往返次数而不是字节数。HTTP/1.1 的 6 条连接每条同一时刻只能有一个请求在等响应,1000 个请求要 167 批,每批等一个 RTT(50ms)+ 服务时间(10ms);HTTP/2 在一条连接上同时跑 100 路,只需 10 批。字节数只影响带宽占用,而 50ms 的 RTT 是固定成本,跟包大小无关——所以“省字节”在低频场景几乎没有感知,“减少往返”才有。
Q2 gRPC 流式为什么能再降一个数量级?它的代价是什么?
▸ 答案:流式把“每个请求等一个响应”变成“连续发、连续收”,只在开头付一次 RTT,后续消息流水线化。代价:① 流是有状态的,断线重连要处理“发到哪了/哪些要重发”;② 流控窗口(42.3)会成为吞吐上限;③ 错误处理从“一次调用的错误”变成“流中途的错误”,语义更复杂;④ 网关/代理对长流的支持参差不齐。
Q3 一个内网服务用 HTTP/JSON,QPS 很高但延迟不敏感(批量任务)。值得迁到 gRPC 吗?
▸ 答案:看瓶颈在哪。如果是带宽/CPU(序列化)瓶颈,迁移有收益(包更小、编解码耗时更短);如果是服务端处理时间占大头,协议优化收益很小。更该先做的往往是:批量接口(一次传 N 条,减少往返)、压缩、异步化。规则是:先量出“网络与序列化占端到端延迟的比例”,低于 20% 时迁协议基本是白忙。
Q4 为什么很多公司“对外 HTTP/JSON,对内 gRPC”?
▸ 答案:因为两类流量的约束不同。对外要的是可达性与可调试性:浏览器、第三方、curl、网关、CDN 全都默认支持 HTTP/JSON;对内要的是效率与契约:服务数量多、调用密集、语言多,gRPC 的多路复用、小头部、IDL 代码生成与强类型契约收益明显。代价是要维护一层转换(HTTP/JSON ↔ gRPC),这层通常放在 BFF 或网关上(33.6)。