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

学习目标

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

前置知识


原理

问题陈述: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。