连接管理: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 会尝试复用连接):
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 短连接时:
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 长连接与连接池。
- 修改 Playground 1 的服务器:响应加上 Connection: close 头,验证 curl 第二个请求不再复用连接。
- 在 Playground 3 中把请求顺序换成先 /fast 后 /slow,方案 A 的总耗时变成多少?这说明队头阻塞的影响与什么有关?
- 用 curl 的 --parallel 参数改写方案 B,对比行为。
- 你接手一个 HTTP/1.1 时代的老项目,里面有 4 个分片域名和巨型雪碧图。升级到 HTTP/2 后,列出你会逐步回退的优化项及理由。