Learn
HTTP/11-connections

连接管理:keep-alive 与队头阻塞

第 3 章算过账:每条 TCP 连接的建立要花 1 个 RTT(HTTPS 还要加 TLS 握手)。一个网页几十上百个资源,连接策略直接决定加载速度。这一章讲清 HTTP/1.x 时代的连接演化史——它的每个痛点,都是 HTTP/2、HTTP/3 存在的理由。

1. 从短连接到 keep-alive

HTTP/1.0 默认短连接:一次请求-响应,连接就关。100 个资源 = 100 次三次握手 + 100 次慢启动,性能灾难。

HTTP/1.1 默认长连接(persistent connection):一条连接串行跑多个请求-响应,用 Connection 头控制:

HTTP/1.0:  默认关闭;加 Connection: keep-alive 才保持
HTTP/1.1:  默认保持;加 Connection: close 才关闭

用 curl 直接观察连接复用(curl 对同一命令里的多个 URL 会尝试复用连接):

观察连接复用:Re-using existing connection
python3 -c '
from http.server import BaseHTTPRequestHandler, HTTPServer
 
class H(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"    # 启用 keep-alive 的关键
    def do_GET(self):
        body = ("path=" + self.path + "\n").encode()
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))  # 1.1 长连接必须能定界
        self.end_headers()
        self.wfile.write(body)
    def log_message(self, *a):
        pass
 
HTTPServer(("127.0.0.1", 8000), H).serve_forever()
' &
for _i in $(seq 1 50); do (exec 3<>/dev/tcp/127.0.0.1/8000) 2>/dev/null && break; sleep 0.1; done
 
# 一条 curl 命令请求两个 URL,观察第二个请求是否复用连接
curl -v http://127.0.0.1:8000/first http://127.0.0.1:8000/second 2>&1 \
  | grep -E "Connected to|Re-using|path="

输出里第二个请求出现 Re-using existing connection——三次握手只发生了一次。注意服务器代码里的两个关键点:protocol_version = "HTTP/1.1",以及必须发送 Content-Length(长连接下没有"连接关闭"作为报文结束信号,必须靠长度定界,否则客户端不知道体何时结束)。

对照组——服务器坚持 HTTP/1.0 短连接时:

对照:HTTP/1.0 短连接每次都重新握手
python3 -c '
from http.server import BaseHTTPRequestHandler, HTTPServer
 
class H(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.0"    # 1.0 默认响应完就断
    def do_GET(self):
        body = b"hi\n"
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
    def log_message(self, *a):
        pass
 
HTTPServer(("127.0.0.1", 8000), H).serve_forever()
' &
for _i in $(seq 1 50); do (exec 3<>/dev/tcp/127.0.0.1/8000) 2>/dev/null && break; sleep 0.1; done
 
curl -v http://127.0.0.1:8000/a http://127.0.0.1:8000/b 2>&1 \
  | grep -E "Connected to|Re-using|Closing connection"

这次没有 Re-using:每个请求都重新 Connected to——服务器响应完就关了连接(响应头里有 Connection: close 语义)。

2. 管线化:一次勇敢而失败的尝试

长连接仍是"发一个,等回来,再发下一个",一条连接同一时刻只有一个请求在飞。HTTP/1.1 曾提出管线化(pipelining):连续发出多个请求,不等响应。

但它有个致命约束:响应必须按请求顺序返回(HTTP/1.x 报文没有编号,乱序就对不上号)。后果:

  • 第一个请求慢,后面全部被堵住——队头阻塞反而更集中;
  • 大量代理、服务器实现有 bug,中间设备兼容性差;
  • 失败重试语义复杂(后面的请求算发过没有?)。

结局:所有主流浏览器实现过又全部默认关闭,最终移除。管线化的墓志铭写着:没有多路复用的并发是伪并发——这正是 HTTP/2 帧编号设计的直接动因。

3. 队头阻塞:亲手测一次

HTTP/1.1 的队头阻塞(Head-of-Line Blocking):一条连接上,前一个响应没传完,后一个请求只能排队。实验对比"单连接串行"与"双连接并行":

队头阻塞:慢请求堵住快请求
python3 -c '
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import time
 
class H(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    def do_GET(self):
        if self.path == "/slow":
            time.sleep(2)            # 模拟慢查询
        body = (self.path + " done\n").encode()
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
    def log_message(self, *a):
        pass
 
ThreadingHTTPServer(("127.0.0.1", 8000), H).serve_forever()
' &
for _i in $(seq 1 50); do (exec 3<>/dev/tcp/127.0.0.1/8000) 2>/dev/null && break; sleep 0.1; done
 
echo "== 方案 A:一条连接串行(/fast 被 /slow 堵住)=="
T0=$(date +%s)
curl -s http://127.0.0.1:8000/slow http://127.0.0.1:8000/fast
echo "总耗时约 $(( $(date +%s) - T0 )) 秒"
 
echo "== 方案 B:两条连接并行(/fast 立即返回)=="
T0=$(date +%s)
curl -s http://127.0.0.1:8000/slow > /work/slow.out &
SLOW=$!
curl -s -w "fast 用时 %{time_total}s\n" http://127.0.0.1:8000/fast
wait $SLOW
cat /work/slow.out
echo "总耗时约 $(( $(date +%s) - T0 )) 秒"

方案 A 里 /fast 明明瞬间能完成,却陪着 /slow 等了 2 秒——这就是队头阻塞。方案 B 用第二条连接绕开了它。

4. 浏览器的工程对策(HTTP/1.1 时代)

对策做法代价
并发连接每个域名同时开 6 条连接6 倍握手与内存,仍有上限
域名分片静态资源拆到 img1.cdn.com、img2.cdn.com... 突破 6 条限制更多 DNS + 握手,HTTP/2 时代成反模式
资源合并雪碧图、JS/CSS 打包,减少请求数改一行全量失效缓存
内联小图转 base64 塞进 CSS无法独立缓存

这些"性能最佳实践"全是协议缺陷逼出来的绕路。HTTP/2 的多路复用(第 13 章)把根因解决后,域名分片和过度合并反而变成负优化——性能优化手段必须跟着协议版本更新。

4.1 服务端的连接参数

以 Nginx 为例,长连接相关的三个常见配置:

keepalive_timeout 65s;      # 空闲连接保留多久
keepalive_requests 1000;    # 一条连接最多服务多少请求(防内存碎片累积)
# upstream 里:
keepalive 32;               # 到上游的连接池大小(反代必配,否则对上游全是短连接)
⚠️反向代理最常见的性能误配

Nginx 默认用 HTTP/1.0 + Connection: close 连上游!做反向代理时必须显式配置 proxy_http_version 1.1 并清空 Connection 头,否则前端到 Nginx 是长连接,Nginx 到后端却每个请求握一次手,QPS 高时后端 TIME_WAIT 爆炸(回忆第 3 章:主动关闭方背 TIME_WAIT)。

ℹ️连接是稀缺资源

服务端每条连接都占文件描述符和内存。空闲超时(timeout)与最大请求数(keepalive_requests)是在"省握手"与"防连接泄漏/资源耗尽"之间找平衡。压测时 QPS 上不去,先查连接复用率与本地端口耗尽(回忆 TIME_WAIT)。

小结

  • HTTP/1.0 默认短连接,1.1 默认 keep-alive;长连接下报文必须靠 Content-Length 或 chunked 定界;
  • 管线化因"响应必须按序"而失败,教训催生了 HTTP/2 的多路复用;
  • HTTP/1.1 队头阻塞:一条连接同时只能跑一个请求-响应,慢请求堵死后续;
  • 浏览器用 6 连接 + 域名分片 + 资源合并绕路,HTTP/2 后这些手段多数过时;
  • 反向代理到上游务必启用 HTTP/1.1 长连接与连接池。
🎯练习
  1. 修改 Playground 1 的服务器:响应加上 Connection: close 头,验证 curl 第二个请求不再复用连接。
  2. 在 Playground 3 中把请求顺序换成先 /fast 后 /slow,方案 A 的总耗时变成多少?这说明队头阻塞的影响与什么有关?
  3. 用 curl 的 --parallel 参数改写方案 B,对比行为。
  4. 你接手一个 HTTP/1.1 时代的老项目,里面有 4 个分片域名和巨型雪碧图。升级到 HTTP/2 后,列出你会逐步回退的优化项及理由。