面试题:HTTP/1.1 长连接与 HTTP/2 多路复用的本质区别

很多候选人在被问到「HTTP/1.1 的长连接和 HTTP/2 的多路复用有什么区别」时,答的都是「一个是复用 TCP 连接,一个是并行传输,HTTP/2 更快」。这个回答在面试里几乎等于没答,因为它没有触及二者的本质差异:HTTP/1.1 的长连接解决的是「连接复用」问题,但仍然受制于应用层的请求-响应串行语义;HTTP/2 的多路复用解决的是「帧级并发」

很多候选人在被问到「HTTP/1.1 的长连接和 HTTP/2 的多路复用有什么区别」时,答的都是「一个是复用 TCP 连接,一个是并行传输,HTTP/2 更快」。这个回答在面试里几乎等于没答,因为它没有触及二者的本质差异:HTTP/1.1 的长连接解决的是「连接复用」问题,但仍然受制于应用层的请求-响应串行语义;HTTP/2 的多路复用解决的是「帧级并发」问题,把一条 TCP 连接变成了一条可以同时承载多个独立请求流的管道。本文把这个差异拆成可讲、可验证、可落地的几层,既能在面试中讲清楚,也能在线上排障时用得上。

一、先把概念对齐:长连接、Keep-Alive、流水线、多路复用不是一回事

面试中大量混乱来自概念混用。先把四个词的定义钉死:

  • 持久连接(persistent connection):HTTP/1.1 默认行为(RFC 7230 §6.3),一个 TCP 连接在完成一次请求-响应后不立即关闭,后续请求可继续使用。对应头部 Connection: keep-alive(HTTP/1.1 中已是默认值,显式写出只是兼容 HTTP/1.0 代理)。
  • HTTP 流水线(pipelining):客户端不等前一个响应返回就连续发送多个请求,但服务器必须按请求顺序返回响应(RFC 7230 §6.3.2)。这才是「串行语义」的真正来源,也是流水线在实际部署中几乎被浏览器禁用的原因(队头阻塞 + 中间设备兼容性差)。
  • 域分片(domain sharding):既然一个连接上请求是串行的,浏览器就对同一域名开 6 个左右并发连接(Chrome 对同一 origin 的 HTTP/1.1 连接上限为 6)。这是对长连接串行缺陷的「工程绕行」,代价是额外的 TCP 握手、TLS 握手和拥塞控制冷启动。
  • 多路复用(multiplexing):HTTP/2 在一条 TCP 连接上把请求和响应切分成二进制帧(frame),每个帧带 stream ID,不同 stream 的帧可以交错发送、乱序到达,接收端按 stream ID 重组。

面试要点:能主动指出「HTTP/1.1 的 keep-alive 是连接级复用,HTTP/2 的 multiplexing 是流级并发」,这一句话就把层次拉开了。前者复用通道,后者复用通道内的时隙。

二、本质区别一:并发模型不同,队头阻塞的层级不同

HTTP/1.1:应用层队头阻塞(head-of-line blocking)

HTTP/1.1 长连接上,请求-响应严格配对且顺序敏感。设想一个连接上依次发出 A、B、C 三个请求,A 的响应体很大或后端处理慢,B、C 的响应即使已经准备好,也不能越过 A 发送(除非启用 pipelining 且服务器严格按序返回,但即便如此 B、C 仍被 A 阻塞)。这就是应用层 HOL 阻塞。

浏览器给出的解法是并发多连接,代价可以用真实数据说明:一个 HTTPS 请求要经历 TCP 三次握手(1 RTT) + TLS 1.3 握手(1 RTT,TLS 1.2 为 2 RTT)。在 40ms RTT 的链路上,新建一条 TLS 1.3 连接至少 80ms 才发出第一个字节的应用数据,而多开 6 条连接意味着 6 份拥塞控制窗口从初始窗口(Linux 默认 initcwnd=10 个 MSS)重新爬升。

HTTP/2:传输层队头阻塞被消除,但 TCP 层 HOL 依然存在

HTTP/2 把一条连接抽象成若干并发 stream(默认 SETTINGS_MAX_CONCURRENT_STREAMS 通常由服务端下发,nginx 默认 128,Go 的 http2 默认 250),每个 stream 有自己的流量控制窗口。帧可以交错,所以 A 慢不影响 B、C 的帧被发送。

但必须讲清楚一个常被追问的点:HTTP/2 并没有消除 TCP 层的队头阻塞。所有 stream 的帧跑在同一条 TCP 连接上,一旦发生丢包,TCP 的重传会阻塞该连接上后续所有字节的交付,所有 stream 一起卡住。这正是 HTTP/3 改用 QUIC(基于 UDP,每个 stream 独立可靠传输)的根本动因。面试里能补上这一句,说明不是只背了 HTTP/2 的优点。

维度 HTTP/1.1 长连接 HTTP/2 多路复用
复用单位 TCP 连接(连接级) Stream(流级,帧为传输单位)
并发方式 多开连接(每 origin 约 6 条) 单连接内多 stream 并发
应用层 HOL 存在,且无法在协议内解决 消除(stream 间独立)
TCP 层 HOL 存在 仍然存在(HTTP/3 才解决)
头部传输 每次请求重复发送纯文本头 HPACK 静态/动态表压缩
传输格式 文本协议,行分隔 二进制分帧层(frame)

三、本质区别二:协议语义从「文本行」变成「二进制帧 + 流状态机」

这不是实现细节,而是能力的来源。HTTP/1.1 的报文是纯文本,一条连接上同一时刻只存在一个「当前响应」,协议本身没有字段能标识「这段字节属于哪个请求」——只能靠顺序。HTTP/2 引入了分帧层,每个帧头部 9 字节,格式固定:

+-----------------------------------------------+
|                 Length (24)                   |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)                      |
+=+=============================================================+
|                   Frame Payload (0...)                      ...
+---------------------------------------------------------------+

帧类型(Type)包括 DATA(0x0)、HEADERS(0x1)、PRIORITY(0x2)、RST_STREAM(0x3)、SETTINGS(0x4)、PUSH_PROMISE(0x5)、PING(0x6)、GOAWAY(0x7)、WINDOW_UPDATE(0x8)、CONTINUATION(0x9)。有了 stream ID,多路复用才成立;有了 RST_STREAM,才能只取消一个请求而不关闭整条连接——这是 HTTP/1.1 做不到的(HTTP/1.1 里取消一个请求往往直接断 TCP,代价是摧毁同连接上的其它请求)。

用一段可运行的 Python 代码验证 HTTP/2 的多路并发。下面用 httpx(内置 h2 支持)对同一连接发起 5 个并发请求,并打印实际使用的 HTTP 版本:

import asyncio
import httpx


async def main():
    # http2=True 启用 h2 协议栈;limits 里限制连接数为 1,
    # 确保所有请求复用同一条 TCP 连接,从而验证 stream 级复用。
    limits = httpx.Limits(max_connections=1, max_keepalive_connections=1)
    async with httpx.AsyncClient(http2=True, limits=limits, timeout=10.0) as client:
        urls = [f"https://http2.pro/api/v1?i={i}" for i in range(5)]
        tasks = [client.get(u) for u in urls]
        responses = await asyncio.gather(*tasks, return_exceptions=True)

        for r in responses:
            if isinstance(r, Exception):
                print("ERROR:", type(r).__name__, r)
                continue
            print(
                "url=%s http_version=%s status=%s body_len=%d"
                % (r.request.url, r.http_version, r.status_code, len(r.content))
            )


asyncio.run(main())

输出中 http_version=HTTP/2,且 5 个请求共享一条连接。若把 http2=False 并保持 max_connections=1,请求会在这唯一一条连接上排队(HTTP/1.1 串行),整体耗时明显上升——这就是面试里可以口述的对照实验。

用 curl 也能直接观察,注意版本参数:

# 查看协商到的协议版本与连接复用情况
curl -s -o /dev/null -w "http_version=%{http_version} num_connects=%{num_connects}\n" \
  --http2 https://www.cloudflare.com/

# 连续两个 URL 复用同一连接(第二次 num_connects 为 0)
curl -s -o /dev/null -w "http=%{http_version} conn=%{num_connects}\n" \
  --http2 https://www.cloudflare.com/ https://www.cloudflare.com/

如果本地 curl 报 curl: (1) Unsupported protocol 或提示 HTTP/2 client preface string missing or corrupt,先执行 curl -V 看 Features: 行里是否有 HTTP2;没有就需要重新编译带 nghttp2 的 curl(macOS 用 brew install curl,注意 /usr/bin/curl 是系统自带的旧版本,需把 /opt/homebrew/opt/curl/bin 放在 PATH 前面)。

四、本质区别三:连接数假设被反转,工程实践随之改变

这是最容易在面试中出彩的部分——协议设计哲学的反转:

  • HTTP/1.1 时代,连接越多越好:因为单连接串行,所以域分片(把静态资源拆到 static1.example.com、static2.example.com)、雪碧图(CSS sprite)、资源合并(把多个 JS 打包成一个)都是为了减少请求数、绕开串行限制。
  • HTTP/2 时代,连接越少越好:一条连接就能并发,多开连接反而稀释了拥塞窗口、让 HPACK 动态表无法跨连接共享、并且增加 TLS 握手开销。因此 HTTP/2 下应当合并域名、关闭域分片,打包策略也要重新评估(过度打包会破坏缓存粒度)。

实测层面的参数:Linux 上查看当前连接的拥塞控制与初始窗口,可以用

# 查看可用的拥塞控制算法(确认是否为 cubic/bbr)
sysctl net.ipv4.tcp_congestion_control
sysctl net.ipv4.tcp_slow_start_after_idle
# 查看 HTTP/2 相关的 nginx 连接状态(需要 stub_status 模块)
curl -s http://127.0.0.1/nginx_status

nginx 开启 HTTP/2 的写法(注意:http2 是 listen 的参数,不是独立指令):

server {
    listen 443 ssl;
    http2 on;                     # nginx 1.25.1+ 的推荐写法
    # 1.25.1 之前写作:listen 443 ssl http2;

    ssl_certificate     /etc/nginx/cert/fullchain.pem;
    ssl_certificate_key /etc/nginx/cert/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    # 调大并发 stream 上限(默认 128),配合后端能力设置
    http2_max_concurrent_streams 256;

    # 单连接上允许的最大帧大小,默认 16384,最大 16777215
    http2_chunk_size 16k;

    keepalive_timeout 75s;
    keepalive_requests 1000;      # 单连接最多承载的请求数,超过后发 GOAWAY

    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;   # 注意:nginx 到上游默认仍是 HTTP/1.1
        proxy_set_header Connection "";
    }
}

这段配置里有两个高频踩坑点,面试和排障都用得到:

  1. 「我开了 HTTP/2 但后端没变快」:nginx 的 proxy_pass 默认对上游使用 HTTP/1.1(proxy_http_version 默认 1.0,需显式设为 1.1 才能 keepalive)。多路复用在「客户端 ↔ nginx」这一段生效,而「nginx ↔ 上游」仍是短连接串行,瓶颈没动。要端到端 HTTP/2,需上游也支持 h2c 或 h2(如 grpc_pass 场景),或者接受「边缘复用 + 内部连接池」的折中。
  2. http2 on; 报错:nginx 1.25.1 之前没有 http2 指令,写 http2 on; 会得到 nginx: [emerg] unknown directive "http2",应改回 listen 443 ssl http2;。反之在 1.25.1+ 用旧写法会给警告:the "listen ... http2" directive is deprecated, use the "http2" directive instead。

另一个真实报错:浏览器控制台出现 ERR_HTTP2_PROTOCOL_ERROR,且只在部分响应上出现。常见原因是响应头里带了 HTTP/2 不允许的字段(如 Connection、Keep-Alive、Transfer-Encoding,这些是 connection-specific header,RFC 7540 §8.1.2.2 明确禁止),或后端返回了非法的小写化失败的头名。排查方式是抓包看 HEADERS 帧,或者先用 curl --http1.1 对比是否只在 h2 下失败。

五、把差异写成一段能在面试里讲的回答

把上面三层压成一段结构化的口述(约 90 秒),基本可以覆盖面试官追问:

「HTTP/1.1 的长连接是连接级别的复用:一个 TCP 连接可以跑多次请求-响应,但同一时刻只能有一个在途响应,请求是串行的,所以存在应用层队头阻塞。浏览器的应对是每个 origin 开 6 条左右连接,代价是握手和拥塞控制冷启动。
HTTP/2 的多路复用是流级别的并发:它引入二进制分帧层,把请求响应切成带 stream ID 的帧,多个 stream 的帧在同一条连接上交错传输,接收端按 stream ID 重组,所以消除了应用层 HOL,还能用 RST_STREAM 单独取消一个请求。代价是多路复用共享同一条 TCP 连接,一旦丢包,TCP 层重传会阻塞所有 stream,这个 TCP 级 HOL 直到 HTTP/3 用 QUIC 才解决。
所以二者的本质区别不是快慢,而是并发模型:HTTP/1.1 是『连接复用 + 应用层串行』,HTTP/2 是『单连接内的帧级并发』。这直接反转了工程实践——HTTP/1.1 时代靠域分片和资源合并减少连接数,HTTP/2 时代应该合并域名、关闭分片,因为连接越少、拥塞窗口和 HPACK 动态表越集中,收益越大。」

如果面试官继续追问「那 HTTP/2 是不是一定比 HTTP/1.1 快」,正确的态度是区分场景:在高 RTT、多小资源的页面上,HTTP/2 收益明显;在单条大文件下载上,两者差别很小(甚至因为分帧开销略慢),因为瓶颈在带宽和 TCP 拥塞控制,而不在请求并发度。这种「知道它什么时候没用」的回答,通常比一味夸协议更能拿到分。

小结

记住三条主线即可:其一,复用粒度从「连接」下沉到「stream」,这是二进制分帧层带来的能力;其二,队头阻塞从应用层搬到了 TCP 层,HTTP/2 消除前者但无法消除后者,这是 HTTP/3 的起点;其三,工程假设反转——HTTP/1.1 拼连接数,HTTP/2 拼单连接质量,所以域分片、资源合并、打包策略都要跟着改。把这三点讲清楚,比背任何性能数字都有说服力。

FAQ

Q1:HTTP/1.1 的 Connection: keep-alive 和 HTTP/2 的连接复用能同时存在吗?

不能混着理解。HTTP/2 中 Connection 是禁用头(RFC 7540 §8.1.2.2),连接是长期存在的,不存在「关闭 keep-alive」这回事。如果你在 HTTP/2 响应里下发 Connection: keep-alive,严格实现的客户端会直接判定协议错误,报 ERR_HTTP2_PROTOCOL_ERROR。清理中间件(尤其是老版本 Java Servlet Filter、PHP 框架)主动注入的这类头即可。

Q2:HTTP/2 只用一条连接,会不会更慢?

取决于瓶颈位置。单连接意味着所有 stream 共享一个拥塞窗口,在丢包率高的链路上(比如移动网络),TCP 重传会拖慢全部 stream,此时多连接反而因为拥塞窗口独立而鲁棒性更好——这也是部分 CDN 在弱网场景下仍保留「按资源类型拆分少量连接」策略的原因。另外 HTTP/2 的 PRIORITY 帧与依赖树在实际实现中大多被简化或忽略(Chrome 已废弃对优先级的严格支持),不要指望靠它做精细调度。

Q3:为什么抓包看 HTTP/2 的 HEADERS 帧里读不到明文头?

因为 HPACK 压缩。头部字段被编码进静态表(61 个预定义条目)和动态表,用索引号 + Huffman 编码表示。想还原明文,Wireshark 需要配置 TLS 解密(导入 SSLKEYLOGFILE 生成的密钥日志,或提供服务器私钥),然后在 Statistics → HTTP2 里查看解压后的头部;命令行可以用 nghttp -nv https://example.com(nghttp2 客户端)直接打印可读的帧序列。

Q4:服务端返回 GOAWAY 是什么情况?

GOAWAY(帧类型 0x7)表示服务端要关闭连接,并携带 Last-Stream-ID,客户端应当重试 ID 大于该值的请求。常见触发:keepalive_requests 达到上限、http2_max_concurrent_streams 被打满后服务端主动收缩、优雅重启(nginx reload)。如果日志里频繁出现 GOAWAY,先看客户端是否在单连接上打了过多并发 stream,再考虑调大 http2_max_concurrent_streams 或做客户端侧限流。

Q5:怎么快速判断线上到底跑的是 HTTP/1.1 还是 HTTP/2?

用 curl -sI --http2 https://域名 | head -1 看状态行是 HTTP/2 200 还是 HTTP/1.1 200;浏览器 DevTools 的 Network 面板打开「Protocol」列,会显示 h2 / http/1.1 / h3。若显示 http/1.1 却以为自己开了 h2,八成是 CDN 或负载均衡层没开 ALPN,检查 TLS 握手协商的 ALPN 是否为 h2(openssl s_client -alpn h2 -connect 域名:443,看输出里 ALPN protocol: h2)。