先回顾一下
前面,我们在《Web 性能优化第一课:真正影响速度的是延迟》讲到,打开一个网页往往要经历几十上百次”请求-响应”的往返,而每一次往返都要付出延迟的时间成本。
这篇文章我们要讲的”连接复用”,正是从”减少往返次数”这条思路上,能拿到的第一块、也是最实在的一块优化红利。
在讲连接复用之前,得先说清楚一件事:每一次网络请求,并不是你发个消息过去,对方回个消息过来这么简单,在这之前,双方要先”建立连接”。而建立连接这件事本身,就要消耗好几趟往返。
打电话前,得先拨号
我们还是用一个生活里的场景来理解。
你给一个朋友打电话,不是你一开口说话对方就能听见。你得先拨号,等电话接通,对方接起来说”喂”,你才能开始说正事。这个”拨号—接通—喂”的过程,本身就要花时间,而且和你后面要聊的内容是两码事——哪怕你只想说一句”晚上一起吃饭吗”,也得先完成这套接通的流程。
浏览器和服务器之间的通信也是一样的道理。在真正传输网页数据之前,浏览器要先和服务器完成一次”TCP三次握手”,建立起一条可靠的连接通道;如果这个网站用的是HTTPS(现在绝大多数网站都是),还要再加一层”TLS握手”,用来协商加密方式、交换密钥,确保之后传输的数据是加密的、安全的。
这两层握手加起来,少则1到2次往返,多则3到4次往返。假设你和服务器之间单程延迟是50毫秒,一个来回就是100毫秒,握手加起来轻松花掉200到400毫秒——而这还没有开始传输真正的网页内容。
问题来了:如果每发一个请求,都要重新走一遍这个”拨号接通”的流程,那这个成本也太高了。一个网页里可能要请求HTML、CSS、JavaScript、图片,加起来几十个文件,如果每个文件都单独打一次电话、挂了再打下一个,光是”拨号”的时间加起来就相当可观。
这就是连接复用要解决的问题:能不能打通一次电话之后,把要说的事情一件一件都在这通电话里说完,而不是每说一句话就挂断重打?
HTTP/1.1 时代:Keep-Alive,把电话线留着别挂
在早期的 HTTP/1.0 协议里,默认情况下,浏览器每发一个请求,就要新建一条TCP连接,请求处理完了,这条连接就关掉。想请求下一个文件,再重新握手建一条新连接。这种模式下,前面提到的握手成本,是要为每一个文件都重复付一遍的,非常浪费。
到了 HTTP/1.1,引入了一个关键机制,叫 Keep-Alive(持久连接)。简单说,就是浏览器和服务器约定好:这条连接用完了先别挂,留着,后面还有请求就继续用这条线。
打个比方,就像你和朋友通电话,说完第一件事之后,你们俩都不挂电话,而是说”先别挂,我还有事跟你说”,于是免去了重新拨号的功夫,直接在同一通电话里把第二件事、第三件事都说完。
这样一来,同一个域名下的多个请求,可以复用同一条TCP连接,不需要每次都重新握手,省下了大量原本要浪费在”拨号接通”上的时间。
不过 HTTP/1.1 的连接复用有一个明显的短板:同一条连接上的请求,是需要排队的,前一个请求的响应没回来,后一个请求就得等着,这个现象业内一般称为”队头阻塞”(Head-of-Line Blocking)。
还是拿打电话类比:虽然电话线没挂,但你们俩说话是一句接一句的,你问了一个问题,对方还没回答完,你没法插队先问下一个问题。
为了缓解这个问题,浏览器一般会同时和同一个域名建立多条并行的TCP连接(通常是6条左右),相当于同时打好几通电话,分头把事情说完。
但这也带来了新的代价——连接数量越多,每条连接握手的开销、服务器维护连接的资源消耗也就越大,而且6条是有上限的,请求数量一旦超过这个数,还是得排队等。
HTTP/2 时代:一条线,想说几件事同时说
那有没有办法既保留”一条连接”,又不用排队等待呢?这正是 HTTP/2 要解决的问题。
HTTP/2 引入了一个叫**多路复用(Multiplexing)**的机制。它的思路是:不再要求同一条连接上的请求必须一句说完才能说下一句,而是把每次要传的数据切成一个个小的”帧”(Frame),每个帧上都带个标记,说明自己属于哪个请求。这样,多个请求的数据帧可以在同一条连接上交错着发送,到了对方那边,再根据标记把属于同一个请求的帧重新拼接起来。
继续用打电话类比的话,这就好比你和对方通话时,不再是”一件事说完再说下一件”,而是两个人可以同时讨论好几个话题,把每句话都贴上”这是关于话题A的”或者”这是关于话题B的”标签,说完之后各自按标签把内容整理归类,该聊天聊天,互不耽误。
这带来的好处是实打实的:只需要建立一条TCP连接,就可以并行处理多个请求,既省去了建多条连接的握手开销,又不会出现请求之间互相排队等待的问题。
HTTP/2 没解决完的问题:TCP层面的队头阻塞
不过HTTP/2也不是终点。它解决的是”应用层”的排队问题——也就是你不用一个请求接一个请求地傻等了。但它依然是建立在TCP这个”底层管道”之上的,而TCP协议本身有个特点:它要求数据必须按发送顺序,一个不漏、按顺序到达接收方。
这意味着什么呢?如果这一条TCP连接中,有一个数据包在传输过程中丢失了(网络传输中丢包是很常见的事情),那么哪怕后面的数据包已经先一步到达了,接收方也不能提前处理它们,必须等着前面丢失的那个包重新传过来、按顺序补齐了,才能继续处理后面的内容。
这就好比虽然你们俩在电话里可以同时聊好几个话题了,但假如通话过程中有一句话没听清、需要对方重复一遍,那这一段时间里,哪怕其他话题的内容已经说到你耳朵里了,你也没法往下处理——因为底层的”电话线”本身要求内容必须严格按顺序对齐,一旦有一处卡住,后面的都跟着卡住。
这就是所谓的”TCP层面的队头阻塞”,它和前面HTTP/1.1里”请求需要排队”的队头阻塞不是一回事,是发生在更底层的问题。
HTTP/3:换一条不容易堵车的路
为了从根子上解决这个问题,HTTP/3 做了一个更彻底的改动:它不再基于TCP,而是改用一个基于UDP的新协议,叫 QUIC。
QUIC 协议在设计上,把原本TCP里”必须严格按顺序处理”的限制放开了——不同请求的数据,即便是在同一条连接上传输,彼此之间也是相对独立的。
如果某个请求对应的数据包丢失了,只会影响这一个请求本身的处理,不会拖累其他请求。
还是电话的比方,这相当于把”一条电话线里混着好几个话题”升级成了”每个话题都有自己独立的一根线,只是这几根线打包在一起走,互不干扰”,某一根线偶尔要求对方重复一下,不耽误其他线继续往下聊。
另外,QUIC 还顺带把 TLS 加密握手的过程和连接建立的过程做了整合优化,进一步减少了建立连接时需要的往返次数。
对于之前连接过的服务器,甚至可以做到”0-RTT”(零往返时间)重新连接,也就是不需要额外的往返握手,直接就能带着数据发出第一个请求——这在移动网络这种延迟本身就偏高的场景下,提升会更加明显。
这些机制演进,给我们的实际启发是什么
讲了这么多协议层面的机制,回到实际工作中,这些内容能给我们哪些具体的启发?
第一,确认你的网站是否已经用上了HTTP/2或HTTP/3。 现在主流的浏览器、以及大部分CDN和云服务商,都已经默认支持这两个协议了,但如果你的服务器配置比较老旧,或者证书配置有问题,很可能实际上还是在用HTTP/1.1,白白损失了本可以拿到的性能提升。
这个可以通过浏览器开发者工具的网络面板,查看请求使用的协议版本来确认。
第二,理解了连接复用的原理,就能明白为什么”域名分片”这个曾经流行的优化技巧,在HTTP/2时代反而变得没有必要、甚至有害。
早年HTTP/1.1时代,因为同一域名下并行连接数有限制(通常6条),有人想出把静态资源分散到多个子域名下(比如 img1.example.com、img2.example.com),这样浏览器就能对不同域名分别建立6条连接,变相提高并行度。
但到了HTTP/2,一条连接就能承载大量并行请求,这种做法反而会导致需要建立更多条TCP连接、增加更多次握手的开销,得不偿失。如果你的项目里还留着这种历史遗留配置,现在是时候重新评估一下了。
第三,理解连接复用,也能帮你更好地理解”预连接”(Preconnect)这类优化手段的价值。
既然建立连接本身就有成本,那对于一些明确知道即将会用到、但还没正式发起请求的第三方域名(比如字体服务、统计脚本所在的域名),提前让浏览器把连接建立好,等真正需要发请求的时候,直接复用这条已经建好的连接,就能省掉当时才现场握手的那部分延迟。
小结
- 每一次网络请求前,浏览器和服务器都要先完成连接建立的握手过程(TCP握手,如果是HTTPS还要加上TLS握手),这个过程本身要消耗好几趟往返延迟。
- HTTP/1.1 引入了 Keep-Alive,让同一条TCP连接可以被多个请求复用,避免了每个请求都重新握手,但同一条连接上的请求依然要排队处理,存在队头阻塞。
- HTTP/2 引入了多路复用,通过把数据切成带标记的帧,让多个请求可以在同一条连接上交错并行传输,解决了应用层的排队问题。
- HTTP/2 依然基于TCP,而TCP要求数据严格按顺序到达,一旦某个包丢失,会拖累这条连接上所有请求——这是更底层的队头阻塞。
- HTTP/3 改用基于UDP的QUIC协议,让不同请求的数据在传输层面相对独立,一个请求丢包不再拖累其他请求,同时进一步压缩了建立连接所需的往返次数。
- 实际优化中,可以检查网站是否用上了HTTP/2或HTTP/3,重新评估”域名分片”这类过时技巧是否还有必要,以及合理使用预连接来提前完成握手。