bigywater.tech
gRPC · 1

一个 unary 调用在网线上长什么样:HTTP/2 帧、头和 5 字节消息前缀

gRPC 常被说成“基于 HTTP/2 的 RPC”,但一次调用在网线上到底是什么样?这篇在客户端和服务端之间放一个抓包代理,把一个最简单的 Unary 调用的每一帧、每个头、每个字节都拆开看。

5 字节
gRPC 每条消息前面的前缀:1 字节压缩标志加 4 字节长度
82 对 15 字节
同一条连接上请求 HEADERS 帧的负载:第一次调用与第二次调用(HPACK 动态表)
grpc-status: 0
调用是否成功放在最后的 trailers 里,HTTP 的 :status 永远是 200

一次调用的帧

实测 · 本机执行输出
  调用返回 "echo:hello",n=7,用时(含建立连接)2.3 ms
     1.62ms C→S PREFACE       流 0 长 24    "PRI * HTTP/2.0\r\n"
     1.62ms C→S SETTINGS      流 0 长 0     
     1.62ms S→C SETTINGS      流 0 长 6     MAX_FRAME_SIZE=16384
     1.77ms S→C SETTINGS      流 0 长 0    [ACK]  
     2.11ms C→S SETTINGS      流 0 长 0    [ACK]  
     2.18ms C→S HEADERS       流 1 长 82   [END_HEADERS]  :method: POST; :scheme: http; :path: /demo.Demo/Unary; :authority: 127.0.0.1:9801; content-type: application/grpc; user-agent: grpc-go/1.84.0; te: trailers; grpc-timeout: 4998325u
     2.19ms C→S DATA          流 1 长 14   [END_STREAM]  00000000090a0568656c6c6f1007
     2.28ms S→C WINDOW_UPDATE 流 0 长 4     增量 14
     2.28ms S→C PING          流 0 长 8     02041010090e0707
     2.33ms S→C HEADERS       流 1 长 14   [END_HEADERS]  :status: 200; content-type: application/grpc
     2.33ms S→C DATA          流 1 长 19    000000000e0a0a6563686f3a68656c6c6f1007
     2.33ms S→C HEADERS       流 1 长 24   [END_STREAM|END_HEADERS]  grpc-status: 0; grpc-message: 
     2.37ms C→S PING          流 0 长 8    [ACK]  02041010090e0707
     2.45ms C→S WINDOW_UPDATE 流 0 长 4     增量 19
     2.45ms C→S PING          流 0 长 8     02041010090e0707
     2.52ms S→C PING          流 0 长 8    [ACK]  02041010090e0707

连接上先交换 24 字节的前言和 SETTINGS。每个调用占一条 HTTP/2 流(流 1、3、5……),请求是 HEADERS 加带 END_STREAM 的 DATA,响应是 HEADERS、DATA 和带 END_STREAM 的 HEADERS(trailers)。

请求头

实测 · 本机执行输出
  :method: POST
  :scheme: http
  :path: /demo.Demo/Unary
  :authority: 127.0.0.1:9801
  content-type: application/grpc
  user-agent: grpc-go/1.84.0
  te: trailers
  grpc-timeout: 4998325u
  (这个 HEADERS 帧的负载 82 字节)

方法永远是 POST,路径是 /包名.服务名/方法名,content-type 是 application/grpc,te: trailers 表示客户端能接收 trailers,grpc-timeout 带着截止时间(单位微秒)。这就是 gRPC 对 HTTP/2 的全部约定。

消息格式

实测 · 本机执行输出
  DATA 帧负载共 14 字节:00000000090a0568656c6c6f1007
  第 1 字节 压缩标志 = 0(0 表示没压缩);第 2–5 字节 消息长度 = 9(大端)
  载荷 0a0568656c6c6f1007;我自己 proto.Marshal(Req{msg:hello,n:7}) = 0a0568656c6c6f1007;两者字节相同

一条消息 = 1 字节压缩标志 + 4 字节大端长度 + protobuf 载荷。载荷与直接 proto.Marshal 的结果相同,所以任何能看懂 HTTP/2 和 protobuf 的工具都能解它(见 protobuf 那一篇的字节拆解)。

状态在 trailers 里

实测 · 本机执行输出
  流 1 HEADERS 标志 END_HEADERS  :status: 200; content-type: application/grpc
  流 1 DATA   负载 19 字节  前缀+载荷:000000000e0a0a6563686f3a68656c6c6f1007
  流 1 HEADERS 标志 END_STREAM|END_HEADERS  grpc-status: 0; grpc-message: 

响应的 HTTP 状态永远是 200,调用成败看最后 trailers 里的 grpc-status 和 grpc-message。也因此只能到流结束才知道结果(下一篇再展开)。

首次调用的成本

实测 · 本机执行输出
  客户端→服务端 203 字节;服务端→客户端 155 字节;请求消息本身 protobuf 9 字节,响应消息 14 字节

首次调用客户端发 203 字节、服务端发 155 字节,而业务数据只有 9 和 14 字节;大头是连接建立和 82 字节的请求头。

连接复用与 HPACK

实测 · 本机执行输出
  第二次调用用时 0.4 ms;这一次调用的 TCP 字节数:客户端→服务端 94,服务端→客户端 97
     0.19ms C→S HEADERS       流 3 长 15   [END_HEADERS]  :method: POST; :scheme: http; :path: /demo.Demo/Unary; :authority: 127.0.0.1:9801; content-type: application/grpc; user-agent: grpc-go/1.84.0; te: trailers; grpc-timeout: 4999971u
     0.19ms C→S DATA          流 3 长 14   [END_STREAM]  00000000090a0568656c6c6f1007
     0.30ms S→C WINDOW_UPDATE 流 0 长 4     增量 14
     0.31ms S→C PING          流 0 长 8     02041010090e0707
     0.32ms S→C HEADERS       流 3 长 2    [END_HEADERS]  :status: 200; content-type: application/grpc
     0.33ms S→C DATA          流 3 长 19    000000000e0a0a6563686f3a68656c6c6f1007
     0.33ms S→C HEADERS       流 3 长 2    [END_STREAM|END_HEADERS]  grpc-status: 0; grpc-message: 
     0.39ms C→S PING          流 0 长 8    [ACK]  02041010090e0707
     0.43ms C→S WINDOW_UPDATE 流 0 长 4     增量 19
     0.44ms C→S PING          流 0 长 8     02041010090e0707
     0.51ms S→C PING          流 0 长 8    [ACK]  02041010090e0707
  请求 HEADERS 帧负载:第一次 82 字节,第二次 15 字节

第二次调用不需要重新握手,请求头靠 HPACK 动态表从 82 字节降到 15 字节,响应头从 14 降到 2,trailers 从 24 降到 2。这是 gRPC 小消息仍然高效的原因之一。

分步演示

一次调用的字节
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1 / grpc-go v1.84.0 / protobuf-go v1.36.12 / golang.org/x/net v0.57.0 / protoc 36.2(Homebrew);服务端和客户端都是本机起的 Go 进程(127.0.0.1,端口 9800 以上,不访问任何外部服务);需要看线上字节时用我自己写的 TCP 抓包代理加 golang.org/x/net/http2 的 Framer 解析 HTTP/2 帧;计时用 time.Now 或 perf_counter,数字每次运行会有小幅抖动。抓包代理是我写的 TCP 转发加 http2.Framer 解析,看到的是明文 HTTP/2(没有 TLS);观察中还有 PING 和 WINDOW_UPDATE 帧,它们是 grpc-go 的 BDP 流控估计(第 10 篇)产生的,这里只记录了现象;所有字节数是确定性的,耗时是单次测量
    • 没有验证:grpc-go 以外实现(Java、C++)的帧序列;TLS 下的帧长度(多了 TLS 记录的开销);其它版本 grpc-go 的头(比如 grpc-accept-encoding 是否出现);HPACK 动态表大小的上限与淘汰;HTTP/2 之外的传输(gRPC over HTTP/3)
    京ICP备2026067502号-1京公网安备11010502063487号