Files
cs-note/hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理.md
T
2026-05-24 11:42:38 +08:00

16 KiB
Raw Blame History

tags, create time, update time
tags create time update time
gRPC
HTTP/2
Protocol
Go
Networking
2026-05-11 16:42 2026-05-18

HTTP/2 传输原理

概述

gRPC 跑在 HTTP/2 之上。这意味着两个结果:一是你免费获得了 HTTP/2 带来的所有性能红利——多路复用、头部压缩;二是你要接受一层新的抽象,理解 frame(帧)和 stream(流)的概念才能调试那些"偶发性超时""连接无故断开"的问题。

先建立直觉:数据是怎么层层包装的?

把一次 gRPC 请求想象成寄快递:

层级 类比 它的作用
Protobuf 你要寄的物品 定义数据的格式。比如一个 User 对象:名字、年龄、邮箱
gRPC 快递员 + 运单号 把物品打包,贴上一个"信封"(metadata),告诉收件方这是什么类型的请求
HTTP/2 Frame 纸箱上的标签 把每个信封切成小箱子,贴上标签(类型、长度、属于哪个流)
Stream 快递站的分拣通道 一条逻辑通道上跑一个完整的请求-响应周期
TCP 运送快递的卡车 保证每辆车安全到达,不丢包、不乱序

这四层的关系是:内层的数据被外层包裹。发送时从内到外逐层封装,接收时从外到内逐层拆解:

你调用 client.GetUser(request)
  → Protobuf 把你的 request 序列化成字节   (装箱)
    → gRPC 加上 metadata(超时时间、认证信息等)   (贴运单)
      → HTTP/2 把数据切分成 frame,打上 Stream ID   (贴标签)
        → TCP 可靠地发送到对方

回到最初的问题——为什么 gRPC 选择 HTTP/2?

HTTP/1.1 下,每个请求都要走一次 TCP 连接(三次握手)。高并发场景下光握手就耗死了。

HTTP/2 用一条 TCP 连接承载任意数量的并发请求,这就是多路复用(multiplexing)。代价是你得理解这一套全新的二进制协议层。


连接怎么建立的?

每次发起 RPC 调用前,客户端和服务端必须先"打招呼"。分三步完成:

第一步:建路 — TCP 握手

就是普通的 TCP 三次握手,不再展开。如果这步失败了,你会看到 connection refused 或 connect timeout。

第二步:对暗号 — TLS + ALPN

如果用的是 HTTPS(生产环境标配),还要过 TLS 加密这一步。关键在这里:

TLS 握手的 ClientHello 消息里,客户端会声明自己支持的协议:

"我会 h2(HTTP/2)和 http/1.1,你选一个吧。"

服务端回复 ServerHello,选定一个协议:

"好,我们走 h2。"

这个机制叫 ALPN(Application-Layer Protocol Negotiation)。就像两个人见面先用英语打招呼——如果说不到一块儿去(协商失败),整条连接就不能走 HTTP/2。

第三步:确认身份 — HTTP/2 Preface

双方都确定要用 HTTP/2 后,还要互发一段固定格式的字符串来最终确认:

  • 客户端先发一段固定文本 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n 和一个空的 SETTINGS 帧
  • 服务端收到后,回复一个 ACK 和它自己的 SETTINGS

至此握手完成,可以开始收发业务数据了。

[!warning] 常见坑 一些老旧代理(旧版 Nginx < 1.3.10、旧版 Squid)不支持 ALPN 或不认 Preface,会导致 HTTP/2 偷偷降级为 HTTP/1.1。遇到"偶发的连接断开"时可以检查中间件版本。

[!tip] 完整流程一览

Step 1 : TCP 三次握手           —— 建道路
Step 2 : TLS + ALPN              —— 确认都说 h2
Step 3 : HTTP/2 Preface          —— 正式确认,交换参数
Step 4 : 🚀 开始通信

什么是 Frame 和 Stream?

这是 HTTP/2 最核心的两个概念,理解了它们就理解了 gRPC 的工作方式。

Frame(帧)— 最小传输单元

HTTP/2 的二进制世界里,所有数据都以 frame 为单位传输。每个 frame 的结构非常固定:

┌───────────────────────┬──────────────┬──────────────────┐
│   Length (24 bits)     │   Type(8bit) │ R + StreamID(31) │  ← 9 字节的表头
├───────────────────────┴──────────────┴──────────────────┤
│                   Payload (实际数据)                     │
└─────────────────────────────────────────────────────────┘

五个字段的意思:

字段 含义 备注
Length Payload 有多长 最大约 16MB
Type 这个帧是什么类型 见下表
R 保留位 始终为 0
Stream ID 属于哪条流 0 表示这条帧不属于任何流(属于整条连接)
Payload 数据内容 由 Type 决定含义

常见的帧类型有哪些?

帧类型 Type Code 什么时候用到
DATA 0x0 传输实际的请求/响应数据
HEADERS 0x1 携带 HTTP 头和 gRPC 元数据
SETTINGS 0x4 握手阶段交换配置参数
PING 0x6 保活探测(判断连接是否还活着)
WINDOW_UPDATE 0x8 流量控制(调整接收窗口)
GOAWAY 0x7 优雅关闭("这条连接以后不能再建新流了")
RST_STREAM 0x3 异常中断某个特定的流

gRPC 实际上只用了其中一小部分——复杂的细节都被封装在内部了。

Stream(流)— 一条逻辑通道

Stream 是一趟完整的请求-响应旅程。 每条流有唯一编号,规则很简单:

谁发起 Stream ID 举例
客户端 奇数 1, 3, 5, 7…
服务端 偶数 2, 4, 6, 8…
起始值 1 第一条流永远是 Stream 1
用完之后 不复用 即使流已结束,ID 也不会回收

Multiplexing(多路复用)— 一条连接干 N 份活

这才是重点。一条 TCP 连接上可以同时存在多个 Stream,它们的帧交错着在网络中传输。

以 HTTP/1.1 的方式同时查三个用户,需要三个 TCP 连接,串行执行:

连接 1: ━━[UserA 请求]━━[UserA 响应]━━⏱等待━━[UserB 请求]━━[UserB 响应]━━...
连接 2: ━━[UserB 请求]━━[UserB 响应]━━
连接 3: ━━[UserC 请求]━━[UserC 响应]━━

总耗时 = A + B + C  (串行加起来)

HTTP/2 只需要一个连接,三个 Stream 并行:

Stream 1(UserA):  ━REQUEST_A━━RESPONSE_A━
Stream 3(UserB):         ━REQUEST_B━━RESPONSE_B━
Stream 5(UserC):                ━REQUEST_C━━RESPONSE_C━
                              (同一个 TCP 连接内交错发送)

总耗时 ≈ max(A, B, C)  (并行取最大值)

那接收方怎么知道收到的帧属于哪个请求?靠的就是 Stream ID。

具体过程:

客户端发出请求的帧按这个顺序传过去:
S1_HEADERS → S3_HEADERS → S5_HEADERS → S1_DATA → S3_DATA → S5_DATA

服务端收到后,根据 Stream ID 重新组装:
  Stream 1:S1_HEADERS + S1_DATA  → "这是查 UserA 的请求"
  Stream 3:S3_HEADERS + S3_DATA  → "这是查 UserB 的请求"
  Stream 5:S5_HEADERS + S5_DATA  → "这是查 UserC 的请求"

服务端响应时走对应的偶数 Stream:
  S2 回给 Stream 1(UserA 的响应)
  S4 回给 Stream 3(UserB 的响应)
  S6 回给 Stream 5(UserC 的响应)

客户端收到后照样按 Stream ID 匹配回来就行。

[!note] 一句话总结 Stream 就像是高速公路上的车道,Frame 就是在车道上跑的车。多条车道的车可以并行前进,收费站(接收方)只要看车牌号(Stream ID)就知道把这辆车分到哪个车位去。


HPACK:头部为什么要压缩?

HTTP/1.1 有个缺点:每个请求都得附带完整的 header,而且这些 header 绝大部分内容是重复的:

// 第 1 次请求,约 200+ 字节
POST /user.v1.UserService/CreateUser HTTP/1.1
Host: localhost:50051
Content-Type: application/grpc
Grpc-Timeout: 30s
User-Agent: grpc-go/1.50.0
Te: trailers

// 第 2 次请求,又发了一遍一模一样的东西
POST /user.v1.UserService/CreateUser HTTP/1.1
Host: localhost:50051
Content-Type: application/grpc
Grpc-Timeout: 30s
User-Agent: grpc-go/1.50.0
Te: trailers

同一个服务,几百个请求,Host、Content-Type、User-Agent 每次都原封不动地再发一遍——白白浪费带宽。

HTTP/2 的做法:共享词典

HPACK 的核心思想是:客户端和服务端各维护一份相同的字典,只传索引号,不传原文。

这个字典有两个来源:

  1. 静态字典 — RFC 标准预定义了 61 个常用 header 的映射关系。比如 :method 对应索引号 2,content-type 对应另一个固定索引号。双方都内置这份字典,不需要额外同步。
  2. 动态字典 — 运行过程中遇到新出现的 key-value 对,两边各自存下来,后续请求直接引用。

第一次请求还是要发完整内容的(顺便把新条目加入动态字典):

:method = POST          → 查静态字典 → 找到索引号 2 → 发 {idx 2}
:path = /user.v1...     → 不在字典里 → 存进动态表 + 发完整路径
content-type = ...      → 查静态字典 → 找到索引号 → 发 {idx ?}
grpc-timeout = 30s      → Huffman 编码后只占 5 字节

第二次请求就轻松了——:method、content-type 都在静态字典里,直接报索引号就行,一个字都不用多传。

压缩效果

版本 单次请求头部大小 说明
HTTP/1.1 ~200 bytes 每次都原样发送
HTTP/2 + HPACK(首次) ~50 bytes 新条目要多发一点
HTTP/2 + HPACK(后续) 10~20 bytes 大部分都能索引到

[!tip] 为什么 gRPC 强制要求 hpack table size >= 4096? gRPC 的请求头部大部分是固定的(:method, :path, content-type, grpc-timeout),非常适合缓存。table 越大,命中率越高,压缩效果越好。


Flow Control:谁来管流量?

这里有一个容易混淆的点——HTTP/2 自带一层流量控制,gRPC 又叠加了一层,它们分工不同:

层级 管什么 默认值 失控会怎样
HTTP/2 Window 网络缓冲区(TCP 队列) 65535 bytes 缓冲区满时发送方被 backpressure 堵住,表现为超时
gRPC Message Size 应用层内存(unmarshal 用的堆空间) 4 MB proto message 超过限制时报 message larger than max

类比一下:

  • HTTP/2 Window 像是门口通道的宽度——太宽了接不住,得慢慢放
  • gRPC Message Size 像是仓库的容量——货到了太多放不下,会 OOM

两层都配好才算真正的安全。

[!warning] 踩坑指南 你的 protobuf message 超过 4MB 时会报错:

grpc: received message larger than max (5242880 vs 4194304)

解决:调大 MaxCallRecvMsgSize,但别设为无上限——小心被恶意巨型 payload 打爆内存。

grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024)) // 10MB

连接的生命周期

stateDiagram-v2
    [*] --> Idle
    Idle --> Connecting: TCP connect
    Connecting --> Connected: OK
    Connecting --> ErrorFailed: Failed

    Connected --> TLSEncrypted: TLS + ALPN (if secure)
    Connected --> Plaintext: Insecure mode

    TLSEncrypted --> SettingsSent: Send Preface + SETTINGS
    Plaintext --> SettingsSent: Send Preface + SETTINGS

    SettingsSent --> Ready: Receive SETTINGS ACK
    SettingsSent --> ErrorSettingsTimeout: Timeout

    Ready --> Active: Create Streams
    Active --> HalfClose: Stream done
    HalfClose --> GoAwayReceived: Server sends GOAWAY
    GoAwayReceived --> DrainPending: Wait for active streams
    DrainPending --> Closed: All pending done

    state Active {
        StreamOpen --> Sending
        StreamOpen --> Receiving
        Sending --> Done
        Receiving --> Done
    }

    note right of ErrorFailed
        DNS 解析失败 /
        TCP connect timeout /
        TLS cert invalid
    end note

    note right of ErrorSettingsTimeout
        对方未在规定时间回复
        SETTINGS ACK
    end note

各阶段的关键点:

  1. Connecting — TCP 握手。DNS 解析失败、目标不可达都会在这步挂掉。
  2. TLS + ALPN — 证书过期或域名不匹配在这一步被发现。
  3. Settings exchange — 双方交换 window size、max concurrent streams 等参数。正式握手点。
  4. Ready — 握手完成,可以创建 Stream 了。
  5. Active — 正常干活阶段,随时创建和销毁 Stream。
  6. GOAWAY — 服务端要停机重启了,通知"这条连接不再接受新请求",但已经创建的请求还能继续完成。
  7. Closed — 连接彻底断开。gRPC 会自动重连(reconnect policy)。

[!note] GOAWAY vs RST_STREAM

  • GOAWAY:连接级别的。"这条连接以后不能建新流了" → 优雅关闭
  • RST_STREAM:流级别的。"这个流坏了,断了" → 不影响同连接上的其他流

对开发者的实际影响

1. 不需要手动建连接池

HTTP/1.1 时代开发者习惯手动维护连接池。但在 gRPC 里一个 Dial 就够了——内部的连接管理器会自动处理复用和重连。

conn, _ := grpc.Dial("target", grpc.WithTransportCredentials(...))
client1 := pb.NewService1Client(conn)
client2 := pb.NewService2Client(conn)
// 所有 client 共享这个 conn,底层自动管理

2. Keepalive 必须配置

生产环境不配 keepalive,负载均衡器或 K8s ingress 会认为你的连接是空闲的然后直接杀掉。

grpc.WithKeepaliveParams(keepalive.ClientParameters{
    Time:                10 * time.Second,
    Timeout:             5 * time.Second,
    PermitWithoutStream: true,  // ★ 关键:即使没有活跃请求也发 ping
})

推荐配置取决于你的中间件策略:

组件 典型 idle timeout 建议 keepalive Time
AWS ALB 60s 25s
Nginx proxy_pass 75s 30s
K8s kube-proxy 根据 conntrack 25s

[!warning] PermitWithoutStream 为什么重要? 默认值是 false。当没有活跃 RPC 时(比如两次调用之间的等待期),gRPC 不会发 keepalive ping。恰好此时中间设备判断连接空闲,直接把 TCP 连接断掉了——下次调用就报 connection reset。设成 true 就能保住连接。

3. 遇到错误时的排查思路

现象 可能的原因 第一反应检查什么
偶发 DEADLINE_EXCEEDED HTTP/2 Window 满了被 backpressure 堵住 Wireshark 看 WINDOW_UPDATE 延迟
连接间歇性断开 Keepalive 没开,LB 杀了空闲连接 检查 PermitWithoutStream
"message larger than max" Proto message 超过 4MB 调大 MaxCallRecvMsgSize
PROTOCOL_ERROR 中间代理篡改了 frame 检查 Nginx / Envoy 是否启用了 http2
某个流卡住不报错 Server handler 阻塞或未回写 GRPC_TRACE=stream,transport

4. 常用调试工具

# 快速测试一个 gRPC endpoint
grpcurl -plaintext -d '{"id": 42}' localhost:50051 user.v1.UserService.GetUser

# 查看服务提供了哪些方法
grpcurl -plaintext localhost:50051 list

# 查看某个 Service 的详细接口定义
grpcurl -plaintext localhost:50051 describe user.v1.UserService

# 看 frame 级别的网络细节
nghttp -v http://localhost:50051

# Wireshark 过滤 gRPC 流量
tcp.port == 50051 && http2

# Go 内置 debug 日志
GRPC_VERBOSITY=DEBUG GRPC_TRACE=http2,transport,subchannel ./your-app

关联笔记