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:抓包与命令速查

第34章 代理的背压与流控:一个代理最难的部分

学习目标

  • 理解代理的头号工程难题:桥接两条速度不同的连接而不让缓冲无限增长
  • 掌握"流控耦合"机制:写阻塞时停止读,把背压顶回最初的发送方
  • 看清阻塞模型(Go io.Copy)与事件驱动模型(epoll)实现背压的根本差异
  • 修正 第24章 C 例子里真实存在的背压 bug,理解 Envoy 的 watermark buffer

前置知识

  • 第03章 缓冲 vs 流式、第12章 splice、第21章 Go/第24章 C
  • 网络手册 · TCP 与可靠传输(滑动窗口)

原理

问题陈述:fast producer,slow consumer

第21章起,我们一直把转发写成"双向拷贝"。但那是玩具。真正的难题藏在一句话里:

代理桥接的两条连接速度几乎总是不同的。一端产生数据比另一端消费快时,数据会堆在代理里。如果不加约束,这个堆积是无界的——直到 OOM。

三个真实场景:

① 慢读者(下载):源站 100MB/s 吐 → 客户端 1MB/s 读
   → 99MB/s 的差值堆在代理。一个慢客户端就能撑爆内存。

② 慢上游(上传):客户端 100MB/s 传 → 源站 1MB/s 收
   → 上传数据堆在代理。

③ Slowloris 攻击:成千上万个故意极慢的连接,每个占一点缓冲
   → 累积耗尽代理内存/连接,是经典 DoS。

这就是为什么"缓冲整个响应体再转发"(第03章 的 buffering)在大 body 下是危险的——它本质是"无背压"。

核心机制:流控耦合(flow-control coupling)

解法的精髓只有一句:

当写一端阻塞(对方接收不过来),就停止从另一端读。

为什么这样就够了?因为 TCP 本身自带背压——接收窗口(rwnd,网络手册 · TCP)。当你停止读 socket,它的内核接收缓冲很快填满,TCP 就向对端通告 window=0,对端被迫停发。代理要做的,是把两条连接的窗口"耦合"起来:

   源站 ──①数据──▶ [代理读缓冲] ──②写──▶ 客户端(慢)
                                          │ 写不进去(send buf 满, EAGAIN)
                      ③停止从源站读 ◀─────┘
                      │
   源站接收窗口收缩 → 通告 win=0 → ④源站停发
                      (背压一路顶到了最初的发送方)

整条链路上任何一处的缓冲都不会超过一两个 socket 缓冲 + 一点中间缓冲的大小——有界了。代理本身几乎不囤数据,它只是个"窗口耦合器"。

实现一:阻塞/协程模型——背压是免费的(Go)

这解释了 第21章 为什么那么简单。io.Copy(dst, src) 内部是 Read 一块、Write 一块的循环:

for {
    n := src.Read(buf)      // 读一块
    dst.Write(buf[:n])      // 写出去——若 dst 慢,Write 在这里【阻塞】
}                           // Write 阻塞 → 这个 goroutine 停在这 → 不会再 Read src

Write 阻塞自动地停止了 Read——背压天然成立,你什么都不用做。代价是每方向占一个 goroutine(阻塞在 Write 上),但 Go 的 goroutine 够便宜。这就是"Go 写代理简单"的深层原因:阻塞模型把背压送给了你。 Rust 的 copy_bidirectional(第22章)、Python 的 await writer.drain()(第23章)同理——drain() 那行就是显式的背压点。

实现二:事件驱动/epoll 模型——背压要手动做(C/Nginx/Envoy)

事件驱动模型没有"阻塞"可依赖——socket 都是非阻塞的,write 返回 EAGAIN 而不是等待。所以背压必须显式实现成一个状态机:

正常态 READING:监听 src 的 EPOLLIN
  src 可读 → 读数据 → 写 dst
    写成功 → 继续
    写返回 EAGAIN(dst send buf 满)→ 进入 BLOCKED 态:
        · 把没写完的数据留在中间缓冲
        · epoll_ctl(MOD, src, 去掉 EPOLLIN)   ← 暂停读源(关键!背压动作)
        · epoll_ctl(MOD, dst, 加上 EPOLLOUT)  ← 等 dst 变可写

BLOCKED 态:等 dst 的 EPOLLOUT
  dst 可写 → 把中间缓冲排空到 dst
    排空完 → 回到 READING:
        · epoll_ctl(MOD, dst, 去掉 EPOLLOUT)
        · epoll_ctl(MOD, src, 加回 EPOLLIN)   ← 恢复读源

关键就是那两步 epoll_ctl:写不动时摘掉源的读事件(停止读=背压),对端能写了再装回去。 没有这个状态机,事件驱动代理就没有背压。

揭穿 第24章 的 bug

我在 第24章 的 C 例子里,forward() 是这样的(简化):

static void forward(int from) {
    for (;;) {
        ssize_t n = splice(from, ..., pipe_w, ...);   // src → pipe
        if (n <= 0) break;
        while (n > 0) {
            ssize_t m = splice(pipe_r, ..., peer, ...); // pipe → peer
            if (m <= 0) break;          // ← peer EAGAIN 时只是 break!
            n -= m;
        }
    }
}

它有真实的背压缺陷:当 peer 的发送缓冲满(splice 到 peer 返回 EAGAIN),代码只是 break 退出——但:

  • 它没有暂停 from 的 EPOLLIN,所以 epoll 下一轮还会因为 from 可读再次调用 forward,继续往 pipe 里灌;
  • 它没有注册 peer 的 EPOLLOUT,所以 peer 变可写时没人去排空 pipe;
  • pipe 容量有限(默认 64KB),灌满后 splice(from→pipe) 也会 EAGAIN,最终表现为:慢读者下这条连接僵死、CPU 空转、数据卡在 pipe。

玩具能跑通 curl(因为 curl 读得快,永远不触发),但一遇到慢读者就暴露。修正版必须引入上面的 READING/BLOCKED 状态机(完整实现见 第38章 capstone)。

这正是我想强调的:"能编译、能跑 demo"和"真懂底层"之间,差的就是这种只有在压力下才暴露的机制。

生产级答案:Envoy 的 watermark buffer

Envoy(第15章)把这套背压抽象成 watermark buffer(水位缓冲):每个方向一个缓冲,设两个阈值:

  缓冲积压 ≥ 高水位(如 1MB) → readDisable(true):暂停从源读(= 上面的摘 EPOLLIN)
  缓冲积压 ≤ 低水位(如 0.5MB)→ readDisable(false):恢复读

为什么要两个水位(高/低),而不是一个阈值? 这是迟滞(hysteresis):如果只有一个阈值,缓冲会在阈值上下反复横跳,导致读事件被疯狂地开关(抖动)。高低双水位拉开一个区间,避免频繁切换——和恒温器、TCP 的某些机制一个道理。

Nginx 的 proxy_buffers/proxy_buffering、HAProxy 的缓冲调优,本质都是在配这套"有界缓冲 + 背压"的参数。

缓冲 vs 流式,再审一遍(呼应第03章)

现在能更深刻地理解 第03章 的选择了:

缓冲整包流式 + 背压
内存无界(大 body OOM)有界(一两个缓冲)
背压无(先全收下)有(窗口耦合)
适合小响应、要改写整体大文件/流/SSE/gRPC
风险慢读者/大body 打爆——

默认就该流式 + 背压;缓冲只用于"必须看到完整 body"的小响应。

L7 的额外难度:多路复用下的背压

上面讲的是一条连接对一条连接。但 HTTP/2(第36章)一条连接上有 N 个 stream,一个慢 stream 不能阻塞整条连接的其它 stream。所以 h2 不能简单"停止读整条连接",而要按 stream 背压——这就是 h2 自带 WINDOW_UPDATE 流控的原因(每个 stream 一个窗口)。代理必须把 h2 的 stream 级流控翻译成后端的连接级背压,复杂度陡增。下一层细节见 第36章。


️ 实现 / 命令

实验一:制造慢读者,观察无背压代理的内存爆炸

# 用 curl 的限速模拟慢客户端:以 10KB/s 读一个大响应
curl --limit-rate 10k -x http://你的代理:8080 http://源站/big-100mb.bin -o /dev/null &

# 观察代理进程内存
watch -n1 "ps -o rss= -p $(pgrep -f 你的代理) | awk '{print \$1/1024 \" MB\"}'"
#   无背压代理:内存随源站吐出速度飙升(源站快、客户端 10KB/s)→ 最终 OOM
#   有背压代理:内存平稳在几百 KB(背压让源站也降到 ~10KB/s)

实验二:strace 看背压的 epoll 动作

# 对一个正确实现背压的事件驱动代理(如 nginx)strace
strace -f -e trace=epoll_ctl,splice,write -p $(pgrep nginx) 2>&1 | grep -E "EPOLLOUT|EAGAIN"
# 慢读者场景下你会看到:write/splice 返回 EAGAIN → epoll_ctl 注册 EPOLLOUT
# → 客户端读走数据后 EPOLLOUT 触发 → 排空 → 重新监听上游读

实验三:验证 Go 的 io.Copy 自带背压

# 同样的慢读者,打 Go 代理(第21章),看内存平稳
curl --limit-rate 10k -x http://127.0.0.1:8080 http://源站/big.bin -o /dev/null &
# Go 代理内存几乎不动——因为 dst.Write 阻塞了搬运 goroutine,自动停止读 src
# 用 pprof 看:goroutine 阻塞在 net write,heap 平稳

修正版 epoll 背压状态机(伪代码骨架)

// 每个连接的每个方向维护状态
enum { READING, BLOCKED } state;

void on_src_readable(conn *c) {        // EPOLLIN on src
    ssize_t n = splice(c->src, c->pipe_w, ...);
    if (try_flush(c) == EAGAIN) {      // 写 peer 没排空
        epoll_mod(c->src, /*remove*/ EPOLLIN);   // 背压:停读源
        epoll_mod(c->dst, /*add*/    EPOLLOUT);  // 等 peer 可写
        c->state = BLOCKED;
    }
}
void on_dst_writable(conn *c) {        // EPOLLOUT on dst
    if (try_flush(c) == DONE) {        // pipe 排空了
        epoll_mod(c->dst, /*remove*/ EPOLLOUT);
        epoll_mod(c->src, /*add*/    EPOLLIN);   // 恢复读源
        c->state = READING;
    }
}

排错

现象根因解决
代理内存随慢客户端持续增长直至 OOM没做背压(无界缓冲)实现流控耦合 / 关闭整包缓冲走流式
事件驱动代理慢读者下连接僵死、CPU 空转写 EAGAIN 后没暂停读、没注册 EPOLLOUT加 READING/BLOCKED 状态机
大文件下载偶发代理内存尖峰proxy_buffering on 缓冲大 body该路径流式(proxy_buffering off)
Slowloris 拖垮代理慢连接累积占缓冲/连接超时 + 连接数限制 + 最小速率要求
水位反复切换、读事件抖动单阈值无迟滞高/低双水位(hysteresis)
h2 一个慢 stream 卡住整条连接按连接而非按 stream 背压用 stream 级 WINDOW_UPDATE(第36章)

本章小结

  • 代理的头号难题:桥接两条不同速连接而缓冲不能无界——否则慢读者/慢上游/Slowloris 就能 OOM 你。
  • 解法是流控耦合:写阻塞就停止读,借 TCP 接收窗口把背压一路顶回最初的发送方,全链路缓冲有界。
  • 阻塞/协程模型(Go io.Copy)背压免费(Write 阻塞自动停 Read);事件驱动模型必须手写状态机(EAGAIN→摘 EPOLLIN+装 EPOLLOUT→排空后还原)。
  • 第24章 的玩具有真实背压 bug;生产级答案是 Envoy 的高低水位 watermark buffer(迟滞防抖动)。

下一章 第35章 socket 与 TCP 状态机,深入代理必须管理的另一套底层机制:半关闭、连接的完整状态机、超时与 TIME_WAIT。

Next
第35章 socket 与 TCP 状态机:半关闭、超时、连接生命周期