第 7 章 连接管理:一场持续 20 年的性能战争

你的接口慢,90% 的锅不在你的业务逻辑,而在”连接”。理解了连接,你才真正理解”性能”这个词在 HTTP 里的分量。


7.0 引子:本地 10ms,线上 3 秒

你:墨叔,有个邪门的事。我这个接口,本地压测 10 毫秒,一上线就 3 秒。我加了索引、加了缓存、优化了 SQL,还是慢。

老张:我帮你看了。你线上每个请求都新建一个连接,每次都要完整走一遍 TCP 握手 + TLS 握手。

你:握手?那不是几毫秒的事吗?

老张:几毫秒?从北京到你的服务器机房,光一个来回(RTT)就要 40 毫秒。TCP 握手 1.5 个 RTT,TLS 握手又 1-2 个 RTT。加起来,光握手就 100 多毫秒,还不算 TCP 慢启动。

你:……那我本地为什么快?

墨叔:因为本地 RTT 是 0.1 毫秒。你的”慢”,根本不是慢在代码,是慢在”建连”。

这一章,我们把”连接”这件被大多数人忽略的事,彻底讲透。


7.1 先把问题问对:一次 HTTP 请求,到底花了多少时间在”建连”上

墨叔:问你一个基础问题:一次 HTTPS 请求,从你的代码发出,到服务器开始处理你的业务逻辑,中间要经历什么?

你:……DNS 解析,TCP 握手,TLS 握手,然后发请求。

墨叔:对。我们把这些步骤的时间成本算清楚。假设 RTT = 40ms(北京到上海的典型值):

1. DNS 解析:查 example.com 的 IP          (可能 0ms 有缓存,可能 40ms+ 无缓存)
2. TCP 三次握手:SYN → SYN-ACK → ACK       (1.5 个 RTT = 60ms)
3. TLS 握手:ClientHello → ... → Finished  (约 2 个 RTT = 80ms)
4. 发送 HTTP 请求                          (0.5 个 RTT)
5. 服务器处理 + 响应                        (取决于业务)

你:所以,光”握手”(步骤 2+3)就要 140ms,在业务逻辑还没跑之前,就花掉了 140ms?

墨叔:对。而且还有两个更狠的隐藏成本:

隐藏成本一:TCP 慢启动(Slow Start)。 TCP 不是一开始就全速发送的。它会从很小的拥塞窗口(比如 10 个 MSS)开始,指数增长地试探网络的承载能力。这意味着:一个 100KB 的响应,可能不是”一个 RTT 内发完”,而是被慢启动拖成了好几个 RTT。

隐藏成本二:连接是”一次性消耗品”(如果没有复用)。 如果每个请求都新建连接,那么每个请求都要重复支付上面这 140ms + 慢启动的代价。

你:所以”连接复用”是性能的命根子?

墨叔:对。整个 HTTP 连接管理的历史,就是一部**“如何复用连接”的斗争史**。我们从头看。


7.2 从短连接到长连接:HTTP/1.0 → HTTP/1.1

墨叔:最早的 HTTP(0.9/1.0),每个请求都是”一次性”的:

客户端                              服务器
  |-- 建立 TCP 连接 ------------------>|
  |-- GET /a HTTP/1.0 --------------->|
  |<-- 200 OK(a 的内容)-------------|
  |-- 关闭连接(Connection: close)----X|

发完就断。 下一个请求,重新建连、重新握手。

你:这么浪费?那一个网页有 50 个资源,就要握手 50 次?

墨叔:对。所以当年打开一个网页慢得离谱。于是大家开始想办法复用连接。

HTTP/1.0 的权宜之计:加一个头 Connection: keep-alive,意思是”这个连接先别关,我还要用”。但这是个非标准的扩展——不是所有服务器都支持,客户端发了 keep-alive,服务器可能不理你,照样关。

HTTP/1.1 的正规化:默认就是长连接(persistent connection),除非显式 Connection: close。

你:那 HTTP/1.1 长连接下,一个连接能复用多久?

墨叔:这是个关键问题,答案直接关系到很多线上故障。“能复用多久”取决于三个东西的博弈:

  1. 服务器:可能会在空闲一定时间后主动关(IdleTimeout)。
  2. 中间设备(LB、NAT、防火墙):它们也会关!而且它们的超时往往比你短。这是个大坑,后面讲。
  3. 客户端:连接池里有”最大空闲连接数""空闲超时”等参数。

所以长连接不是”永久连接”,是”在各方都同意的时间窗口内复用”。


7.3 队头阻塞:HTTP/1.1 的癌症

墨叔:长连接解决了”重复握手”,但带来了一个新问题,而且是 HTTP/1.1 的”癌症”——队头阻塞(Head-of-Line Blocking,HOL)。

你:队头阻塞?就是”排在前面的卡住,后面的全堵死”?

墨叔:对。在 HTTP/1.1 的一个长连接上,请求是串行的:

连接(一条流水线,只能串行)
[请求1] → [响应1] → [请求2] → [响应2] → [请求3] → [响应3]

你必须等请求 1 的响应完整返回,才能发请求 2。如果请求 1 很慢(比如一个大文件下载),请求 2、3 就全被堵在后面。

你:浏览器不是可以开多个连接吗?

墨叔:对,浏览器会为同一个域名开 6 个连接(HTTP/1.1 时代的默认值)。但:

  1. 6 个连接意味着 6 次握手(浪费)。
  2. 6 个连接还是不够——一个现代页面有 100+ 个资源请求。
  3. 每个连接内部,仍然是串行的——每个连接都有它自己的队头阻塞。

你:所以当年大家是怎么绕过队头阻塞的?

墨叔:几个办法,各有各的丑:

办法一:域名分片(Domain Sharding)。 把资源分散到多个域名(static1.example.com、static2.example.com…),让浏览器为每个域名开 6 个连接。6 个域名 = 36 个连接。

  • 代价:DNS 查询变多、连接变多、运维变复杂。而且这个”优化”在 HTTP/2 时代反而成了反优化(第 12 章讲)。

办法二:合并请求(sprites、concat)。 把多个小图片合成一张雪碧图(CSS sprite),把多个 JS/CSS 合并成一个文件。

  • 代价:缓存粒度变粗(改一个图标,整个雪碧图失效)、构建变复杂。

办法三:管线化(Pipelining)。 HTTP/1.1 理论支持”不用等响应就连续发多个请求”:

[请求1][请求2][请求3] → [响应1][响应2][响应3]

你:这个听起来能解决队头阻塞啊?

墨叔:理论上能,但实际上管线化彻底失败了。原因:

  1. 响应必须按顺序返回——服务器可以并发处理,但返回时必须”先回请求 1 的响应,再回请求 2 的”(因为没有”请求 ID”来标识响应属于谁)。所以队头阻塞还在——如果请求 1 慢,它的响应慢,后面的响应也得等着按顺序发。
  2. 实现混乱:很多服务器、代理不支持管线化,有的甚至会搞错响应的顺序。
  3. 没有请求 ID 是硬伤:不能乱序响应,就无法真正并发。

所以管线化从出生到死亡,几乎没有被真正使用过。 它成了一个”协议里有,但现实里没人用”的僵尸特性。

你:这个”僵尸特性”的教训是什么?

墨叔:好问题。教训是——

一个解决性能问题的机制,如果它”不能乱序”,就治不了队头阻塞的根。 队头阻塞的根源是”顺序耦合”——响应必须按请求的顺序返回。要根治,就得给每个请求一个独立的 ID,让响应可以乱序返回。

这就是 HTTP/2 的核心思路(第 12 章):流(Stream)就是”带 ID 的独立通道”。你在这里埋下的这个理解,第 12 章会收获。


7.4 连接池:Go 里最容易被配置错的东西

墨叔:回到 Go。你线上”每个请求新建连接”的 bug,本质是没有用连接池(或连接池配错了)。

Go 的 http.Client 底层有个 Transport,它维护连接池。关键参数:

client := &http.Client{
    Transport: &http.Transport{
        MaxIdleConns:        100,              // 全局最大空闲连接数(默认 100)
        MaxIdleConnsPerHost: 10,               // 每个 Host 最大空闲连接数(默认 2!)
        MaxConnsPerHost:     0,                // 每个 Host 最大连接数(0 = 不限制)
        IdleConnTimeout:     90 * time.Second, // 空闲连接保留多久(默认 90s)
    },
    Timeout: 10 * time.Second,                 // 整个请求的超时
}

你:这些参数里,哪个最容易踩坑?

墨叔:MaxIdleConnsPerHost 默认值是 2! 这是 Go 程序员最容易踩的坑之一。

想象你有一个高并发服务,同时要往同一个下游发 100 个请求。但 MaxIdleConnsPerHost = 2,意味着连接池里最多只保留 2 个空闲连接给这个 Host。结果:

  • 前 2 个请求用完了连接,把连接放回池子。
  • 第 3 个请求来了,复用池子里的连接。
  • 但如果同一时刻有 100 个并发请求,池子里只有 2 个连接,剩下 98 个请求只能新建连接(然后用完立即关闭,因为池子满了)。

结果就是你线上的现象:大量新建连接、大量握手、性能崩坏。

你:所以高并发场景,要把 MaxIdleConnsPerHost 调大?

墨叔:对,调到接近你的峰值并发数。比如你知道下游峰值并发 50,就设 MaxIdleConnsPerHost: 50。

但注意一个反过来的坑:调太大也有问题——空闲连接太多,会占用下游服务器的资源(每个连接在服务器端也占一个文件描述符和内存)。所以是”够用就好,别贪多”。

连接池的另一个大坑:body 没读完,连接就废了

墨叔:还有第二个坑,坑了 90% 的 Go 程序员:

resp, _ := client.Get("http://example.com/big")
// 忘记读 resp.Body,也忘记 Close
 
// 或者只 Close 不读:
resp.Body.Close()  // 这会让连接"不能复用",下次得重新握手

你:为什么 Close 了还不能复用?

墨叔:因为 HTTP 是流式的,响应的 body 可能没读完(比如服务器还在发)。如果客户端直接 Close,Go 为了避免污染下一个请求(上一个响应的残留数据混进来),会主动关闭这个连接,而不是放回池子复用。

正确做法是:

resp, _ := client.Get(url)
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)  // 把 body 读干净,连接才能复用

你:所以”读完 body + Close”才是让连接复用的正确姿势。

墨叔:对。不读 body 就 Close,等于放弃了连接复用,每个请求都重新握手。 这又是一个”性能问题藏在协议细节里”的例子。


7.5 幽灵连接:长连接与中间设备的致命冲突

墨叔:现在讲一个特别阴险的线上故障——幽灵连接。

场景:你的服务 A 通过长连接调用服务 B,一切正常。但偶尔会有一批请求突然失败,报 connection reset by peer(连接被对方重置),然后自动恢复。

排查:抓包发现,A 拿着一个”它以为还活着”的连接去发请求,结果 B 那边这个连接早就被关了。

你:谁关的?B 吗?

墨叔:中间设备关的。 可能是:

  1. 负载均衡器(LB)的空闲超时:比如 AWS 的经典 ELB 空闲超时是 60 秒。连接空闲 60 秒,LB 就静默地把它关掉(还不通知双方)。
  2. NAT/防火墙的会话超时:NAT 设备维护”连接映射表”,空闲久了就把映射清掉。
  3. 服务器 B 的 IdleTimeout:B 自己也可能关了。

关键问题是:中间设备关连接时,往往不发送 FIN/RST 包(或者发了但被别的设备吞了)。于是客户端 A 根本不知道连接已经死了,它还傻乎乎地复用这个”尸体连接”去发请求,结果请求发出去,要么石沉大海,要么被 RST。

你:所以叫”幽灵连接”——连接看着活着,实际已经死了。

墨叔:对。这个问题的本质是:

TCP 连接是一个”有状态”的东西,它的状态分布在两端的操作系统里。中间设备如果”私自”把连接关了而不通知,两端的状态就”漂移”了——一端以为活着,一端已经死了。

你:那怎么解决?

墨叔:三个层次的解法:

  1. 客户端连接池的空闲超时,要设得比中间设备的超时更短。 比如 LB 是 60 秒,你的 IdleConnTimeout 就设 30 秒。这样你的连接总是”先于”中间设备关闭,就永远不会拿到尸体连接。

  2. TCP keepalive:让操作系统定期发探测包,检测连接是否还活着。但默认的 keepalive 间隔太长(Linux 默认 2 小时!),对这个问题几乎没用。要手动调小(比如 30 秒)。

  3. 应用层心跳:最可靠的方式(第 14 章 WebSocket 的 ping/pong 就是干这个的)。

你:所以那个”偶尔失败然后恢复”,就是”用到了尸体连接→失败→客户端重建连接→恢复”的循环?

墨叔:精确。这种偶发故障最难查,因为它是”时间相关”的——只在连接空闲了恰好超过某个阈值时发生。所以压测测不出来(压测连接一直是热的),只在低峰期出现。


7.6 设计者视角:为什么连接这么难搞

墨叔:现在退后一步,问一个设计问题:为什么”连接”这件事,会这么难搞?

你:因为……连接是有状态的,而状态会漂移?

墨叔:对,但更深一层。连接难搞的根源是:

TCP 连接是一个”端到端的、有状态的、跨越多个设备的”抽象,而现实中它要穿过一堆”无状态的、自私的”中间设备。

  • 端到端:连接是”客户端 A ↔ 服务器 B”之间的。
  • 有状态:两端各自维护状态(序列号、缓冲区、连接表)。
  • 跨越多个设备:数据要穿过 NAT、防火墙、LB——这些中间设备有自己的超时、自己的状态、自己的私心。

冲突就来了:

  • A 和 B 想”复用连接”(省握手),于是让连接长时间存活。
  • 中间设备想”省资源”,于是空闲久了就偷偷关掉连接。

于是”长连接”成了一场三方博弈:A 想留着,B 想留着,但中间设备不想。协议无法强制中间设备”别关我的连接”,只能在两端做各种防御(短超时、心跳、重连)。

这引出一个更深的教训,也和第 5 章”逐跳 vs 端到端”呼应:

HTTP 的分层模型,假设了”中间设备是透明的、忠实的转发者”。但现实是,中间设备是有状态的、有自己利益诉求的、会悄悄做决定的。 所有”连接管理”的复杂度,本质上都是”理想的分层模型”和”现实的中间设备”之间的鸿沟。

这就是为什么,再漂亮的协议设计,落地到真实网络里,都要为”中间设备的现实”打一堆补丁。


7.7 Go 落地:一个正确的客户端配置

墨叔:把这一章的东西,落到一个”正确配置的” Go HTTP 客户端上:

var httpClient = &http.Client{
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 50,               // 匹配你的峰值并发
        IdleConnTimeout:     30 * time.Second, // 比 LB 的空闲超时短
        DialContext: (&net.Dialer{
            Timeout:   5 * time.Second,        // 建立 TCP 连接的超时
            KeepAlive: 30 * time.Second,       // TCP keepalive 间隔
        }).DialContext,
        TLSHandshakeTimeout:   5 * time.Second, // TLS 握手超时
        ResponseHeaderTimeout: 10 * time.Second, // 等响应头的超时(最常被忽略)
    },
    Timeout: 30 * time.Second, // 整个请求的兜底超时
}
 
resp, err := httpClient.Get(url)
if err != nil {
    return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body) // 读干净,让连接可复用

你:这几个超时的区别,能不能一句话说清?

墨叔:

超时管什么不设的后果
DialContext.TimeoutTCP 建连建连失败时无限等
TLSHandshakeTimeoutTLS 握手握手卡死时无限等
ResponseHeaderTimeout等响应头对方接受了请求但永远不响应,你一直等
IdleConnTimeout空闲连接保留多久复用尸体连接(幽灵连接)
Client.Timeout整个请求(含读 body)请求永久挂起

你:ResponseHeaderTimeout 我确实从没设过。

墨叔:这是最容易被忽略、也最关键的。它防的是”对方把连接占住但就是不回话”的慢速攻击。不设它,你的连接池会被”半死不活的请求”慢慢耗尽。


7.8 排查清单:连接类性能/故障定位

第 1 步:确认是不是"建连"问题
        → 第一个请求慢、后续快? = 握手/慢启动问题
        → 看 RTT(ping)、看握手次数(抓包数 SYN)

第 2 步:连接复用了吗?
        → 看服务器连接数监控,是否频繁新建/断开
        → Go:检查是否读了 body、MaxIdleConnsPerHost 是否太小

第 3 步:是"幽灵连接"吗?
        → 偶发 reset、低峰期出现、自动恢复 = 中间设备静默断连
        → 对比你的 IdleConnTimeout 和 LB 的空闲超时

第 4 步:连接池是否耗尽?
        → "too many open files"、连接数打满 = 泄漏或池子太小

第 5 步:超时设全了吗?
        → 五个超时,缺哪个补哪个

思考题

第 1 题(概念)

HTTP/1.1 的”管线化(Pipelining)“为什么彻底失败了?请说出至少两个原因,并指出它失败的根本原因是什么(提示:和”乱序”有关)。

第 2 题(概念)

Go 里,resp.Body.Close() 之前不读 body,会导致连接无法复用。请解释为什么(提示:从”流式响应”和”残留数据”的角度)。

第 3 题(设计)

你的服务要作为一个 HTTP 客户端,高频调用一个下游服务(峰值并发 100,下游前面有一个空闲超时 60 秒的负载均衡器)。

请给出你的连接池配置方案,并解释每个参数为什么这么设。特别注意:如何避免”幽灵连接”问题?

第 4 题(生活类比)

墨叔:打电话,和 HTTP 的长连接,有什么相似之处?

提示:想想这些场景——

  1. 你给客服打电话,每次说完一句话就挂断,下句话再打过去——这对应什么?
  2. 你打通了电话,但一直不说话,对方客服可能会挂掉——这对应什么?
  3. 你以为电话还通着,其实对方早就挂断了,你还在对着空气说话——这对应什么?

第 5 题(更深一层的发问)

队头阻塞(7.3 节)的根本原因,是”响应必须按请求顺序返回,因为响应没有 ID 来标识它属于谁”。

现在请你思考:

  1. 如果给每个请求/响应都加上 ID,让响应可以乱序返回,队头阻塞就解决了吗?在 TCP 这一层,还有没有”队头阻塞”?
  2. 提示:TCP 是有序字节流,如果 TCP 里有一个数据包丢失了,后面的包怎么办?
  3. 这个答案,会让你明白为什么”HTTP/2 解决了队头阻塞”这个说法只说对了一半——请说出那”没被解决的一半”是什么。(这是第 13 章 HTTP/3 的引子)

参考答案

第 1 题答案

原因一:响应不能乱序。 管线化允许客户端”不等待就连续发请求”,但服务器返回时必须按请求顺序返回响应。因为 HTTP/1.1 的响应没有”请求 ID”,服务器没法说”这个响应是给请求 2 的”。所以只要请求 1 慢,它的响应就慢,后面的响应即使早就处理完了,也得排队等。

原因二:实现混乱,兼容性差。 很多代理、服务器根本不支持管线化,或者支持得不对(搞错响应顺序)。客户端发管线化请求,可能收到错乱的结果,或者被直接拒绝。

根本原因:没有”乱序”能力。 队头阻塞的本质是”顺序耦合”——响应被锁死在请求的顺序上。而”顺序耦合”的根源是”响应无法标识自己属于哪个请求”。所以,只要协议不给每个请求/响应一个独立的 ID,就永远无法乱序,队头阻塞就永远存在。管线化只是”让请求可以提前出发”,但没解决”响应必须排队返回”,所以它治标不治本。

(这直接指向 HTTP/2 的”流 + 流 ID”设计,第 12 章。)

第 2 题答案

核心原因是:HTTP 响应是流式的,body 可能没读完,而连接是”字节流”的复用载体。

具体机制:

  1. 客户端发起请求,服务器返回响应。响应的 body 可能很大,服务器边生成边发送(流式)。
  2. 如果你不读 body 就 Close,意味着”这个响应的 body 我还没消费完,连接上可能还残留着没读的字节”。
  3. 如果 Go 把这个连接放回池子复用,那么下一个请求会读到上一个响应残留的字节——数据串了,灾难。
  4. 所以 Go 的策略是:没读干净 body 的连接,直接关闭,不放回池子——宁可用”重建连接”的代价,换取”数据不错乱”的安全。

所以正确姿势:defer resp.Body.Close() + io.Copy(io.Discard, resp.Body),把 body 读干净,连接才能安全复用。

更深一层:这又是一个”性能和正确性”的权衡。Go 选择”读不干净就关连接”,本质是用”多一次握手”的成本,规避”数据错乱”的风险。这和第 1 章、第 3 章的很多选择一样——在”错了会死人”的地方,宁可牺牲性能也要保证正确。

第 3 题答案

Transport: &http.Transport{
    MaxIdleConns:        100,
    MaxIdleConnsPerHost: 100,               // 匹配峰值并发 100
    IdleConnTimeout:     30 * time.Second,  // 关键!比 LB 的 60s 短
    DialContext: (&net.Dialer{
        Timeout:   5 * time.Second,
        KeepAlive: 30 * time.Second,        // TCP keepalive 也调短
    }).DialContext,
    TLSHandshakeTimeout:   5 * time.Second,
    ResponseHeaderTimeout: 10 * time.Second,
}

每个参数的理由:

  • MaxIdleConnsPerHost: 100:匹配峰值并发 100,避免”池子不够用 → 频繁新建连接”(7.4 节的坑)。
  • IdleConnTimeout: 30s < LB 的 60s:这是防幽灵连接的关键。让连接”总是先于 LB 关闭”,客户端永远拿不到尸体连接。
  • KeepAlive: 30s:TCP 层的保活探测,30 秒探测一次,及时发现死连接(默认 2 小时太慢,没用)。
  • ResponseHeaderTimeout: 10s:防”对方接受但不响应”的慢速攻击。

避免幽灵连接的核心思路(这是本题的要点):

让客户端的连接生命周期,严格”短于”沿途任何中间设备的空闲超时。 这样连接总是在”中间设备想关它之前”就被客户端自己正常关闭了,永远不会出现”中间设备偷偷关、客户端不知道”的状态漂移。

补充:如果下游服务响应偶发很慢,还要配合重试 + 幂等(第 3 章)——因为连接断了之后,有些请求其实已经执行了,盲目重试会重复执行。

第 4 题答案

电话场景对应 HTTP
每说一句就挂,下句再打短连接(HTTP/1.0,每个请求新建连接)——每次都要重新”拨号”(握手),效率极低
打通了不说话,对方挂掉中间设备/服务器的空闲超时——连接空闲久了被静默关闭
以为还通着,对方早挂了,对着空气说话幽灵连接——连接已死,客户端不知情,复用尸体连接发请求,石沉大海

关键洞察(本题的核心):

打电话和 HTTP 长连接,本质都是”在不可靠的、有中间环节的通道上,维持一个有状态的关系”。它们的共同困境是:

“关系”是有状态的(通着/断了),但这个状态分布在两端,中间还隔着不透明的环节(电话交换机 / NAT、防火墙、LB)。任何一方(或中间环节)单方面改变状态,另一方都不知道。

所以打电话时要靠”喂喂喂”来确认对方还在,HTTP 长连接要靠”心跳/keepalive/短超时”来确认连接还活着。这是所有”有状态的长连接”(无论是电话、TCP、还是 WebSocket)都必须面对的根本问题。

第 5 题答案

1. 加了 ID、响应可以乱序,队头阻塞就解决了吗?在 TCP 层还有没有?

没有完全解决。TCP 层还有队头阻塞。

2. TCP 层为什么还有队头阻塞:

因为 TCP 是”可靠的有序字节流”。它保证两点:

  • 可靠:每个字节都要到达,丢了的要重传。
  • 有序:字节按顺序交付给应用层。

假设你在一个 TCP 连接上并发传 100 个”HTTP/2 流”(每个流有独立 ID,可以乱序)。这些流的字节共享同一个 TCP 连接,在 TCP 层它们是一串连续的字节。

现在,如果这串字节里,属于”流 5”的一个数据包在网络上丢了:

  • TCP 发现丢包,会重传这个包。
  • 但 TCP 是”有序”的——丢的那个包后面的所有包(即使它们属于别的流,即使它们已经到达了接收端)都必须”卡住”,等丢失的包重传到达、按顺序排好之后,才能一起交给应用层。

所以:流 5 丢了一个包,流 1、2、3、4、6… 全被连累,一起卡住。 这就是TCP 层的队头阻塞——它不管你上层是什么流、什么 ID,它只管”字节的有序性”,一丢包,后面全堵。

3. 为什么”HTTP/2 解决了队头阻塞”只说对了一半:

  • 对的一半:HTTP/2 用”流 + 流 ID”解决了 HTTP 层(应用层)的队头阻塞——100 个请求可以在一个连接上并发,响应可以乱序,不再有”请求 1 慢导致请求 2 全堵”的问题。
  • 没解决的一半:HTTP/2 跑在 TCP 上,而 TCP 的”有序可靠”特性,决定了一个包丢失,整个连接的所有流都被卡住。这是 TCP 层的队头阻塞,HTTP/2 无能为力。

所以:HTTP/2 只是把队头阻塞从”应用层”下沉到了”传输层”,并没有消灭它,只是让它变得更隐蔽了。在弱网环境(丢包率高)下,HTTP/2 甚至可能比 HTTP/1.1(多开连接)表现更差——因为 HTTP/1.1 有 6 个连接,一个连接丢包只堵那一个,而 HTTP/2 只有一个连接,一丢包全堵。

这就是 HTTP/3 诞生的根本动因:既然 TCP 的”有序可靠”是队头阻塞的根源,那就换掉 TCP,用 UDP 自己实现一个”可以乱序、每流独立重传”的可靠传输(QUIC)。第 13 章会详细展开。


一页纸小结

慢,往往慢在建连:DNS + TCP 握手(1.5 RTT)+ TLS 握手(2 RTT)+ TCP 慢启动。RTT 一远,光握手就上百毫秒。

长连接是复用连接的斗争:HTTP/1.0 短连接 → HTTP/1.1 默认长连接。

队头阻塞是 HTTP/1.1 的癌症:响应必须按顺序返回(因为没 ID),一个慢请求堵死整个连接。管线化失败,因为它不能乱序。

连接池的坑:MaxIdleConnsPerHost 默认 2(太小);不读 body 就 Close 会放弃复用。

幽灵连接:中间设备(LB/NAT)静默断连,客户端不知情,复用尸体连接。解法:IdleConnTimeout 设得比中间设备短 + keepalive + 心跳。

根本困境:理想的分层模型假设中间设备透明,现实是中间设备有状态、有私心、会悄悄做决定。

埋下的伏笔:队头阻塞的根治需要”乱序 + 每流独立”,HTTP/2 解决了应用层,没解决 TCP 层 → 引出 HTTP/3。


下一章:现在我要带你进入 HTTP 里最复杂、最被低估、也最能暴露一个工程师功力的子系统——缓存。我要问你一个能让无数人半夜惊醒的问题:为什么你明明什么都没配,你的响应还是被浏览器缓存了? 以及那个能导致用户 A 看到用户 B 数据的事故,是怎么发生的。