[译文]性能测试对比——面向REST APIs的HTTP/1.1、HTTP/2、HTTP/2服务器推送

英文版权归Evert Pot所有,中译文作者yangyang(aka davidkoree)。双语版可用于非商业传播,但须注明英文版作者、版权信息,以及中译文作者。翻译水平有限,请广大读者指正。

 

在web服务开发领域,通过降低HTTP请求数量来提高服务性能,是一门常见的手艺。

这样做有很多好处,包括维持更少的字节传输量。但最主要的原因,还是因为众多主流浏览器在同一域名上仅能建立6个并发的HTTP请求,倒退到2008年,请求数量被限制在最多2个。

当到达最多请求数限制时,浏览器必须等前面的请求完成,才能发起新的请求。这样一来,直观的后果就是,等待所花费的延迟越高,全部请求完成需要的时间就越长。

来看一个模拟这种情况的例子。下面的动画演示了获取一个页面主体的请求过程,这个页面可能是一个网站主页,或是一个JSON数据。

当页面获取成功后,模拟器得到了其中链接的99个对象,它们可能是图片、脚本、或其他API形式的文档。

基于上述6个并发连接的限制,工程师们想出了各种优化手段,比如合并压缩后传输脚本,图片被合并成「精灵图」来显示(译注:sprite maps,把网页中用到的多个图像整合到一张图片文件里,再利用CSS精确定位技术分别予以展示)。

类似的情况也发生在web service领域,考虑到单HTTP连接的一些限制和性能代价,REST API及其他基于HTTP的服务设计者们,会采取把一大堆逻辑实体打包到单一的HTTP请求/响应过程中,以代替那种小而美的API封装方式。

举个例子,当某个客户端需要通过API获取一个文章列表时,设计者们往往将其封装为一个单次请求,并返回列表。而不是逐一地吐出每一篇文章的URI地址。

这种节省有非常显著的效果。下面的动画模拟与刚才相似,区别在于我们这回把所有资源合并到一个请求里面,予以响应输出。

如果客户端通过API向服务器请求体量较大的数据集,为了减少HTTP的请求数,开发人员可能会被迫暴露更多的API,每个API都针对用户的特殊使用场景做适配,或者搭建一套可以响应各种查询条件的系统。

在具体实现和形式上,最简单的可能是支持各种查询参数的API,更复杂的则是GraphQL,它用一种管道机制封装了自定义的请求/响应模型,允许范围更广的查询组合。

复合文档(compounding documents)的缺点

复合文档有很多缺点。对混和在一起的实体有依赖的系统,其前后端往往也额外复杂。

如果把单一实体看做拥有URI(译注:统一资源标识符)的对象,则可以通过GET方式获取它或它的缓存。取而代之的话,通过在客户端与服务器之间添加一个抽象层,那么它就会负责梳理和调配对象的工作。

进一步看,这种抽象其实是重新实现了一遍HTTP的逻辑,它带来一个不好的副作用,那就是与HTTP相关的其他特性也被重新实现了一遍。最常见的例子就是缓存机制(caching)。

在REST生态圈里,复合文档的例子比比皆是。JSON:API, HALAtom都有相应的观念。

如果你观察一下大多数符合JSON:API特性的客户端实现,你便会发现这些客户端往往包含了某种”实体大杂烩“,事无巨细地记录着实体信息,实际上维护着与HTTP缓存相同的东西。

还有一些系统存在另一个问题,那就是客户端通常更难以获取它们实际想要的数据。因为在复合文档中,要么没有包含目标数据,要么混杂着所有数据。又或者包含目标数据的文档结构非常复杂(见GraphQL)。

一个更严重的缺陷是,API设计者们越来越倾向让系统变得更不透明,由于体系互联的缺失,其在信息组织上不再网状化。

HTTP/2 和 HTTP/3

HTTP/2如今已十分流行,它在HTTP请求上的开销非常低。每个HTTP/1.1请求需要开启一个TCP连接。然而HTTP/2只需要为每个域名开启1个TCP连接,多个请求可以在其上并行且无序的流动。

实际上如今我们可以通过协议本身来实现复合文档中体现并行的场景。

利用多个HTTP/2请求来替代复合的HTTP/1.1请求,有如下优势:

  • 浏览器/应用程序不再需要从单一响应中梳理多个信息实体,所有东西都可以简化为通过GET来获取。集合不需要再嵌入元素,它只需要指向它们。
  • 如果浏览器中有集合数据的(部分)缓存,它可以聪明地跳过请求动作或者很快得到一个304 Not Modified响应。
  • 那些被更早识别的元素可以比其他元素更快地抵达浏览器终端,可以被更及时地渲染,而不是等到所有元素都加载完毕再渲染。

HTTP/2 Push

与上述多个请求的方式相比,复合请求仍有一些优点。

举个例子,我们创建了一个可以返回文章列表的API,当我们请求数据时,只是返回文章链接的列表,而非每篇文章。

GET /articles HTTP/1.1

Host: api.example.org



HTTP/1.1 200 OK

Content-Type: application/json



{

  "_links": {

    "item": [

      { "href": "/articles/1" },

      { "href": "/articles/2" },

      { "href": "/articles/3" },

      { "href": "/articles/4" },

      { "href": "/articles/5" },

      { "href": "/articles/6" },

      { "href": "/articles/7" }

    ]

  },

  "total": 7

}

作为客户端,它请求文章列表首先要拉取集合、等待响应、然后再并行拉取集合中的每个元素,这样会让延迟翻倍。

还有一个问题,就是服务端需要处理8个请求,1个是处理集合,另外还有7个是针对每个元素。获取整个列表往往更划算。这个有时也被称为N+1查询问题(译注:请自行搜索「Hibernate N+1」)。

这个问题可以通过HTTP/2的「服务器推送」(Server Push)技术来解决。服务器推送是HTTP/2的一项新特性,它可以让服务器在客户端实际请求资源之前主动发送它们。

不过这种方法也有一个缺点,就是服务器并不知道客户端缓存了哪些资源。它只会假设自己要发送所有资源,或会猜测哪些资源需要被发送。

曾经有一个未完善的提案,试图解决这个问题,它的方法是通过布隆过滤(bloom filter)来让客户端告诉服务器,自己有哪些缓存数据。不幸的是,我相信人们已经放弃了该提案。

所以你只能二选一。要么完全消除初始延迟,要么借助缓存来减小通信量。

理想的解决方案,可能是上述要素的复合形式。我一直在努力制定这样一个规范:它允许客户端在HTTP请求头中声明自己希望获得哪些链接关系——我叫他Prefer Push,看起来像这样:

GET /articles HTTP/2

Prefer-Push: item

Host: api.example.org

如果服务端能解析这个头信息,它便能理解客户端想要所有相关的链接资源,并开始尽早地推送它们。

在服务端假想的框架控制器里,处理请求的代码如下:

function articlesIndex(request, response, connection) {

  const articles = articleServer.getIndex();

  response.body = articles.toLinks();

  if (request.prefersPush('item')) {

    for(const article of articles) {

      connection.push(

        article.url,

        article.toJson();

      };

    }

}

CORS问题

CORS(译注:跨域资源共享)技术也有一些缺点,这里值得讲讲。CORS主要方便了一件事,就是让web应用能在某(些)域名下向其他域名下的API发起HTTP请求。

这个过程里包含了一些不同的手段,其中非常耗性能的一个是「预检请求」(preflight request)。

当执行不安全的跨域请求时,浏览器首先会发起OPTION请求,以便服务器明确地予以识别和选择。

实际上,大多数API请求都是「不安全」的,这对于每个HTTP请求来说可能会有双倍延迟。

有趣的是当年Macromedia Flash也遇到过类似问题,他们的解决方案是创建一个跨域多源请求策略,你所需要做的是把策略存入一个名为crossdomain.xml的文件,将之放到你的域名根目录下,Flash读取到该文件后就能记住它。

每隔几个月我就会上网搜索看是否有人会在Javascript上实现一套现代的CORS方案,如今我发现一个W3C草案(注1),也希望浏览器厂商们能跟进一下。

一个不太优雅的解决方案是在API所属域名下托管一个代理脚本,它嵌在<iframe>标签里,可以无限制地访问自己的源。在其之上的web应用程序可以通过window.postMessage()与它通讯。

完美的世界

在一个完美的世界里,HTTP/3已经广泛可用,它更好地优化了性能,浏览器针对缓存摘要(cache digests)也拥有了标准机制,客户端可以把它们需要的链接关系通知给服务器,API服务也可以尽快地把任何客户端可能需要的资源推送过去。各种全域的源策略合而为一。

最后一张模拟图展示了它可能的样貌。在这个例子中,浏览器有预热的缓存,缓存数据中的每一项都有对应的ETag。

当客户端发起请求,检查是否有新数据或需要更新的项时,客户端会携带缓存摘要,服务器以推送的方式予以响应,仅返回有变动的资源。

真实世界的性能测试

我们离完美世界还有段距离,但我们仍可以根据目前所掌握的东西来做事。我们可以利用HTTP/2的服务器推送功能,发起请求易如反掌。

对于我来说,HTTP/2使得「细小且众多的HTTP端点」成为一种简洁的设计,但是性能上是否站得住脚?找到一些证据可能会有帮助。

我做性能测试的目标是用下述不同方式来获取资源:

1. h1 - 独立的HTTP/1.1请求

2. h1-compound - 复合的HTTP/1.1集合

3. h2 - 独立的HTTP/2请求

4. h2-compound - 复合的HTTP/2集合

5. h2-cache - 一个HTTP/2集合,其中每项都是独立获取,有预热的缓存

6. h2-cache-stale - 一个HTTP/2集合,其中每项都是独立获取,有预热的缓存,但是需要重新检查更新

7. h2-push - 基于HTTP/2,无缓存,但每一项都被拉取过

我的预测

理论上,在完整的周期里,「复合请求」与「HTTP/2推送响应」都会传输同样数量的信息。

我认为,基于HTTP/2的HTTP请求仍会有一些开销。不过,在复合请求的场景下,它仍然具有一定的性能优势。

真正的好处将在缓存开始发挥作用时体现出来。对于一个典型API中的给定集合,我认为可以假设许多项可以被缓存。

因此「跳过了90%工作」的测试,在性能表现上应该是最快的。这种假设似乎是合乎逻辑的。

所以根据我的预测,由快到慢依次为:

1. h2-cache - 一个HTTP/2集合,其中每项都是独立获取,有预热的缓存

2. h2-cache-stale - 一个HTTP/2集合,其中每项都是独立获取,有预热的缓存,但是需要重新检查更新

3. h2-compound - 复合的HTTP/2集合

4. h1-compound - 复合的HTTP/1.1集合

5. h2-push - 基于HTTP/2,无缓存,但每一项都被拉取过

6. h2 - 独立的HTTP/2请求

7. h1 - 独立的HTTP/1.1请求

第一轮测试和初步观察

我首次测试是在一台本地装有Node.js的机器上,Node版本为12。所有HTTP/1.1测试在SSL上完成,HTTP/2测试在另一个端口完成。

为了模拟延迟,我给每个HTTP请求添加了40~80毫秒的延迟时间。

这是我第一版的测试工具

随即我就遇到了一些问题。Chrome浏览器禁用了自签名证书的缓存。实际上我没能真正弄清楚如何让Chrome接受我本地环境的自签名证书,于是我先放弃了,改用Firefox做测试。

在Firefox上,「服务器推送」看起来不太可靠。它经常在我重复发起推送测试时才管用。

但是,Firefox最让我惊讶的是,通过本地缓存获取数据的速度仅仅比我人工模拟延迟获取全新响应数据的速度快一点点。在跑了几轮测试之后,有些测试结果显示实际上前者比后者更慢了。

通过对比这些数据,我不得不改进我的测试方案。

更好的测试

再次测试,基础方案如下:

1. 我重复执行50次测试。

2. 我在AWS上跑测试,实例是在us-west-2启用,级别为t2.medium。

3. 我的测试是在真实互联网环境下执行,去掉了延迟模拟。

4. 我使用的是LetsEncrypt SSL安全证书。

5. 我在每种浏览器上跑2种测试:

  • 一种包含25个项的集合
  • 一种包含500个项的集合

测试1:25个请求

图中有一些有趣的地方。

首先,我们预计HTTP/1.1独立请求会是最慢的,如图中结果所示,没有意外。实际上它提供了一个基准线。

第二个最慢的是HTTP/2独立请求。

HTTP/2推送、HTTP/2缓存,它们只有略微提升。

Chrome和Firefox大都有同样的结果。让我们看看Chrome的具体测试结果:

复合请求显然是速度最快的。这表明我当初的猜测是错的。即便有缓存的加持,它仍然不能击败在一个单独的复合响应中重发全部集合。

与无缓存相比,有缓存时,性能会有小幅提升。

测试2:500个请求

让我再来测个大的。在这个测试中,由于请求变多,持续时间会更久,我们预计在某些方面差异会变大,同时也会有差异变小的情况出现。尤其是,最初请求的影响会弱化。

这2张图表明:

  • 对于请求最多的测试来说,Chrome是最慢的浏览器。
  • 对于使用服务器推送的测试,Firefox是最慢的。并且当浏览器缓存最多时,Firefox也最慢。

这与我自己的观察基本相符。Firefox的推送能力似乎不太可靠,使用缓存时看起来也慢。

我们可以看到的是,在500个请求中,复合请求在Firefox上要快1.8倍,在Chrome上快3.26倍。

最让人意想不到的是浏览器缓存的速度。我们的常规测试会发起501个HTTP请求,而预热缓存的测试只发起51个HTTP请求。

在Chrome上,上述测试结果表明发起501个请求的速度是发起51个请求的2.3倍,在Firefox上,这个值只有1.2倍

换句话说,Firefox从缓存中请求数据的速度仅仅比从链路另一侧获取资源的速度略微快一些。这非常让人意外。

这让我很好奇,是否Firefox的缓存普遍就是那么慢?或者在高并发情况下会尤其变差?我没有仔细研究,但感觉Firefox的缓存可能有一些不良的全局锁行为。

另一个突出现象,就是当并行发起500个请求时,Chrome表现得特别差,比Firefox慢2倍还多。这个巨大的差异让我对测试结果产生怀疑,随后我重新跑测试,但每次都得到相似结果。

我们还看到,使用推送的好处变得不那么明显,因为我们只有通过减少第一个请求的延迟才能真正节省时间。

结论

我的测试并不完美,HTTP/2大多数测试是用Node的实现来做的。为了得到真实证明,我认为在更多环境下做测试很重要。

我搭建的服务端也可能不是最佳方案。我的服务从文件系统提供文件,但真实环境中的系统可能有不同表现。

所以把这些测试结果当做参考结果,而不是论证结果。

这些测试结果让我明白,如果速度是最优先考虑的因素,你应该继续使用复合响应。

我确实相信,虽然这些测试结果足够接近真实情况,但为了获得一个潜在更简洁的系统设计,深入执行全方面的性能测试可能是值得的。

同时,缓存的介入实际上没有带来明显的差别。由于浏览器的优化手段较弱,经常性地执行新的HTTP请求,可能跟从缓存中获取数据所花的时间是一样的。Firefox尤其如此。

我也相信,推送所带来的效果可以明示但不够多。推送最大的优势在于新数据集的第一次加载,并且对于避免N+1查询问题也更为重要。在下列情况下,较早推送响应会很有用:

  • 当一次性搜集所有响应对服务器来说有好处的时候。
  • 当API中有多个原型(hops)需要获取全部数据时,智能化的推送机制会非常有效,可以降低复合延迟。

小结:

  • 如果对速度的要求极为苛刻,应坚持使用复合文档。
  • 如果更看重简洁性,维护小规模的、多点分散的API肯定更为切实可行。
  • 缓存只带来一点点小变化。
  • 较之客户端,优化策略更有利于服务器端。

然而,我对这些测试结果仍抱有怀疑。如果时间充裕,我会用Go语言实现的服务端做测试,并如实地模拟服务器端的各种情况。

我2020年的愿望清单

在本文结尾之际,我列一份2020年的愿望清单,并翘首以盼:

这个心愿单充满野心。一旦我们达成心愿,我相信我们的REST客户可以更简单地获益,我们在服务器端的实现上可以更少的权衡性能和简单性,并且我们可以将浏览器和服务器当做索引化URI资源状态的同步引擎,它既健壮又快速。种种这些,都会有长足进步。

资源

原始的测试结果

Google Sheets格式的结果

测试脚本

译注:建议读者点击原文,通过点击start按钮来观看模拟动态图,它们更好的演示了作者在文中提到的HTTP请求响应过程。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值