HiHuo
首页
博客
手册
工具
关于
首页
博客
手册
工具
关于
  • 代理技术全栈手册

    • 代理技术全栈手册 - HiHuo
    • 原理篇

      • 第01章 代理是什么:正向 / 反向 / 透明 / 隧道的统一模型
      • 第02章 代理与网络层级:L3 / L4 / L5 / L7 在哪里截断流量
      • 第03章 一个请求穿过代理的一生:连接生命周期全景
    • 协议篇

      • 第04章 HTTP 代理协议:绝对 URI、CONNECT 隧道、转发头与连接池
      • 第05章 HTTPS 与 TLS 代理:终止 / 透传 / MITM / SNI / mTLS
      • 第06章 SOCKS 协议:SOCKS4/4a/5 与 UDP ASSOCIATE 报文级解析
      • 第07章 HTTP/2、gRPC 与 HTTP/3(QUIC) 代理的挑战
      • 第08章 代理自动配置:PAC / WPAD / 系统代理 / NO_PROXY
    • 层级与转发篇

      • 第09章 L4 代理:TCP/UDP 转发与连接级负载均衡
      • 第10章 L7 代理:协议感知与基于内容的路由
      • 第11章 透明代理:iptables REDIRECT/DNAT、TPROXY 与 eBPF 劫持
      • 第12章 数据搬运的艺术:splice / sendfile / 零拷贝 / io_uring
    • 组件横评篇

      • 第13章 Nginx / OpenResty:反向代理、upstream 与 Lua 可编程
      • 第14章 HAProxy:L4/L7、ACL、健康检查与 stick table
      • 第15章 Envoy:xDS 动态配置与 filter chain,为何是云原生数据面
      • 第16章 Traefik / Caddy:自动服务发现与自动 HTTPS
      • 第17章 Squid 与正向/缓存代理:企业出网、缓存与审计
      • 第18章 mitmproxy:抓包、改包、脚本化调试
      • 第19章 内网穿透与隧道:frp / gost / SSH 隧道 / ngrok
      • 第20章 科学上网生态的技术原理(技术中立)
    • 多语言手写篇

      • 第21章 Go:100 行手写 HTTP/CONNECT + SOCKS5 代理
      • 第22章 Rust:基于 tokio 的高性能 TCP 代理
      • 第23章 Python:asyncio 实现,适合调试与脚本
      • 第24章 C:epoll 裸写与零拷贝,及语言选型对比
    • 容器与K8s篇

      • 第25章 Docker 里的代理:HTTP_PROXY、build/pull 与 daemon 配置
      • 第26章 Sidecar 与流量劫持:Istio init-container 的 iptables 原理
      • 第27章 Ingress 与南北流量:Ingress-nginx 与 Gateway API
      • 第28章 Egress 与出网治理:出口网关、registry mirror、审计
      • 第29章 Service Mesh 数据面:Envoy Sidecar 全链路
    • 进阶篇

      • 第30章 可编程代理:Lua / Wasm / eBPF / xDS,代理的"软件定义"
      • 第31章 性能调优:并发模型、连接池、超时与重试、压测
      • 第32章 排错决策树:502 / 504 / 握手失败 / 环路 / 泄漏
      • 第33章 代理安全:开放代理、SSRF、凭证泄漏与攻击面
    • 底层机制篇

      • 第34章 代理的背压与流控:一个代理最难的部分
      • 第35章 socket 与 TCP 状态机:半关闭、超时、连接生命周期
      • 第36章 HTTP/2 帧、流控与 HPACK:h2 代理的内部机制
      • 第37章 负载均衡算法推导与韧性状态机
      • 第38章 Capstone:把玩具代理改造成生产级骨架
    • 综合实战篇

      • 第39章 企业多跳转发链:拓扑、协议矩阵与贯穿性难题
      • 第40章 端到端实战:把 6 类流量全代理通
      • 第41章 更刁钻的流量:gRPC、长轮询、WebRTC、大文件、双向流
      • 第42章 可落地完整参考实现:一套能跑的多协议转发栈
    • 附录

      • 附录 A:代理协议报文速查(HTTP / SOCKS / PAC / PROXY protocol)
      • 附录 B:组件选型决策树
      • 附录 C:抓包与命令速查

第36章 HTTP/2 帧、流控与 HPACK:h2 代理的内部机制

学习目标

  • 看清 HTTP/2 的帧结构与关键帧类型,理解"一切都是帧"
  • 吃透 h2 的两级流控窗口——它就是 第34章 背压在多路复用下的解法
  • 理解 HPACK 为何有状态、不能透传,代理必须维护独立编解码上下文
  • 搞懂 h2↔h1 的阻抗失配翻译——h2 代理真正的难点

前置知识

  • 第07章 HTTP2/gRPC 代理挑战(本章是其机制级展开)
  • 第34章 背压、第35章 状态机

原理

第07章 说"h2 多路复用所以不能 splice、很难代理"。本章把"为什么难"拆到机制级。

一切都是帧

HTTP/2 把一条 TCP 连接上的通信切成帧(frame)。每帧 9 字节定长头 + 变长 payload:

 +-----------------------------------------------+
 |                 Length (24)                   |   payload 长度
 +---------------+---------------+---------------+
 |   Type (8)    |   Flags (8)   |               |   类型 + 标志
 +-+-------------+---------------+---------------+
 |R|                 Stream Id (31)              |   属于哪个 stream
 +=+=============================================+
 |                   Payload ...                 |

Stream Id 是关键:多个 stream(逻辑请求/响应)的帧交错跑在一条连接上,靠这个 id 区分。代理必须解析帧头、按 stream id 重组——这就是不能盲转 splice 的根本(第02章)。

关键帧类型

帧作用代理要关心
HEADERS携带 HPACK 压缩的头,开启 stream解压、改写、重压
DATA请求/响应体受流控约束(见下)
SETTINGS连接参数协商(窗口、最大并发流、最大帧)两侧参数可不同,要桥接
WINDOW_UPDATE流控:补充窗口额度必须正确转发,否则卡死
RST_STREAM取消单个 stream(不关连接)h1 无对应,难翻译
GOAWAY优雅关闭连接(告知最后处理的 stream id)关闭语义传递
PING保活 / 测 RTT一般本地应答
CONTINUATION头太大时续帧HPACK 炸弹防护点
PRIORITY/PUSH_PROMISE优先级 / 服务端推送基本已废弃

每个 stream 还有自己的小状态机:idle → open → half-closed(half-closed local/remote) → closed(呼应 第35章 的半关闭,h2 在应用层重现了它)。

两级流控窗口:h2 的背压(呼应第34章)

第34章 结尾埋了个伏笔:h2 一条连接上多个 stream,一个慢 stream 不能阻塞别的 stream,所以不能"停止读整条连接"。h2 的答案是应用层的两级流控窗口:

  连接级窗口(stream 0):约束整条连接的在途 DATA 总量
  每流级窗口(每个 stream):约束单个 stream 的在途 DATA 量

  发送方:只能在【连接窗口 ∩ 该流窗口】的额度内发 DATA
  接收方:消费了数据后,发 WINDOW_UPDATE 补充额度(分别补连接级和流级)
  • 只有 DATA 帧受流控(HEADERS 等控制帧不受限)
  • 默认初始窗口 65535 字节(可由 SETTINGS 调大,吞吐关键)
  • 为什么要两级? 流级窗口让背压精确到单个 stream:一个慢消费的 stream 耗尽自己的流窗口、发送方对它停发,但其它 stream 的窗口还在,照常传输——背压被隔离在 stream 内。这正是 第34章 说的"按 stream 背压",是 h2 相对 h1 的核心进步(h1 只能整条连接停)。

代理的责任:把客户端侧的 WINDOW_UPDATE/窗口状态,正确地映射到后端侧。漏传或算错 WINDOW_UPDATE,stream 的窗口耗尽后就永远等不到补充 → 卡死。 这就是 第07章 "gRPC 流式调用挂起"的底层机制之一。

HPACK:为什么不能透传压缩字节

HTTP/2 用 HPACK 压缩头部。它由三部分组成:

  静态表:61 项预定义常见头(:method GET、:status 200、content-type 等),固定
  动态表:连接内【动态学习】的头,双方各维护一份,随通信增长
  编码:① 引用表中某项的索引(1~2 字节搞定一个头)
       ② 字面值(可选 Huffman 编码)+ 决定是否加入动态表

致命关键:HPACK 是有状态的,编解码上下文绑定一条连接、一个方向。 第 N 个请求的头压缩,依赖前面请求建立的动态表状态。

这意味着:代理不能把客户端侧压缩好的头字节,原样转发给后端。 因为后端的 HPACK 动态表状态和客户端侧不同步。代理必须:用客户端侧上下文解压 → 得到明文头 → (改写)→ 用后端侧自己的 HPACK 上下文重新压缩。 两侧各维护一套独立的 HPACK 编解码器。这是 h2 代理比 h1 重得多的原因之一。

安全:恶意构造的头可撑爆动态表或用 CONTINUATION 洪泛(HPACK Bomb / CONTINUATION flood,近年的 CVE),代理要限制动态表大小与头总量。

h2 ↔ h1 阻抗失配翻译:真正的难点

现实里代理常常前端 h2、后端 h1(或反之)。两个协议模型不对等,翻译处处是坑:

维度h2h1代理怎么翻
多路复用一连接 N stream一连接一请求N 个 stream → 后端 h1 连接池(第31章)的 N 条连接
流控两级窗口 WINDOW_UPDATE无应用层流控(靠 TCP,第34章)把 h2 窗口 ↔ 后端 TCP 背压互转
取消RST_STREAM 取消单 stream无"取消单请求"RST_STREAM → 中止/复位整条 h1 连接(无法只取消一个)
头伪头 :method/:path/:scheme/:authority、全小写、禁逐跳头请求行 + 普通头伪头 ↔ 请求行/Host 互转,删逐跳头(第04章)
trailers原生支持需 chunked trailer转发 trailer(gRPC grpc-status,第07章)

这张表就是"代理 gRPC 为什么要选 Envoy"的全部理由:Envoy 原生在 stream 粒度处理帧、流控、HPACK、RST、trailer 的双向翻译,而拼凑的 h1 代理在每一行都可能翻车。


️ 实现 / 命令

实验一:用 nghttp 看真实帧流

# nghttp -v 打印每一帧(来自 nghttp2 工具集)
nghttp -v https://www.google.com/ 2>&1 | grep -E "recv|send" | head -30
#  send SETTINGS frame             ← 连接参数协商
#  send HEADERS frame  (stream 13) ← 发请求头(HPACK 压缩)
#  recv SETTINGS frame
#  recv HEADERS frame  (stream 13) ← 响应头
#  recv DATA frame     (stream 13) ← 响应体
#  recv WINDOW_UPDATE frame        ← 流控补充额度

亲眼看到 HEADERS/DATA/SETTINGS/WINDOW_UPDATE,第07章 的抽象就具象了。

实验二:观察 SETTINGS 协商的窗口/并发参数

nghttp -v https://example.com/ 2>&1 | grep -A6 "SETTINGS frame"
#  [SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]   ← 最大并发 stream
#  [SETTINGS_INITIAL_WINDOW_SIZE(0x04):65535]     ← 初始流控窗口
#  [SETTINGS_MAX_FRAME_SIZE(0x05):16384]

实验三:h2 多路复用压测

# h2load:一条连接上跑大量并发 stream,看多路复用吞吐
h2load -n10000 -c1 -m100 https://example.com/    # 1 条连接、100 并发流
# -c1 -m100 = 单连接 100 路复用,对比 h1 需要 100 条连接

实验四:验证代理是否正确转发 WINDOW_UPDATE(gRPC 流式)

# 大流式响应经代理,若 WINDOW_UPDATE 没传,收到 ~64KB(初始窗口)后卡死
grpcurl -plaintext -proxy http://代理:8080 server:443 stream.BigStream/Download
# 正常:持续收流;卡在 ~64KB 不动 = 代理没正确传 WINDOW_UPDATE(窗口耗尽)

排错

现象根因解决
gRPC/流式收到约 64KB 后卡死代理没正确传 WINDOW_UPDATE,流窗口耗尽用原生 h2 代理(Envoy),核对流控转发
一个慢请求拖垮整条 h2 连接按连接而非按 stream 背压正确实现两级流控(流级隔离)
头解析错乱/连接被重置HPACK 上下文不同步(透传了压缩字节)两侧各维护独立 HPACK 编解码
代理被畸形头打挂HPACK Bomb / CONTINUATION 洪泛限制动态表大小与头总量,打补丁
h2 转 h1 后 gRPC 状态丢失trailer 没转发转发 trailer(grpc-status,第07章)
客户端取消请求后端仍在跑RST_STREAM 没映射到后端中止h2 代理需把 RST_STREAM 传导为后端 abort

本章小结

  • HTTP/2 一切皆帧(9 字节头 + payload),靠 Stream Id 多路复用——这是不能 splice 的根因。
  • 两级流控窗口(连接级 + 流级 WINDOW_UPDATE)是 h2 的背压,流级隔离让一个慢 stream 不拖累其它——代理漏传 WINDOW_UPDATE 就卡死。
  • HPACK 有状态、绑定连接:代理必须解压→改写→用自己这侧上下文重压,不能透传压缩字节。
  • h2↔h1 阻抗失配(多路复用/流控/RST_STREAM/伪头/trailer)是 h2 代理的真正难点,也是 Envoy 成为标杆的理由。

下一章 第37章 负载均衡算法推导与韧性状态机,把 第09章 列的算法名补上推导(一致性哈希/Maglev/P2C),并讲透熔断、重试预算、亚稳态失败。

Prev
第35章 socket 与 TCP 状态机:半关闭、超时、连接生命周期
Next
第37章 负载均衡算法推导与韧性状态机