第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。