深入研究:HTTP2 的真正性能到底如何

1,115 阅读4分钟

写于 2016.10.19

一、研究目的

HTTP2的概念提出已经有相当长一段时间了,而网上关于关于http2的文章也一搜一大把。但是从搜索的结果来看,现有的文章多是偏向于对http2的介绍,鲜有真正从数据上具体分析的。这篇文章正是出于填补这块空缺内容的目的,通过一系列的实验以及数据分析,对http2的性能进行深入研究。当然,由于本人技术有限,实验所使用的方法肯定会有不足之处,如果各位看官有发现问题,还请向我提出,我一定会努力修改完善实验的方法的!

二、基础知识

关于HTTP2的基础知识,可以参考下列几篇文章,在这里就不进行赘述了。

通过学习相关资料,我们已经对HTTP2有了一个大致的认识,接下来将通过设计一个模型,对HTTP2的性能进行实验测试。

三、实验设计

设置实验组:搭建一个HTTP2(SPDY)服务器,能够以HTTP2的方式响应请求。同时,响应的内容大小,响应的延迟时间均可自定义。

设置对照组:搭建一个HTTP1.x服务器,以HTTP1.x的方式响应请求,其可自定义内容同实验组。另外为了减少误差,HTTP1.x服务器使用https协议。

测试过程:客户端通过设置响应的内容大小、请求资源的数量、延迟时间、上下行带宽等参数,分别对实验组服务器和对照组服务器发起请求,统计响应完成所需时间。

由于nginx切换成http2需要升级nginx版本以及取得https证书,且在服务器端的多种自定义设置所涉及的操作环节相对复杂,综合考虑之下放弃使用nginx作为实验用服务器的方案,而是采用了NodeJS方案。在实验的初始阶段,使用了原生的NodeJS搭配node-http2模块进行服务器搭建,后来改为了使用express框架搭配node-spdy模块搭建。原因是,原生NodeJS对于复杂请求的处理非常复杂,express框架对请求、响应等已经做了一系列的优化,可以有效减少人为的误差。另外node-http2模块无法与express框架兼容,同时它的性能较之node-spdy模块也更低(General performance, node-spdy vs node-http2 #98),而node-spdy模块的功能与node-http2模块基本一致。

1、服务器搭建

实验组和对照组的服务器逻辑完全一致,关键代码如下:

app.get('/option/?', (req, res) => {
	allow(res)
	let size = req.query['size']
	let delay = req.query['delay']
	let buf = new Buffer(size * 1024 * 1024)
	setTimeout(() => {
		res.send(buf.toString('utf8'))
	}, delay)
})

其逻辑是,根据从客户端传入的参数,动态设置响应资源的大小和延迟时间。

2、客户端搭建

客户端可动态设置请求的次数、资源的数目、资源的大小和服务器延迟时间。同时搭配Chrome的开发者工具,可以人为模拟不同网络环境。在资源请求响应结束后,会自动计算总耗时时间。关键代码如下:

for (let i = 0; i < reqNum; i++) {
	$.get(url, function (data) {
		imageLoadTime(output, pageStart)
	})
}

客户端通过循环对资源进行多次请求,其数量可设置。每一次循环都会通过imageLoadTime更新时间,以实现时间统计的功能。

3、实验项目

a. http2性能研究

通过研究章节二的文章内容,可以把http2的性能影响因素归结于“延迟”和“请求数目”。本实验增加了“资源体积”和“网络环境”作为影响因素,下面将会针对这四项进行详细的测试实验。其中每一次实验都会重复10次,取平均值后作记录。

b. 服务端推送研究

http2还有一项非常特别的功能——服务端推送。服务端推送允许服务器主动向客户端推送资源。本实验也会针对这个功能展开研究,主要研究服务端推送的使用方法及其对性能的影响。

四、HTTP2 性能数据统计

1、延迟因素对性能的影响

条件/实验次数 1 2 3 4 5
延迟时间(ms) 0 10 20 30 40
资源数目(个) 100 100 100 100 100
资源大小(MB) 0.1 0.1 0.1 0.1 0.1
统计时间(s)http1.x 0.38 0.51 0.62 0.78 0.94
统计时间(s)http2 0.48 0.51 0.49 0.48 0.50

2、请求数目对性能的影响

通过上一个实验,可以知道在延迟为10ms的时候,http1.x和http2的时间统计相近,故本次实验延迟时间设置为10ms。

条件/实验次数 1 2 3 4 5
延迟时间(ms) 10 10 10 10 10
资源数目(个) 6 30 150 750 3750
资源大小(MB) 0.1 0.1 0.1 0.1 0.1
统计时间(s)http1.x 0.04 0.16 0.63 3.03 20.72
统计时间(s)http2 0.04 0.16 0.71 3.28 19.34

增加延迟时间,重复实验:

条件/实验次数 6 7 8 9 10
延迟时间(ms) 30 30 30 30 30
资源数目(个) 6 30 150 750 3750
资源大小(MB) 0.1 0.1 0.1 0.1 0.1
统计时间(s)http1.x 0.07 0.24 1.32 5.63 28.82
统计时间(s)http2 0.07 0.17 0.78 3.81 18.78

3、资源体积对性能的影响

通过上两个实验,可以知道在延迟为10ms,资源数目为30个的时候,http1.x和http2的时间统计相近,故本次实验延迟时间设置为10ms,资源数目30个。

条件/实验次数 1 2 3 4 5
延迟时间(ms) 10 10 10 10 10
资源数目(个) 30 30 30 30 30
资源大小(MB) 0.2 0.4 0.6 0.8 1.0
统计时间(s)http1.x 0.21 0.37 0.59 0.68 0.68
统计时间(s)http2 0.25 0.45 0.61 0.83 0.73
条件/实验次数 6 7 8 9 10
延迟时间(ms) 10 10 10 10 10
资源数目(个) 30 30 30 30 30
资源大小(MB) 1.2 1.4 1.6 1.8 2.0
统计时间(s)http1.x 0.78 0.94 1.02 1.07 1.13
统计时间(s)http2 0.92 0.86 1.08 1.26 1.33

4、网络环境对性能的影响

通过上两个实验,可以知道在延迟为10ms,资源数目为30个的时候,http1.x和http2的时间统计相近,故本次实验延迟时间设置为10ms,资源数目30个。

条件/网络条件 Regular 2G Good 2G Regular 3G Good 3G Regular 4G Wifi
延迟时间(ms) 10 10 10 10 10 10
资源数目(个) 30 30 30 30 30 30
资源大小(MB) 0.1 0.1 0.1 0.1 0.1 0.1
统计时间(s)http1.x 222.66 116.64 67.37 32.82 11.89 0.87
统计时间(s)http2 138.06 71.02 40.77 20.82 7.70 0.94

五、HTTP2 服务端推送实验

本实验主要针对网络环境对服务端推送速度的影响进行研究。在本实验中,所请求/推送的资源都是一个体积为290Kb的JS文件。每一个网络环境下都会重复十次实验,取平均值后填入表格。

条件/网络条件 Regular 2G Good 2G Regular 3G Good 3G Regular 4G Wifi
客户端请求总耗时(s) 9.59 5.30 3.21 1.57 0.63 0.12
服务端推送总耗时(s) 18.83 10.46 6.31 3.09 1.19 0.20
资源加载速度-客户端请求(s) 9.24 5.13 3.08 1.50 0.56 0.08
资源加载速度-服务端推送(s) 9.28 5.16 3.09 1.51 0.57 0.08
条件/网络条件 No Throttling
客户端请求总耗时(ms) 56
服务端推送总耗时(ms) 18
资源加载速度-客户端请求(s) 15.03
资源加载速度-服务端推送(s) 2.80

从上述表格可以发现一个非常奇怪的现象,在开启了网络节流以后(包括Wifi选项),服务端推送的速度都远远比不上普通的客户端请求,但是在关闭了网络节流后,服务端推送的速度优势非常明显。在网络节流的Wifi选项中,下载速度为30M/s,上传速度为15M/s。而测试所用网络的实际下载速度却只有542K/s,上传速度只有142K/s,远远达不到网络节流Wifi选项的速度。为了分析这个原因,我们需要理解“服务端推送”的原理,以及推送过来的资源的存放位置在哪里。

普通的客户端请求过程如下图:

服务端推送的过程如下图:

从上述原理图可以知道,服务端推送能把客户端所需要的资源伴随着index.html一起发送到客户端,省去了客户端重复请求的步骤。正因为没有发起请求,建立连接等操作,所以静态资源通过服务端推送的方式可以极大地提升速度。但是这里又有一个问题,这些被推送的资源又是存放在哪里呢?参考了这篇文章Issue 5: HTTP/2 Push以后,终于找到了原因。我们可以把服务端推送过程的原理图深入一下:

服务端推送过来的资源,会统一放在一个网络与http缓存之间的一个地方,在这里可以理解为“本地”。当客户端把index.html解析完以后,会向本地请求这个资源。由于资源已经本地化,所以这个请求的速度非常快,这也是服务端推送性能优势的体现之一。当然,这个已经本地化的资源会返回200状态码,而非类似localStorage的304或者200 (from cache)状态码。Chrome的网络节流工具,会在任何“网络请求”之间加入节流,由于服务端推送活来的静态资源也是返回200状态码,所以Chrome会把它当作网络请求来处理,于是导致了上述实验所看到的问题。

六、研究结论

通过上述一系列的实验,我们可以知道http2的性能优势集中体现在“多路复用”和“服务端推送”上。对于请求数目较少(约小于30个)的情况下,http1.x和http2的性能差异不大,在请求数目较多且延迟大于30ms的情况下,才能体现http2的性能优势。对于网络状况较差的环境,http2的性能也高于http1.x。与此同时,如果把静态资源都通过服务端推送的方式来处理,加载速度会得到更加巨大的提升。

在实际的应用中,由于http2多路复用的优势,前端应用团队无须采取把多个文件合并成一个,生成雪碧图之类的方法减少网络请求。除此之外,http2对于前端开发的影响并不大。

服务端升级http2,如果是使用NodeJS方案,只需要把node-http模块升级为node-spdy模块,并加入证书即可。nginx方案的话可以参考这篇文章:Open Source NGINX 1.9.5 Released with HTTP/2 Support

若要使用服务端推送,则在服务端需要对响应的逻辑进行扩展,这个需要视情况具体分析实施。

七、后记

纸上得来终觉浅,绝知此事要躬行。如果不是真正的设计实验、进行实验,我可能根本不会知道原来http2也有坑,原来使用Chrome做调试的时候也有需要注意的地方。

希望这篇文章能够对研究http2的同学有些许帮助吧,如文章开头所说,如果你发现我的实验设计有任何问题,或者你想到了更好的实验方式,也欢迎向我提出,我一定会认真研读你的建议的!


下面附送实验所需源码: 1、客户端页面

<!-- http1_vs_http2.html -->

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <title>http1 vs http2</title>
   <script src="//cdn.bootcss.com/jquery/1.9.1/jquery.min.js"></script>
   <style>
   	.box {
   		float: left;
   		width: 200px;
   		margin-right: 100px;
   		margin-bottom: 50px;
   		padding: 20px;
   		border: 4px solid pink;
   		font-family: Microsoft Yahei;
   	}
   	.box h2 {
   		margin: 5px 0;
   	}
   	.box .done {
   		color: pink;
   		font-weight: bold;
   		font-size: 18px;
   	}
   	.box button {
   		padding: 10px;
   		display: block;
   		margin: 10px 0;
   	}
   </style>
</head>
<body>
   <div class="box">
   	<h2>Http1.x</h2>
   	<p>Time: <span id="output-http1"></span></p>
   	<p class="done done-1">× Unfinished...</p>
   	<button class="btn-1">Get Response</button>
   </div>

   <div class="box">
   	<h2>Http2</h2>
   	<p>Time: <span id="output-http2"></span></p>
   	<p class="done done-1">× Unfinished...</p>
   	<button class="btn-2">Get Response</button>
   </div>

   <div class="box">
   	<h2>Options</h2>
   	<p>Request Num: <input type="text" id="req-num"></p>
   	<p>Request Size (Mb): <input type="text" id="req-size"></p>
   	<p>Request Delay (ms): <input type="text" id="req-delay"></p>
   </div>

   <script>
   	function imageLoadTime(id, pageStart) {
   	  let lapsed = Date.now() - pageStart;
   	  document.getElementById(id).innerHTML = ((lapsed) / 1000).toFixed(2) + 's'
   	}
   	
   	let boxes = document.querySelectorAll('.box')
   	let doneTip = document.querySelectorAll('.done')
   	let reqNumInput = document.querySelector('#req-num')
   	let reqSizeInput = document.querySelector('#req-size')
   	let reqDelayInput = document.querySelector('#req-delay')

   	let reqNum = 100
   	let reqSize = 0.1
   	let reqDelay = 300

   	reqNumInput.value = reqNum
   	reqSizeInput.value = reqSize
   	reqDelayInput.value = reqDelay

   	reqNumInput.onblur = function () {
   		reqNum = reqNumInput.value
   	}

   	reqSizeInput.onblur = function () {
   		reqSize = reqSizeInput.value
   	}

   	reqDelayInput.onblur = function () {
   		reqDelay = reqDelayInput.value
   	}

   	function clickEvents(index, url, output, server) {
   		doneTip[index].innerHTML = '× Unfinished...'
   		doneTip[index].style.color = 'pink'
   		boxes[index].style.borderColor = 'pink'
   		let pageStart = Date.now()
   		for (let i = 0; i < reqNum; i++) {
   			$.get(url, function (data) {
   				console.log(server + ' data')
   				imageLoadTime(output, pageStart)
   				if (i === reqNum - 1) {
   					doneTip[index].innerHTML = '√ Finished!'
   					doneTip[index].style.color = 'lightgreen'
   					boxes[index].style.borderColor = 'lightgreen'
   				}
   			})
   		}
   	}

   	document.querySelector('.btn-1').onclick = function () {
   		clickEvents(0, 'https://localhost:1001/option?size=' + reqSize + '&delay=' + reqDelay, 'output-http1', 'http1.x')
   	}

   	document.querySelector('.btn-2').onclick = function () {
   		clickEvents(1, 'https://localhost:1002/option?size=' + reqSize + '&delay=' + reqDelay, 'output-http2', 'http2')
   	}
   </script>
</body>
</html>

2、服务端代码(http1.x与http2仅有一处不同)

const http = require('https') // 若为http2则把'https'模块改为'spdy'模块
const url = require('url')
const fs = require('fs')
const express = require('express')
const path = require('path')

const app = express()

const options = {
  key: fs.readFileSync(`${__dirname}/server.key`),
  cert: fs.readFileSync(`${__dirname}/server.crt`)
}

const allow = (res) => {
  res.header("Access-Control-Allow-Origin", "*")
  res.header("Access-Control-Allow-Headers", "X-Requested-With")
  res.header("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS")
}

app.set('views', path.join(__dirname, 'views'))
app.set('view engine', 'ejs')
app.use(express.static(path.join(__dirname, 'static')))

app.get('/option/?', (req, res) => {
	allow(res)
	let size = req.query['size']
	let delay = req.query['delay']
	let buf = new Buffer(size * 1024 * 1024)
	setTimeout(() => {
		res.send(buf.toString('utf8'))
	}, delay)
})

http.createServer(options, app).listen(1001, (err) => { // http2服务器端口为1002
	if (err) throw new Error(err)
	console.log('Http1.x server listening on port 1001.')
})