超出负载均衡器后,HTTP/2 的意义不大
我想写一篇关于 Pitchfork 的文章,解释它的起源、为什么它目前是这样的,以及我对它未来的一些看法。但在深入讨论之前,我觉得需要先分享一下我对某些技术主题的看法——这次的主题是 HTTP/2。
无论是在线还是在技术会议上,我偶尔会听到人们对 Ruby HTTP 服务器(比如 Puma)不支持 HTTP/2 表示不满。每当遇到这种情况时,我都会问他们为什么要这个功能。但到目前为止,还没有人能提供一个实际的使用场景。
对我来说,这种缺乏支持并不是什么大问题,因为我唯一能想到的情况就是,有人希望直接将 Ruby 应用程序暴露在互联网上,而不通过任何负载均衡器或反向代理。虽然这样看起来可以“少了一层架构”,但从我的经验来看,这其实并不值得这么做。
如果你对 HTTP 协议不太熟悉,也不知道 HTTP/2 和 HTTP/3 有什么区别,你可能会觉得我的观点有些奇怪。所以下面我会尽量用通俗易懂的语言来解释 HTTP/2 到底解决了哪些问题,以及为什么在内部网络中它可能并不重要。
HTTP/2 解决了什么问题?
HTTP/2 的雏形最早是在 2009 年以 SPDY 的形式出现的,主要目标是减少页面加载延迟,使网页加载更快。在当时的互联网中,网页不再只是一个简单的 HTML 文件,而是由许多资源组成的集合,包括样式表、脚本和图片等。因此,当浏览器下载并解析主页面后,还需要下载这些额外的资源。
在 2000 年代后期,随着网页内容变得越来越复杂,所需的资源数量也在不断增加。尽管宽带的速度在提升,但这并不能完全弥补 HTTP/1.1 在处理大量小文件时效率低下的问题。其中一个主要原因在于,根据 RFC 2616 标准(定义了 HTTP/1.1),浏览器只能与同一域名建立两个并发连接:
客户端在使用持久连接时,应限制其与给定服务器保持的并发连接数。单用户客户端不应同时维持超过两个连接到任何一个服务器或代理。
这意味着即使你的网络带宽很大,如果每个请求都需要等待前一个请求完成,那么加载几十个小资源将会变得非常慢。举个例子,假设你需要通过跨大西洋的服务器加载 100 个小型资源,而你的 ping 值大约为 60 毫秒。如果你只用了两个并发连接,整个加载时间至少为 ping * (资源数 / 连接数),也就是约 3 秒,这是很糟糕的体验。
正因为如此,在那时像“资源打包”(assets bundling)这样的前端优化技术变得非常重要。它们通过合并多个文件,减少了请求次数,从而显著提升了页面加载速度[1]。还有些网站采用了“域名分片”(domain sharding)技术,将资源分散到不同的域名下,以增加并发能力。
理论上,HTTP/1.1 有一个叫做“请求管道化”的功能,允许在一个连接上连续发送多个请求而无需等待前一个响应。但实际上,由于一些服务器的行为不当,大多数浏览器最终禁用了这一功能。即使没有被禁用,该功能也有一个问题:队首阻塞(Head-of-Line Blocking)。即如果某个资源的生成速度较慢,则后续所有资源都无法发送。
为了缓解这些问题,到了 2008 年,大部分浏览器开始突破原有的两个连接限制,Firefox 3 将每域名的最大连接数提高到 6,其他浏览器也很快跟进。然而,增加并发连接并非完美解决方案,因为每个 TCP 连接都有一个“慢启动”阶段。当建立新的连接时,计算机不知道这条链路的极限吞吐量是多少,因此初始速度较慢,逐渐加速,直到收到丢包通知为止。这就是为什么持久连接如此重要——已经使用的连接比新建立的连接具有更高的吞吐量。
HTTP/2 正是通过允许多路复用(multiplexing)来解决这些问题,它可以让多个请求共享同一个 TCP 连接,避免了队首阻塞[2]。除此之外,HTTP/2 还引入了一些其他特性,比如强制使用加密[3]、压缩头部信息(用 GZip)以及“服务器推送”(server push),但其中最重要的还是多路复用。
为什么局域网中 HTTP/2 不重要?
HTTP/2 的核心优势在于多路复用,尤其在网络环境较差的情况下(例如移动互联网),它可以带来显著性能提升。但在数据中心环境中却未必如此。
仔细想想,在我们上面提到的计算中,影响最大的因素是往返时间(RTT,即“ping”值)。除非你的基础设施设计得非常差,否则服务器(例如 Puma)与其客户端(负载均衡器或反向代理)之间的往返时间通常非常短,通常不到一毫秒,远远小于实际处理请求所需的时间。换句话说,在局域网中,HTTP/2 多路复用的优势几乎无法体现出来。
另外,局域网中的连接往往会长时间存在,因此基本不会受到 TCP 慢启动的影响。实际上,许多服务器的操作系统已经被调整为默认禁用慢启动机制,这进一步减弱了多路复用的需求。
服务器推送的失败
过去,有些人认为 HTTP/2 的“服务器推送”功能是一个重要的亮点。它的原理很简单:服务器可以在客户端请求主页面的同时,提前推送相关资源(如 CSS 和 JavaScript 文件)。这样一来,浏览器就不需要再解析 HTML 后重新发起请求,从而节省了时间。
然而,这项功能后来被移除了,现代浏览器也不再支持它。原因是,如果浏览器缓存中已经有这些资源,再次推送只会延长页面加载时间。开发人员尝试过用智能算法判断哪些资源可能已在缓存中,但这些方法最终都失败了,于是该功能被废弃。
如今,“服务器推送”已经被一种名为“103 Early Hints”的机制取代,它不仅简单优雅,而且兼容 HTTP/1.1。因此,从实际应用角度来看,HTTP/2 和 HTTP/1.1 几乎没有本质区别。
额外的复杂性
即使在局域网中 HTTP/2 提供的好处有限,但它确实增加了复杂性:
- 实现难度:HTTP/2 是一种二进制协议,虽然不算极其复杂,但调试起来比纯文本协议困难得多。
- 部署难度:HTTP/2 需要全程加密,所以必须为所有应用程序服务器配置密钥和证书。尽管可以通过自动化工具简化这一过程,但仍然比使用 HTTP/1.1 复杂。
此外,如果你想把 HTTP/2 推广到 Ruby 应用服务器,就需要重新设计整个架构,而这通常只有在单机部署时才必要。如果是多台机器,那么将 HTTP/2 引入到后端反而会让系统变得更加复杂,却没有明显收益。
即使你选择单机部署,最好仍然让反向代理来处理这些问题。反向代理不仅能管理静态资源、标准化入站请求,还能帮助防御部分恶意攻击。像 Nginx 和 Caddy 这样的成熟反向代理工具非常易于设置,何不利用这些常见的中间件呢?
当然,如果你觉得反向代理太麻烦,也可以尝试一些零配置解决方案,比如 Thruster,虽然我没亲自试过,但至少从理论上看,它能满足这种需求。
结论
我认为,与其将 HTTP/2 看作是对 HTTP/1.1 的升级,不如将其视为一种替代协议,专门用于更高效地传输相同的 HTTP 资源。类似于 HTTPS 对 HTTP 的改进,并未改变语义,只是改变了数据在传输层面的序列化方式。
因此,我个人认为处理 HTTP/2 的任务应该交给基础设施入口点,通常是负载均衡器或反向代理,理由与 TLS 被留给这些组件是一样的。它们需要解密和解压请求以确定如何处理,那么为什么还要重新加密和压缩后再转发给应用服务器呢?
所以,在我看来,Ruby HTTP 服务器是否支持 HTTP/2 并不是一个关键特性,虽然对某些特殊场景可能有用,但总体来说,缺少这个功能并不会对系统的整体性能造成太大影响。
需要注意的是,虽然我没有具体讨论 HTTP/3,但它的目标与 HTTP/2 基本相同,因此对它的结论也同样适用。
