HTTP/1.1 于 1997 年在 RFC 2068 中首次定义,1999 年通过 RFC 2616 正式标准化,2014 年又通过 RFC 7230-7235 进行了全面修订。尽管 HTTP/2 和 HTTP/3 已经广泛部署,HTTP/1.1 仍然是互联网上使用最广泛的应用层协议——根据 HTTP Archive 的数据,截至 2024 年仍有约 30% 的 Web 请求使用 HTTP/1.1。
理解 HTTP/1.1 不只是”了解历史”。很多后端服务之间的内部通信仍然使用 HTTP/1.1(因为简单、调试方便),反向代理与后端服务之间的连接默认使用 HTTP/1.1,大量的 REST API 和微服务通信基于 HTTP/1.1。更重要的是,HTTP/2 和 HTTP/3 的设计动机正是为了解决 HTTP/1.1 的工程缺陷——不理解 HTTP/1.1 的问题,就无法理解后续协议的设计选择。
一、HTTP/1.0 的效率问题
要理解 HTTP/1.1 的改进,先看 HTTP/1.0 有多低效。
HTTP/1.0 的默认行为是每个请求一个 TCP 连接。加载一个包含 1 个 HTML + 10 个 CSS/JS + 20 个图片的页面,需要建立 31 个 TCP 连接。每个连接都需要:
- TCP 三次握手(1 RTT)
- TLS 握手(如果是 HTTPS,1-2 RTT)
- 发送 HTTP 请求
- 等待 HTTP 响应
- TCP 四次挥手
在 RTT = 50 ms 的典型场景下,仅 TCP 握手就消耗了 31 × 50 ms = 1550 ms。如果是 HTTPS,再加上 31 次 TLS 握手。
HTTP/1.0 时间线(加载一个页面):
连接1: [TCP握手] [请求HTML] [响应HTML] [关闭]
连接2: [TCP握手] [请求CSS] [响应CSS] [关闭]
连接3: [TCP握手] ...
...每个资源都串行等待前一个完成
HTTP/1.0 后来通过非标准的
Connection: keep-alive
头实现了连接复用,但这不是协议规范的一部分,实现不一致,行为不可靠。
二、HTTP/1.1 的持久连接
HTTP/1.1 最重要的改进就是持久连接(Persistent
Connection)成为默认行为。除非显式指定
Connection: close,HTTP/1.1 的 TCP
连接在请求完成后保持打开状态,后续请求可以复用同一个连接。
2.1 持久连接的协议行为
HTTP/1.1 的持久连接不需要客户端发送任何特殊头部——它是默认行为。RFC 7230 Section 6.3 明确规定:
A client that does not support persistent connections MUST send the “close” connection option in every request message.
这意味着如果你想关闭连接,需要主动声明
Connection: close。
持久连接的工作流程:
HTTP/1.1 持久连接时间线:
连接1: [TCP握手] [TLS握手] [请求1→响应1] [请求2→响应2] [请求3→响应3] ... [关闭]
持久连接消除了每个请求的 TCP/TLS 握手开销。对于加载同一个页面的 31 个资源,只需要 1 次 TCP 握手 + 1 次 TLS 握手,之后所有请求共享同一个连接。
此外,持久连接还带来了另一个重要的性能收益:TCP 拥塞窗口的积累。TCP 的慢启动机制意味着每个新连接都从很小的拥塞窗口开始。持久连接允许拥塞窗口在多个请求间持续增长,后续请求可以享受更大的窗口——更高的吞吐量。
但持久连接不是永久连接。它有几个控制参数:
# 服务端通过 Keep-Alive 头告知客户端连接参数
Keep-Alive: timeout=5, max=100
# timeout=5: 空闲 5 秒后关闭连接
# max=100: 最多复用 100 次请求后关闭连接
2.2 Nginx 的持久连接配置
# 客户端 → Nginx 的连接
keepalive_timeout 65; # 空闲连接超时时间(秒)
keepalive_requests 1000; # 单个连接最大请求数(默认 1000)
# Nginx → 后端(upstream)的连接
upstream backend {
server 10.0.0.1:8080;
server 10.0.0.2:8080;
keepalive 32; # 每个 worker 保持的空闲连接数
keepalive_requests 1000; # 单个连接最大请求数
keepalive_timeout 60s; # 空闲连接超时
}
server {
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1; # 必须!默认是 1.0(无持久连接)
proxy_set_header Connection ""; # 清除 Connection 头,启用 keep-alive
}
}
一个常见的性能陷阱:Nginx
代理到后端时默认使用 HTTP/1.0,这意味着每个请求都会建立新的
TCP 连接。如果你的后端是 Java/Go 应用,没有设置
proxy_http_version 1.1 和清除
Connection 头,Nginx 和后端之间会有大量的 TCP
连接建立和关闭——在高并发下可能导致端口耗尽(TIME_WAIT
堆积)。
2.3 持久连接的工程陷阱
陷阱 1:连接超时不匹配
如果客户端和服务端的 keep-alive 超时不一致,可能导致”连接重置”错误。例如:
客户端 keep-alive timeout: 60s
服务端 keep-alive timeout: 30s
T=0: 客户端发送请求,收到响应
T=30: 服务端关闭连接(超时)
T=35: 客户端在"已关闭"的连接上发送新请求 → RST
客户端看到的错误是 “Connection reset by peer”。这在 Go 的 HTTP 客户端中尤其常见,因为 Go 的默认 Transport 会长时间缓存连接。
// Go: 设置合理的 keep-alive 超时
client := &http.Client{
Transport: &http.Transport{
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
},
}陷阱 2:keepalive_requests 过低导致频繁重连
Nginx 默认的 keepalive_requests 为
1000。在高 QPS 场景下(如每秒万级请求),1000
次请求可能在几秒内就耗尽,导致频繁的连接关闭和重建。
# 检查连接的请求计数
# 如果 TIME_WAIT 数量持续很高,可能需要提高 keepalive_requests
ss -s | grep "timewait"
# timewait: 15000 ← 过高,考虑提高 keepalive_requests2.4 持久连接的监控
# 查看当前的持久连接状态
ss -tn state established | grep :80
ss -tn state established | grep :443
# 统计每个目标 IP 的连接数
ss -tn state established dst :443 | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn
# 检查 TIME_WAIT 连接数(过多表示持久连接未生效)
ss -s
# 关注 TCP: inuse / orphan / tw / alloc
# tw(TIME_WAIT)数量过高说明连接没有被复用
# 使用 curl 验证 Keep-Alive
curl -v http://example.com/ 2>&1 | grep -i "connection"
# 响应中应有: Connection: keep-alive三、HTTP/1.1 管线化:一个失败的优化
HTTP/1.1 规范定义了管线化(Pipelining):客户端可以不等待前一个请求的响应,就发送下一个请求。
3.1 管线化的理论优势
无管线化(串行):
Client: [请求1] ----等待---- [请求2] ----等待---- [请求3]
Server: [响应1] [响应2] [响应3]
总时间 = 3 × (请求 + 等待 + 响应)
有管线化(并行发送):
Client: [请求1][请求2][请求3]
Server: [响应1][响应2][响应3]
总时间 = 请求 + 等待 + 3 × 响应
管线化消除了”等待响应”的空闲时间,理论上可以显著减少延迟——尤其是在高延迟链路上。
3.2 管线化为什么失败了
管线化在实际部署中几乎完全失败,原因有三:
原因 1:队头阻塞(Head-of-Line Blocking)
HTTP/1.1 要求响应必须严格按照请求的顺序返回。如果第一个请求的处理很慢(比如一个复杂的数据库查询),后面所有已就绪的响应都必须等待——即使它们的处理早已完成。
请求队列: [慢请求A] [快请求B] [快请求C]
响应队列: [A 处理中.......] [B 就绪但等待A] [C 就绪但等待A]
← 这就是队头阻塞
原因 2:中间设备不兼容
大量的 HTTP 代理、负载均衡器和防火墙不支持管线化。它们可能: - 将管线化的多个请求拆分成独立连接 - 错误地将响应关联到错误的请求 - 直接关闭连接
原因 3:错误处理困难
如果管线化中的一个请求失败(连接中断、超时),客户端需要重新发送所有未完成的请求——但无法确定服务端处理了哪些、没处理哪些。对于非幂等请求(POST),重新发送可能导致重复操作。
3.3 浏览器的选择
由于以上问题,所有主流浏览器默认禁用了 HTTP/1.1 管线化:
| 浏览器 | 管线化支持 | 状态 |
|---|---|---|
| Chrome | 从未支持 | — |
| Firefox | 曾支持(默认关闭) | 已移除 |
| Safari | 从未支持 | — |
| Edge | 从未支持 | — |
管线化的失败直接推动了 HTTP/2 的多路复用设计——HTTP/2 允许响应乱序返回,从根本上解决了 HTTP 层的队头阻塞。
四、队头阻塞:HTTP/1.1 的核心痛点
队头阻塞(Head-of-Line Blocking, HoL Blocking)是理解 HTTP/1.1 性能局限的关键概念。
4.1 HTTP 层的队头阻塞
由于 HTTP/1.1 在单个连接上只能串行处理请求-响应对(管线化不可用的情况下),一个慢请求会阻塞同一连接上所有后续请求。
# 用 curl 演示队头阻塞
# 假设 /slow 需要 3 秒,/fast 只需要 10 ms
# 在同一个连接上串行请求
time curl -s http://example.com/slow http://example.com/fast
# 总时间 ≈ 3.01 秒(fast 必须等待 slow 完成)
# 对比:使用两个并行连接
time curl -s http://example.com/slow & curl -s http://example.com/fast & wait
# 总时间 ≈ 3.0 秒(fast 在 10 ms 内就返回了)4.2 浏览器的并发连接限制
为了缓解队头阻塞,浏览器对同一域名打开多个并行 TCP 连接。HTTP/1.1 规范建议每个域名最多 2 个连接,但实际上浏览器使用了更多:
| 浏览器 | 每域名最大连接数 | 总最大连接数 |
|---|---|---|
| Chrome | 6 | 256 |
| Firefox | 6 | 256 |
| Safari | 6 | — |
| Edge | 6 | — |
6 个并行连接意味着:如果你的页面有 60 个资源要加载,会分成 10 批,每批 6 个并行下载。每一批的完成时间取决于最慢的那个请求——这就是 HTTP/1.1 性能的瓶颈。
4.3 域名分片
域名分片(Domain Sharding)是 HTTP/1.1 时代最常用的性能优化技巧。通过将资源分散到多个子域名,可以突破浏览器的每域名连接限制:
# 将资源分散到不同子域名
<link rel="stylesheet" href="https://static1.example.com/style.css">
<script src="https://static2.example.com/app.js"></script>
<img src="https://static3.example.com/logo.png">
# 每个子域名允许 6 个连接
# 3 个子域名 = 18 个并行连接
域名分片的工程配置(Nginx):
# 所有 staticN.example.com 指向同一组服务器
server {
listen 443 ssl;
server_name static1.example.com static2.example.com static3.example.com;
root /var/www/static;
# 优化静态资源的响应头
location ~* \.(css|js|png|jpg|gif|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin "*";
}
}
DNS 配置:
static1.example.com. 300 IN CNAME cdn.example.com.
static2.example.com. 300 IN CNAME cdn.example.com.
static3.example.com. 300 IN CNAME cdn.example.com.
4.4 域名分片的代价
域名分片不是免费的:
- 额外的 DNS 解析:每个子域名需要一次 DNS 查询(约 20-120 ms)。
- 额外的 TCP/TLS 握手:新域名意味着新连接,每个连接需要 TCP + TLS 握手。
- 无法复用 TLS 会话:不同域名的 TLS 会话不能互相复用(除非使用 TLS 1.3 的 PSK 模式且证书覆盖所有子域名)。
- 增加服务器负载:更多的连接意味着更多的内存和文件描述符消耗。
在 HTTP/2 环境下,域名分片是反优化。HTTP/2 的多路复用允许在单个连接上并行传输所有资源,域名分片反而破坏了这个优势——它迫使浏览器建立多个 HTTP/2 连接,每个连接的多路复用能力都没有被充分利用。
五、HTTP/1.1 的请求和响应格式
HTTP/1.1 使用纯文本格式,这是它的优势(人类可读、易调试)也是劣势(冗余大、解析慢)。
5.1 请求格式
GET /api/users?page=1 HTTP/1.1\r\n ← 请求行(方法 + URI + 版本)
Host: api.example.com\r\n ← 必须的头(HTTP/1.1 强制要求)
User-Agent: curl/8.4.0\r\n
Accept: application/json\r\n
Accept-Encoding: gzip, deflate, br\r\n
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n
Cookie: session=abc123; tracking=xyz\r\n
Connection: keep-alive\r\n
\r\n ← 空行表示头部结束
HTTP/1.1 引入的 Host
头是一个重要的改进——它允许同一个 IP
地址上托管多个虚拟主机。在 HTTP/1.0
中,服务端无法从请求中判断客户端要访问哪个域名(因为 TCP
连接只包含 IP 地址)。Host
头解决了这个问题,也是现代虚拟主机配置的基础。
5.2 响应格式
HTTP/1.1 200 OK\r\n ← 状态行
Content-Type: application/json\r\n
Content-Length: 1234\r\n
Date: Thu, 01 Aug 2025 12:00:00 GMT\r\n
Cache-Control: max-age=60\r\n
Connection: keep-alive\r\n
\r\n ← 空行
{"users": [...]} ← 响应体
5.3 头部冗余问题
HTTP/1.1 的头部是纯文本且每次请求都重复发送。一个典型的请求可能有 500-800 字节的头部,包含 Cookie、User-Agent、Accept 等。对于加载 100 个资源的页面,头部数据就有 50-80 KB——而且这些头部大部分是重复的。
# 查看实际的请求头大小
curl -v -o /dev/null -s https://example.com 2>&1 | grep '^>' | wc -c
# 典型值: 300-800 字节
# 如果有大 Cookie,头部可能超过 2 KB
curl -v -o /dev/null -s https://example.com 2>&1 | grep '^> Cookie:'HTTP/2 通过 HPACK 头部压缩解决了这个问题——动态表记录之前发过的头部,后续请求只发送差异部分。HTTP/2 的头部压缩可以将重复头部的大小减少 85-95%。
5.4 HTTP Request Smuggling
HTTP/1.1 的文本解析特性还引入了一个严重的安全问题:HTTP
请求走私(HTTP Request Smuggling)。当前端代理和后端服务对
Content-Length 和
Transfer-Encoding
头的解析不一致时,攻击者可以构造请求让两端看到不同的请求边界。
# CL-TE 走私攻击示例
POST / HTTP/1.1
Host: example.com
Content-Length: 13
Transfer-Encoding: chunked
0
SMUGGLED
如果前端代理使用
Content-Length(认为请求体是 13
字节),而后端使用
Transfer-Encoding: chunked(认为 chunk 大小 0
表示请求结束),那么 SMUGGLED
部分会被后端视为下一个请求的开头——这就是请求走私。
防御措施: - 服务端同时收到 Content-Length
和 Transfer-Encoding 时,应优先使用
Transfer-Encoding(RFC 7230 规定)。 - 使用
HTTP/2 在前端代理和后端之间通信(HTTP/2
的二进制帧格式不存在这个问题)。 - 配置 WAF
检测异常的双头请求。
六、分块传输编码
分块传输编码(Chunked Transfer Encoding)是 HTTP/1.1 的重要特性,允许服务端在不知道响应总长度的情况下开始发送数据。
6.1 工作原理
HTTP/1.1 200 OK\r\n
Transfer-Encoding: chunked\r\n
Content-Type: text/html\r\n
\r\n
1a\r\n ← 第一个 chunk 的大小(十六进制,26 字节)
<html><body>Hello, \r\n ← 第一个 chunk 的数据
1f\r\n ← 第二个 chunk 的大小(31 字节)
World! This is chunked.</body>\r\n
0\r\n ← 大小为 0 的 chunk 表示结束
\r\n ← 可选的 trailer
6.2 分块传输的工程应用
分块传输在以下场景中必不可少:
# Python Flask:流式响应
from flask import Response
@app.route('/stream')
def stream():
def generate():
for i in range(100):
yield f"data chunk {i}\n"
time.sleep(0.1) # 模拟实时数据生成
return Response(generate(), mimetype='text/plain')
# Flask 自动使用 chunked 编码// Go: 流式响应
func handler(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", 500)
return
}
for i := 0; i < 100; i++ {
fmt.Fprintf(w, "data chunk %d\n", i)
flusher.Flush() // 立即发送当前 chunk
time.Sleep(100 * time.Millisecond)
}
}6.3 Content-Length vs Transfer-Encoding
两种机制不能同时使用(如果同时出现,Transfer-Encoding
优先)。
| 特性 | Content-Length | Transfer-Encoding: chunked |
|---|---|---|
| 响应体大小 | 必须预知 | 不需要预知 |
| 传输效率 | 无额外开销 | 每个 chunk 有 size + CRLF 开销 |
| 断点续传 | 支持(Range 头) | 不支持 |
| 代理缓存 | 友好 | 部分代理不支持缓存 |
| 错误检测 | 长度不匹配可检测 | 需要看到终止 chunk |
| TTFB | 必须等到大小已知 | 可以立即开始发送 |
| 适用场景 | 静态文件、API 响应 | 流式数据、SSE、大报表 |
工程建议:对于静态文件和已知大小的响应,使用
Content-Length;对于服务端生成(SSR)、日志流、实时数据推送等场景,使用分块传输。
七、HTTP/1.1 的方法语义
HTTP/1.1 定义了明确的方法语义,正确理解这些语义对 API 设计和缓存行为至关重要。
7.1 安全性与幂等性
| 方法 | 安全 | 幂等 | 可缓存 | 说明 |
|---|---|---|---|---|
| GET | ✅ | ✅ | ✅ | 获取资源 |
| HEAD | ✅ | ✅ | ✅ | 获取响应头(无响应体) |
| POST | ❌ | ❌ | 有条件 | 创建资源/提交数据 |
| PUT | ❌ | ✅ | ❌ | 替换资源(完整更新) |
| PATCH | ❌ | ❌ | ❌ | 部分更新资源 |
| DELETE | ❌ | ✅ | ❌ | 删除资源 |
| OPTIONS | ✅ | ✅ | ❌ | 查询支持的方法(CORS 预检) |
安全(Safe):不会修改服务端状态。浏览器可以安全地预取安全方法的请求。
幂等(Idempotent):多次执行与一次执行的效果相同。网络中断后可以安全重试幂等请求。
7.2 条件请求
HTTP/1.1 引入了条件请求头,允许客户端只在资源发生变化时才下载:
# 第一次请求
GET /api/data HTTP/1.1
# 响应包含 ETag 和 Last-Modified
HTTP/1.1 200 OK
ETag: "abc123"
Last-Modified: Thu, 01 Aug 2025 12:00:00 GMT
# 后续请求携带条件头
GET /api/data HTTP/1.1
If-None-Match: "abc123"
If-Modified-Since: Thu, 01 Aug 2025 12:00:00 GMT
# 如果资源未变化,返回 304 Not Modified(无响应体)
HTTP/1.1 304 Not Modified
ETag: "abc123"条件请求在 API 轮询场景中可以显著减少带宽消耗。304 响应没有响应体,通常只有 200-300 字节。
7.3 Range 请求
HTTP/1.1 支持范围请求(Range Request),允许客户端只请求资源的一部分:
# 请求文件的前 1000 字节
GET /large-file.zip HTTP/1.1
Range: bytes=0-999
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-999/1000000
Content-Length: 1000
# 断点续传:从上次中断的位置继续
GET /large-file.zip HTTP/1.1
Range: bytes=50000-
# 多范围请求(用于视频 seek)
GET /video.mp4 HTTP/1.1
Range: bytes=0-999, 50000-59999Range
请求是大文件下载、视频流和断点续传的基础。它要求服务端支持并在响应中声明
Accept-Ranges: bytes。
7.4 常见的 API 设计错误
# ❌ 错误:用 GET 执行修改操作
GET /api/users/123/delete
# 为什么危险:
# 1. 爬虫/预取可能意外触发删除
# 2. 浏览器后退/刷新会重新执行
# 3. CDN/代理可能缓存此请求
# 4. 搜索引擎爬取可能触发数据删除
# ✅ 正确:使用 DELETE 方法
DELETE /api/users/123
八、HTTP/1.1 的连接管理
8.1 Connection 头
# 持久连接(HTTP/1.1 默认行为,不需要显式声明)
Connection: keep-alive
# 关闭连接(告诉对方这是最后一个请求/响应)
Connection: close
# 升级协议(如 WebSocket)
Connection: Upgrade
Upgrade: websocket
Connection
头还有一个重要的语义:逐跳头部(Hop-by-Hop
Header)标记。Connection
头中列出的头部名称只对当前连接有效,代理在转发时必须移除这些头部。
# 客户端请求
Connection: keep-alive, X-Custom-Debug
X-Custom-Debug: verbose
# 代理转发时必须移除 X-Custom-Debug 头
# 因为它被 Connection 头标记为逐跳头部
8.2 Expect: 100-continue
HTTP/1.1 引入了 Expect: 100-continue
机制,用于在发送大请求体前先征得服务端同意:
# 客户端:我要上传一个大文件,你准备好了吗?
POST /upload HTTP/1.1
Content-Length: 104857600
Expect: 100-continue
# 服务端选项:
# 1. 返回 100 Continue → 客户端开始发送请求体
# 2. 返回 417 Expectation Failed → 客户端取消上传
# 3. 返回 413 Payload Too Large → 客户端取消上传
这个机制避免了客户端发送几十 MB 的数据后才发现服务端会拒绝请求(比如因为认证失败或文件太大)。
curl 对大请求体默认启用这个机制:
# curl 上传大文件时自动添加 Expect: 100-continue
curl -X POST -d @large-file.bin https://example.com/upload
# 可以用 -H "Expect:" 禁用这个行为(空值)
curl -X POST -H "Expect:" -d @large-file.bin https://example.com/upload8.2 连接超时与资源管理
服务端需要在连接复用的收益和资源消耗之间取得平衡:
# Nginx 连接管理的最佳实践
# 客户端连接
keepalive_timeout 65; # 空闲超时 65 秒
keepalive_requests 1000; # 最大请求数 1000
client_header_timeout 60; # 请求头读取超时
client_body_timeout 60; # 请求体读取超时
send_timeout 60; # 响应发送超时
# 后端连接
upstream backend {
server 10.0.0.1:8080;
keepalive 64; # 保持 64 个空闲连接
keepalive_requests 10000; # 更高的复用次数(内网通信)
keepalive_time 1h; # 连接的最大存活时间
keepalive_timeout 60s; # 空闲超时
}
8.4 连接泄漏排查
连接泄漏(Connection Leak)是 HTTP/1.1 服务常见的问题——客户端打开了连接但不关闭,逐渐耗尽服务端的连接资源。
# 检查当前连接状态分布
ss -s
# TCP: 5000 (estab 3200, closed 800, orphaned 50, timewait 950)
# 如果 estab 持续增长,可能存在连接泄漏
# 按状态统计 TCP 连接
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
# 期望看到 ESTAB 为主,少量 TIME-WAIT
# 异常:大量 CLOSE-WAIT(表示应用没有关闭连接)
# 找出占用最多连接的进程
ss -tnp state established | awk '{print $6}' | sort | uniq -c | sort -rn
# CLOSE_WAIT 堆积排查
# CLOSE_WAIT 表示对端已关闭连接,但本端没有调用 close()
ss -tnp state close-wait | head -20
# 这通常是应用 bug:HTTP 响应读取完成后没有关闭 Response.Body常见的连接泄漏场景和修复:
// Go: 常见的连接泄漏 bug
resp, err := http.Get("https://example.com/api")
if err != nil {
return err
}
// ❌ 忘记关闭 Response.Body → 连接不会被归还到连接池
// data, _ := io.ReadAll(resp.Body)
// ✅ 正确:必须关闭 Body
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)# Python requests: 使用 Session 复用连接
import requests
# ❌ 每次创建新 Session,连接不复用
for url in urls:
resp = requests.get(url)
# ✅ 使用 Session 复用连接
with requests.Session() as session:
for url in urls:
resp = session.get(url)九、HTTP/1.1 性能优化清单
graph TD
A[HTTP/1.1 性能优化] --> B[减少请求数]
A --> C[减少传输量]
A --> D[减少延迟]
B --> B1[资源合并<br/>CSS/JS bundle]
B --> B2[雪碧图<br/>CSS Sprites]
B --> B3[内联小资源<br/>Data URI]
C --> C1[压缩<br/>gzip/Brotli]
C --> C2[缓存<br/>Cache-Control]
C --> C3[最小化<br/>Minify]
D --> D1[持久连接<br/>Keep-Alive]
D --> D2[域名分片<br/>Domain Sharding]
D --> D3[DNS 预取<br/>dns-prefetch]
style A fill:#388bfd,color:#fff
这些优化技巧中,大部分在 HTTP/2 环境下是不必要甚至有害的:
| HTTP/1.1 优化技巧 | HTTP/2 中的效果 |
|---|---|
| 资源合并 | 有害:阻止细粒度缓存 |
| 雪碧图 | 有害:下载多余的图片数据 |
| 域名分片 | 有害:阻止连接复用 |
| 内联小资源 | 中性:Server Push 是更好的替代 |
| gzip/Brotli 压缩 | 仍然有效 |
| Cache-Control | 仍然有效 |
| 持久连接 | 默认行为(HTTP/2 单连接多路复用) |
十、HTTP/1.1 vs HTTP/2 的性能对比
10.1 基准测试方法
# 使用 h2load 对比 HTTP/1.1 和 HTTP/2 性能
# HTTP/1.1 测试(100 个并行连接,10000 个请求)
h2load -n 10000 -c 100 --h1 https://example.com/
# HTTP/2 测试(单个连接,100 个并行流)
h2load -n 10000 -c 1 -m 100 https://example.com/
# 使用 wrk 测试 HTTP/1.1 的极限吞吐量
wrk -t 4 -c 200 -d 30s http://localhost:8080/api/health
# 注意:wrk 只支持 HTTP/1.1
# 使用 ab(Apache Bench)快速测试
ab -n 10000 -c 100 -k https://example.com/
# -k: 启用 keep-alive10.2 实际性能对比数据
在一个典型的页面加载场景中(100 个资源、平均每个 10 KB、RTT 50 ms):
| 指标 | HTTP/1.1 (6 连接) | HTTP/2 (1 连接) | 差异 |
|---|---|---|---|
| 总连接数 | 6 | 1 | -83% |
| TCP 握手次数 | 6 | 1 | -83% |
| 头部大小(总) | ~80 KB | ~5 KB | -94%(HPACK) |
| 页面加载时间 | ~2.5 s | ~1.2 s | -52% |
| 首字节时间 | ~150 ms | ~100 ms | -33% |
但在以下场景中,HTTP/1.1 和 HTTP/2 性能接近: - 单个大文件下载(带宽受限,非延迟受限) - 低并发 API 调用(每次只有 1-2 个请求) - 内网低延迟通信(RTT < 1 ms)
10.3 HTTP/1.1 调试技巧
HTTP/1.1 的纯文本特性使得调试非常方便。以下是几个实用的调试技巧:
# 查看完整的请求/响应头
curl -v https://example.com 2>&1
# 只看响应头(不下载响应体)
curl -I https://example.com
# 分阶段计时(DNS → 连接 → TLS → 首字节 → 传输)
curl -w "\nDNS: %{time_namelookup}s\nConnect: %{time_connect}s\nTLS: %{time_appconnect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\nSize: %{size_download} bytes\n" \
-o /dev/null -s https://example.com
# 使用 telnet 手动发送 HTTP/1.1 请求
echo -e "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n" | \
nc example.com 80
# 检查服务端支持的 HTTP 方法
curl -X OPTIONS -i https://example.com/api/
# 查看 Allow 头
# 测试持久连接是否生效
curl -v --keepalive-time 30 https://example.com https://example.com 2>&1 | \
grep -E "Re-using|Connected to"
# 第二个请求应显示 "Re-using existing connection"
# 检查分块传输
curl -v https://example.com/api/stream 2>&1 | grep -i "transfer-encoding"
# 应看到: Transfer-Encoding: chunked10.4 何时 HTTP/1.1 仍然合适
HTTP/1.1 在以下场景中仍然是合理的选择:
- 后端服务内部通信:简单、调试方便,gRPC/HTTP2 的收益在内网低延迟环境下不明显。
- 遗留系统兼容:很多企业内部系统只支持 HTTP/1.1。
- 简单的 API 调用:如果每次只发一个请求,HTTP/1.1 和 HTTP/2 没有性能差异。
- 流式传输:Chunked Transfer Encoding 在某些场景下比 HTTP/2 DATA 帧更简单直接。
- 代理/网关内部连接:Nginx 到后端的连接通常使用 HTTP/1.1,因为后端应用框架的 HTTP/2 支持可能不完善。
十一、总结
HTTP/1.1 的工程遗产深刻地影响了 Web 架构的发展。从 1997 年至今,它承载了互联网的大部分流量,其设计中的优点和缺陷都为后续协议提供了宝贵的经验教训。它的几个核心教训:
持久连接是必须的,但仅靠持久连接不够。每个连接上的串行请求-响应模式是性能瓶颈的根本原因。现代 Web 页面动辄上百个资源,6 个并行连接根本不够。
管线化的失败证明了一个工程原则:理论上优美的协议设计,如果中间设备不支持,就等于没设计。HTTP/2 通过将多路复用做在更底层(二进制帧)来绕过了这个问题。
队头阻塞是 HTTP/1.1 的根本限制。无论怎么优化——域名分片、资源合并、雪碧图——都只是在绕过这个限制,而不是解决它。HTTP/2 的多路复用解决了 HTTP 层的队头阻塞,但 TCP 层的队头阻塞仍然存在——这正是 HTTP/3 + QUIC 要解决的问题。
纯文本协议的优势被高估了。HTTP/1.1 的纯文本格式确实方便用 telnet 或 curl 调试,但头部冗余、解析复杂度和安全问题(HTTP Request Smuggling)都是代价。HTTP/2 的二进制帧格式在工程上是更好的选择。
HTTP/1.1 没有死。它仍然在后端通信、简单 API、遗留系统中广泛使用。理解它的工程细节——持久连接配置、超时管理、连接复用——对于运维和调试仍然是必要的技能。在可预见的未来,HTTP/1.1 会继续在生产环境中扮演重要角色。
从 HTTP/1.1 到 HTTP/2 的迁移不是”开关一个配置”那么简单。需要重新评估域名分片、资源合并等旧有优化策略,理解 HTTP/2 的多路复用如何改变了性能特征。下一篇将详细剖析 HTTP/2 的内部机制。
参考文献
- RFC 7230: HTTP/1.1 Message Syntax and Routing
- RFC 7231: HTTP/1.1 Semantics and Content
- RFC 7232: HTTP/1.1 Conditional Requests
- RFC 7234: HTTP/1.1 Caching
- RFC 7235: HTTP/1.1 Authentication
下一篇:HTTP/2 完整解剖:流、帧、HPACK 与 Server Push
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【网络工程】TLS 1.3 工程实践:1-RTT 与 0-RTT 的安全权衡
TLS 1.3 将握手从 2 RTT 压缩到 1 RTT,并引入了 0-RTT 恢复模式。本文从工程视角剖析 TLS 1.3 的简化设计——移除了哪些不安全的特性、1-RTT 握手的每一步变化、PSK 模式与 0-RTT 的重放攻击风险控制,以及从 TLS 1.2 升级的工程路径和兼容性陷阱。
【网络工程】TLS 攻防:降级攻击、中间人与协议漏洞
TLS 协议在过去二十年里暴露了一系列严重漏洞。本文从攻击原理到防御工程,系统性剖析 BEAST、POODLE、Heartbleed、DROWN 等经典攻击的技术本质,详解版本降级攻击与 TLS_FALLBACK_SCSV 防御、CBC padding oracle 攻击的数学原理、以及生产环境的 TLS 安全配置检查清单。
【网络工程】HTTP 调试方法论:curl、DevTools 与 mitmproxy
系统讲解 HTTP 调试的完整工具链:curl 高级用法、Chrome DevTools Network 面板深度使用、mitmproxy 的拦截与改写、TTFB 分解与性能分析方法论。覆盖从开发调试到线上排查的全场景。
【QUIC 协议拆解】QUIC 协议拆解(上):为什么 TCP 改不动了
打开一个网页要握手几次?TCP 三次 + TLS 一次 = 至少 2 RTT。QUIC 说:我一次搞定,重连甚至 0 次。不是 TCP 不够好,是它的基因决定了它改不动。