土法炼钢兴趣小组的算法知识备份

【网络工程】HTTP/1.1 深度剖析:持久连接、管线化与队头阻塞

文章导航

分类入口
network
标签入口
#http#http11#keep-alive#pipelining#head-of-line-blocking#engineering

目录

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 连接。每个连接都需要:

  1. TCP 三次握手(1 RTT)
  2. TLS 握手(如果是 HTTPS,1-2 RTT)
  3. 发送 HTTP 请求
  4. 等待 HTTP 响应
  5. 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_requests

2.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 域名分片的代价

域名分片不是免费的:

  1. 额外的 DNS 解析:每个子域名需要一次 DNS 查询(约 20-120 ms)。
  2. 额外的 TCP/TLS 握手:新域名意味着新连接,每个连接需要 TCP + TLS 握手。
  3. 无法复用 TLS 会话:不同域名的 TLS 会话不能互相复用(除非使用 TLS 1.3 的 PSK 模式且证书覆盖所有子域名)。
  4. 增加服务器负载:更多的连接意味着更多的内存和文件描述符消耗。

在 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-LengthTransfer-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-LengthTransfer-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-59999

Range 请求是大文件下载、视频流和断点续传的基础。它要求服务端支持并在响应中声明 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/upload

8.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-alive

10.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: chunked

10.4 何时 HTTP/1.1 仍然合适

HTTP/1.1 在以下场景中仍然是合理的选择:

  1. 后端服务内部通信:简单、调试方便,gRPC/HTTP2 的收益在内网低延迟环境下不明显。
  2. 遗留系统兼容:很多企业内部系统只支持 HTTP/1.1。
  3. 简单的 API 调用:如果每次只发一个请求,HTTP/1.1 和 HTTP/2 没有性能差异。
  4. 流式传输:Chunked Transfer Encoding 在某些场景下比 HTTP/2 DATA 帧更简单直接。
  5. 代理/网关内部连接:Nginx 到后端的连接通常使用 HTTP/1.1,因为后端应用框架的 HTTP/2 支持可能不完善。

十一、总结

HTTP/1.1 的工程遗产深刻地影响了 Web 架构的发展。从 1997 年至今,它承载了互联网的大部分流量,其设计中的优点和缺陷都为后续协议提供了宝贵的经验教训。它的几个核心教训:

  1. 持久连接是必须的,但仅靠持久连接不够。每个连接上的串行请求-响应模式是性能瓶颈的根本原因。现代 Web 页面动辄上百个资源,6 个并行连接根本不够。

  2. 管线化的失败证明了一个工程原则:理论上优美的协议设计,如果中间设备不支持,就等于没设计。HTTP/2 通过将多路复用做在更底层(二进制帧)来绕过了这个问题。

  3. 队头阻塞是 HTTP/1.1 的根本限制。无论怎么优化——域名分片、资源合并、雪碧图——都只是在绕过这个限制,而不是解决它。HTTP/2 的多路复用解决了 HTTP 层的队头阻塞,但 TCP 层的队头阻塞仍然存在——这正是 HTTP/3 + QUIC 要解决的问题。

  4. 纯文本协议的优势被高估了。HTTP/1.1 的纯文本格式确实方便用 telnet 或 curl 调试,但头部冗余、解析复杂度和安全问题(HTTP Request Smuggling)都是代价。HTTP/2 的二进制帧格式在工程上是更好的选择。

  5. HTTP/1.1 没有死。它仍然在后端通信、简单 API、遗留系统中广泛使用。理解它的工程细节——持久连接配置、超时管理、连接复用——对于运维和调试仍然是必要的技能。在可预见的未来,HTTP/1.1 会继续在生产环境中扮演重要角色。

  6. 从 HTTP/1.1 到 HTTP/2 的迁移不是”开关一个配置”那么简单。需要重新评估域名分片、资源合并等旧有优化策略,理解 HTTP/2 的多路复用如何改变了性能特征。下一篇将详细剖析 HTTP/2 的内部机制。


参考文献


上一篇:TLS 攻防:降级攻击、中间人与协议漏洞

下一篇:HTTP/2 完整解剖:流、帧、HPACK 与 Server Push

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2025-08-03 · network

【网络工程】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 升级的工程路径和兼容性陷阱。

2025-08-08 · network

【网络工程】TLS 攻防:降级攻击、中间人与协议漏洞

TLS 协议在过去二十年里暴露了一系列严重漏洞。本文从攻击原理到防御工程,系统性剖析 BEAST、POODLE、Heartbleed、DROWN 等经典攻击的技术本质,详解版本降级攻击与 TLS_FALLBACK_SCSV 防御、CBC padding oracle 攻击的数学原理、以及生产环境的 TLS 安全配置检查清单。


By .