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)。

关联