图解网络 — HTTP 篇
2022-08-23 21:58:07 57 举报AI智能生成
小林coding 图解网络 HTTP 篇 脑图总结
http协议
图解
计算机网络
模版推荐
作者其他创作
大纲/内容
HTTP 常见问题
HTTP 基本概念
HTTP 是什么?
HTTP 是超文本传输协议,也就是 HyperText Transfer Protocol。
HTTP 的名字「超文本协议传输」,它可以拆成三个部分:
1. 协议:HTTP 是一个用在计算机世界里的 <b>协议</b>。它使用计算机能够理解的语言确立了一种计算机<br>之间交流通信的规范(<b>两个以上的参与者</b>),以及相关的各种控制和错误处理方式(<b>行为约定和规范</b>)。
2. 传输:HTTP 是一个在计算机世界里专门用来在 <b>两点之间传输数据 </b>的约定和规范。
3. 超文本:HTTP <b>传输的内容 </b>是「超文本」。HTML 就是最常见的超文本了,它本身只是纯文字文件,但内部<br>用很多标签定义了图片、视频等的链接,再经过浏览器的解释,呈现给我们的就是一个文字、有画面的网页了。
<b>HTTP 是一个在计算机世界里专门在「两点」之间「传输」文字、图片、音频、视频等「超文本」数据的「约定和规范」。</b>
HTTP 常见的状态码有哪些?
2XX
200 OK:请求正常处理。
204 No Content:请求处理成功,但没有资源可返回。
206 Partial Content:对某资源的部分请求,该状态码表示客户端进行了范围请求,而服务器<br>成功执行了这部分的 GET 请求。响应报文中包含由 Content-Range 指定范围的实体内容。
3XX
301 Moved Permanently:资源的 URI 已更新,让客户端也更新下。永久性的重定向。
302 Found:资源的 URI 临时定位到其他位置了。临时性重定向。
304 Not Modified(与重定向无关):自从上次访问后,服务器对该资源没有做过改变,让浏览器使用未过期的本地缓存。
4XX
400 Bad Request:服务器无法理解该请求。请求报文中可能有语法错误。
401 Unauthorized:没有请求权限,通常浏览器会弹出一个对话框,让用户进行登录认证<br>
403 Forbidden:不允许访问该资源。请求的资源被服务器拒绝了。
404 Not Found:服务器上没有请求的资源,路径错误等。
5XX
500 Internal Server Error:服务器内部资源出现故障。
502 Bad Gateway:网关错误,在软件架构中网关通常指用于分发请求的代理服务,如 Nginx 作为代理接收请求,再分发到后面的具体服务器。502 指代理服务器正常,但是代理要去访问源站时发生了错误,代理服务器接收到无效的响应<br>
503 Service Unavailable:服务器正忙。表明服务器超负载或停机维护了。
504 Gateway Timeout:网关超时,指代理服务器请求源服务器超时了<br>
HTTP 常见字段有哪些?
Host 字段
<b>客户端发送请求 </b>时,用来 <b>指定服务器的域名</b>。
有了 Host 字段,就可以将请求发往「同一台」服务器上的不同网站。<br>
Content-Length 字段<br>
<b>服务器在返回数据 </b>时,会有 Content-Length 字段,表明本次 <b>回应的数据长度</b>。
如上面则是告诉浏览器,本次服务器回应的数据长度是 1000 个字节,后面的字节就属于下一个回应了。
Connection 字段<br>
Connection 字段最常用于 <b>客户端要求服务器使用 TCP 持久连接</b>,以便其他请求复用。<br>
HTTP/1.1 版本的默认连接都是持久连接,但为了兼容老版本的 HTTP,需要指定 Connection 首部字段的值为 Keep-Alive。<br><br>一个可以复用的 TCP 连接就建立了,直到客户端或服务器主动关闭连接。但是,这不是标准字段。<br>
Content-Type 字段<br>
Content-Type 字段用于 <b>服务器回应 </b>时,告诉客户端,<b>本次数据是什么格式</b>。<br>
上面的类型表明,发送的是网页,而且编码是UTF-8。
客户端请求的时候,可以使用 Accept 字段声明自己可以接受哪些数据格式。<br><br>Accept: */* 代表客户端声明自己可以接受任何格式的数据。<br>
Content-Encoding 字段<br>
Content-Encoding 字段说明 <b>数据的压缩方法</b>。表示 <b>服务器返回的数据使用了什么压缩格式。</b><br>
上面表示服务器返回的数据采用了 gzip 方式压缩,告知客户端需要用此方式解压。
客户端在请求时,用 Accept-Encoding 字段说明自己可以接受哪些压缩方法。<br>Accept-Encoding: gzip, deflate<br>
GET 与 POST
GET 与 POST 有什么区别
根据 RFC 规范,<b>GET 的语义是从服务器获取指定的资源</b>,这个资源可以是静态的文本、页面、图片视频等。<br>GET 请求的参数位置一般是写在 URL 中,URL 规定只能支持 ASCII,所以 GET 请求的参数只允许 ASCII 字<br>符 ,而且浏览器会对 URL 的长度有限制(HTTP协议本身对 URL长度并没有做任何规定)。
根据 RFC 规范,<b>POST 的语义是根据请求负荷(报文 body)对指定的资源做出处理</b>,具体的处理方式视资源<br>类型而不同。POST 请求携带数据的位置一般是写在报文 body 中, body 中的数据可以是任意格式的数据,<br>只要客户端与服务端协商好即可,而且浏览器不会对 body 大小做限制。
GET 与 POST 方法都是安全和幂等的吗?
安全和幂等的概念:<br><ul><li>在 HTTP 协议里,所谓的<b><font color="#0000ff">「安全」</font></b>是指请求方法 <b><font color="#0000ff">不会「破坏」服务器上的资源</font></b>。</li><li>所谓的<b><font color="#0000ff">「幂等」</font></b>,意思是 <b><font color="#0000ff">多次执行相同的操作,结果都是「相同」的</font></b>。</li></ul>
从 RFC 规范定义的语义来看:<br><ul><li><b><font color="#0000ff">GET 方法就是安全且幂等的</font></b>,因为它是「只读」操作,无论操作多少次,服务器上的数据都是安全的,<br>且每次的结果都是相同的。所以,<b><font color="#0000ff">可以对 GET 请求的数据做缓存,这个缓存可以做到浏览器本身上</font><br><font color="#0000ff">(彻底避免浏览器发请求),也可以做到代理上(如nginx),而且在浏览器中 GET 请求可以保存为书签</font>。</b></li><li>POST 因为是「新增或提交数据」的操作,会修改服务器上的资源,所以是 <b>不安全 </b>的,且多次提交数据就会<br>创建多个资源,所以 <b><font color="#0000ff">不是幂等</font> </b>的。所以,浏览器一般不会缓存 POST 请求,也不能把 POST 请求保存为书签。</li></ul>
实际过程中,开发者不一定会按照 RFC 规范定义的语义来实现 GET 和 POST 方法。比如:<br><ul><li>可以用 GET 方法实现新增或删除数据的请求,这样实现的 GET 方法自然就不是安全和幂等。</li><li>可以用 POST 方法实现查询数据的请求,这样实现的 POST 方法自然就是安全和幂等。</li></ul>
如果「安全」的概念是指信息是否会被泄漏的话,虽然 POST 用 body 传输数据,而 GET 用 URL 传输,<br>这样数据会在浏览器地址拦容易看到,但是并不能说 GET 不如 POST 安全。<br><br>因为 HTTP 传输的内容都是明文的,虽然在浏览器地址拦看不到 POST 提交的 body 数据,但是只要抓个包就都能看到了。<br><br>所以,要避免传输过程中数据被窃取,就要使用 HTTPS 协议,这样所有 HTTP 的数据都会被加密传输。<br>
URL 中的查询参数也不是 GET 所独有的,POST 请求的 URL 中也可以有参数的。
HTTP 特性
HTTP 1.1 的优点
HTTP 最凸出的优点是「简单、灵活和易于扩展、应用广泛和跨平台」。
<b>1. 简单:<br></b><ul><li>HTTP 基本的报文格式就是 header + body,头部信息也是 key-value 简单文本的形式,易于理解,降低了学习和使用的门槛。</li></ul>
<b>2. 灵活和易于扩展:<br></b><ul><li><span style="font-size: inherit;">HTTP协议里的各类请求方法、URI/URL、状态码、头字段等每个组成要求都没有被固定死,都允许开发人员自定义和扩充。</span></li><li><span style="font-size: inherit;">同时 HTTP 由于是工作在应用层( OSI 第七层),则它下层可以随意变化。</span></li><li><span style="font-size: inherit;">HTTPS 也就是在 HTTP 与 TCP 层之间增加了 SSL/TLS 安全传输层,HTTP/3 甚至把 TCP 层换成了基于 UDP 的 QUIC。</span></li></ul>
<b>3. 应用广泛和跨平台:<br></b><ul><li>互联网发展至今,HTTP 的应用范围非常的广泛,同时天然具有跨平台的优越性。</li></ul>
HTTP 1.1 的缺点
HTTP 协议里有优缺点一体的 <b>双刃剑</b>,分别是「无状态、明文传输」,同时还有一大缺点「不安全」。<br>
<b>1. 无状态双刃剑</b><br>
无状态的 <b>好处</b>,因为服务器不会去记忆 HTTP 的状态,所以不需要额外的资源来记录状态信息,<br>这能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。
无状态的 <b>坏处</b>,既然服务器没有记忆能力,它在完成有关联性的操作时会非常麻烦。例如登录->添加购物车->下单->结算->支付,<br>这系列操作都要知道用户的身份才行。但服务器不知道这些请求是有关联的,每次都要问一遍身份信息。<br>这样每操作一次,都要验证信息,这样的购物体验还能愉快吗?<br>
对于无状态的问题,解法方案有很多种,其中比较简单的方式用 Cookie 技术。
Cookie 通过在请求和响应报文中写入 Cookie 信息来控制客户端的状态。<br>
相当于,在客户端第一次请求后,服务器会下发一个装有客户信息的「小贴纸」,<br>后续客户端请求服务器的时候,带上「小贴纸」,服务器就能认得了了,
<b>2. 明文传输双刃剑</b>
明文意味着在传输过程中的信息,是可方便阅读的,通过浏览器的 F12 控制台或 <br>Wireshark 抓包都可以直接肉眼查看,为我们调试工作带了极大的便利性。
但是这正是这样,HTTP 的所有信息都暴露在了光天化日下,相当于信息裸奔。在传输的漫长的过程中,<br>信息的内容都毫无隐私可言,很容易就能被窃取,如果里面有你的账号密码信息,那你号就可能被盗。
<b>3. 不安全</b><br>
HTTP 比较严重的缺点就是不安全:<br><ul><li>通信使用明文(不加密),内容可能会被窃听。比如,账号信息容易泄漏,号没了。</li><li>不验证通信方的身份,因此有可能遭遇伪装。比如,访问假的淘宝、拼多多,钱没了。</li><li>无法证明报文的完整性,所以有可能已遭篡改。比如,网页上植入垃圾广告,视觉污染,眼没了。</li></ul>
HTTP 的安全问题,可以用 HTTPS 的方式解决,也就是通过引入 SSL/TLS 层,使得在安全上达到了极致。<br>
HTTP 1.1 的性能如何?
HTTP 协议是基于 <b>TCP/IP</b>,并且使用了<b>「请求 - 应答」</b>的通信模式,所以性能的关键就在这 <b>两点 </b>里。<br>
<b>1. 长连接</b><br>
早期 HTTP/1.0 性能上的一个很大的问题,那就是每发起一个请求,都要新建一次 TCP <br>连接(三次握手),而且是串行请求,做了无谓的 TCP 连接建立和断开,增加了通信开销。
为了解决上述 TCP 连接问题,HTTP/1.1 提出了 <b>长连接 </b>的通信方式,也叫持久连接。这种方式<br>的好处在于减少了 TCP 连接的重复建立和断开所造成的额外开销,减轻了服务器端的负载。
持久连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。
当然,如果某个 HTTP 长连接超过一定时间没有任何数据交互,服务端就会主动断开这个连接。
<b>2. 管道网络传输</b><br>
HTTP/1.1 采用了长连接的方式,这使得管道(pipeline)网络传输成为了可能。
即可在同一个 TCP 连接里面,客户端可以发起多个请求,只要第一个请求发出去了,<br>不必等其回来,就可以发第二个请求出去,可以 <b><font color="#0000ff">减少整体的响应时间</font></b>。
举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待<br>服务器做出回应,收到后再发出 B 请求。那么,管道机制则是允许浏览器同时发出 A 请求和 B 请求,但是<br><b><font color="#0000ff">服务器必须按照接收请求的顺序发送对这些管道化请求的响应</font></b>。如下图:
如果服务端在处理 A 请求时耗时比较长,那么后续的请求的处理都会被阻塞住,这称为「队头堵塞」。
所以,<b><font color="#0000ff">HTTP/1.1 管道解决了请求的队头阻塞,但是没有解决响应的队头阻塞</font>。</b>
<b>注意:</b>实际上 HTTP/1.1 管道化技术不是默认开启,而且浏览器基本都没有支持,<br>所以后面讨论HTTP/1.1 都是建立在没有使用管道化的前提。
<b>3. 队头阻塞</b><br>
「请求 - 应答」的模式加剧了 HTTP 的性能问题。<br><br>因为当顺序发送的请求序列中的一个请求因为某种原因被阻塞时,在后面排队的所有<br>请求也一同被阻塞了,会招致客户端一直请求不到数据,这也就是「队头阻塞」
总之 HTTP/1.1 的性能一般般,后续的 HTTP/2 和 HTTP/3 就是在优化 HTTP 的性能。<br>
HTTP 缓存技术
HTTP 缓存有哪些实现方式?
对于一些具有重复性的 HTTP 请求,比如 <b><font color="#0000ff">每次请求得到的数据都一样的</font></b>,我们可以把这对「请求-响应」的数据都 <b><font color="#0000ff">缓存在本地</font></b>,<br>那么下次就直接读取本地的数据,<b><font color="#0000ff">不必在通过网络获取服务器的响应了</font></b>,这样的话 HTTP/1.1 的性能肯定肉眼可见的提升。
所以,避免发送 HTTP 请求的方法就是通过 <b><font color="#0000ff">缓存技术</font></b>,HTTP 设计者早在之前就考虑到了这点,因此 HTTP 协议的头部有不少是针对缓存的字段。<br>
HTTP 缓存有两种实现方式,分别是 <b><font color="#0000ff">强制缓存</font> </b>和 <b><font color="#0000ff">协商缓存</font></b>。
强制缓存
<b><font color="#0000ff">强缓存指的是只要浏览器判断缓存没有过期,则直接使用浏览器的本地缓存</font></b>,决定是否使用缓存的主动性在于浏览器这边。
如下图中,返回的是 200 状态码,但在 size 项中标识的是 from disk cache,就是使用了强制缓存。
强缓存是利用下面这两个 HTTP 响应头部(Response Header)字段实现的,它们都用来表示资源在客户端缓存的有效期:<br><ul><li>Cache-Control, 是一个相对时间;</li><li>Expires,是一个绝对时间;</li></ul>
如果 HTTP 响应头部同时有 Cache-Control 和 Expires 字段的话,Cache-Control的优先级高于 Expires 。
Cache-control 选项更多一些,设置更加精细,所以建议使用 Cache-Control 来实现强缓存。具体的实现流程如下:<br><ul><li>当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在</li></ul><span style="font-size: inherit;"> Response 头部加上 Cache-Control,Cache-Control 中设置了过期时间大小;</span><br><ul><li>浏览器再次请求访问服务器中的该资源时,会<b>先通过请求资源的时间与 Cache-Control 中设置</b></li></ul><b> 的过期时间大小,来计算出该资源是否过期</b>,如果没有,则使用该缓存,否则重新请求服务器;<br><ul><li>服务器再次收到请求后,会再次更新 Response 头部的 Cache-Control。</li></ul>
协商缓存
当我们在浏览器使用开发者工具的时候,你可能会看到过某些请求的响应码是 304,这个是告诉浏览器<br>可以使用本地缓存的资源,通常这种 <b><font color="#0000ff">通过服务端告知客户端是否可以使用缓存的方式被称为协商缓存</font></b>。
上图就是一个协商缓存的过程,所以协商缓存就是 <b><font color="#0000ff">与服务端协商之后,通过协商结果来判断是否使用本地缓存</font></b>。<br>
协商缓存可以基于两种头部来实现。<br>
第一种:请求头部中的 If-Modified-Since 字段与响应头部中的 Last-Modified 字段实现,这两个字段的意思是:<br><ul><li>响应头部中的 Last-Modified:标示这个响应资源的最后修改时间;</li><li>请求头部中的 If-Modified-Since:当资源过期了,发现响应头中具有 Last-Modified 声明,则再次发起请求的时候<br>带上 Last-Modified 的时间,服务器收到请求后发现有 If-Modified-Since 则与被请求资源的最后修改时间进行对比<br>(Last-Modified),如果最后修改时间较新(大),说明资源又被改过,则返回最新资源,HTTP 200 OK;如果最<br>后修改时间较旧(小),说明资源无新修改,响应 HTTP 304 走缓存。</li></ul>
第二种:请求头部中的 If-None-Match 字段与响应头部中的 ETag 字段,这两个字段的意思是:<br><ul><li>响应头部中 Etag:唯一标识响应资源;</li><li>请求头部中的 If-None-Match:当资源过期时,浏览器发现响应头里有 Etag,则再次向服务器发起请求时,会将请求头<br>If-None-Match 值设置为 Etag 的值。服务器收到请求后进行比对,如果资源没有变化返回 304,如果资源变化了返回 200。</li></ul>
第一种实现方式是基于时间实现的,第二种实现方式是基于一个唯一标识实现的,相对来<br>说后者可以更加准确地判断文件内容是否被修改,避免由于时间篡改导致的不可靠问题。
如果 HTTP 响应头部同时有 Etag 和 Last-Modified 字段的时候, Etag 的优先级更高,<br>也就是先会判断 Etag 是否变化了,如果 Etag 没有变化,然后再看 Last-Modified。
注意,<b>协商缓存这两个字段都需要配合强制缓存中 Cache-control 字段来使用,<br>只有在未能命中强制缓存的时候,才能发起带有协商缓存字段的请求。</b>
使用 ETag 字段实现的协商缓存的过程如下:<br><ul><li>当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在 Response <br>头部加上 ETag 唯一标识,这个唯一标识的值是根据当前请求的资源生成的;</li><li>当浏览器再次请求访问服务器中的该资源时,首先会先检查强制缓存是否过期,如果没有过期,<br>则直接使用本地缓存;如果缓存过期了,会在 Request 头部加上 If-None-Match 字段,该字段的值就是 ETag 唯一标识;</li><li>服务器再次收到请求后,<b>会根据请求中的 If-None-Match 值与当前请求的资源生成的唯一标识进行比较</b>:</li></ul><span style="font-size: inherit;"> · 如果值相等,则返回 304 Not Modified,不会返回资源;</span><br><span style="font-size: inherit;"> · 如果不相等,则返回 200 状态码和返回资源,并在 Response 头部加上新的 ETag 唯一标识;</span><br><ul><li>如果浏览器收到 304 的请求响应状态码,则会从本地缓存中加载资源,否则更新资源。</li></ul>
HTTP 与 HTTPS
HTTP 与 HTTPS 有什么区别?
HTTP 是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不<br>安全的缺陷,在 TCP 和 HTTP 层之间加入了 SSL/TLS 安全协议,使得报文能够加密传输。
HTTP 连接建立相对简单, TCP 三次握手之后便可进行 HTTP 的报文传输。而 HTTPS <br>在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。
HTTP 的端口号是 80,HTTPS 的端口号是 443。
HTTPS 协议需要向 CA(证书权威机构)申请数字证书,来保证服务器的身份是可信的。<br>
HTTPS 解决了 HTTP 哪些问题?
HTTP 由于是明文传输,所以安全上存在以下三个风险:<br><ul><li><b>窃听风险</b>,比如通信链路上可以获取通信内容,用户号容易没。</li><li><b>篡改风险</b>,比如强制植入垃圾广告,视觉污染,用户眼容易瞎。</li><li><b>冒充风险</b>,比如冒充淘宝网站,用户钱容易没。</li></ul>
HTTPS 在 HTTP 与 TCP 层之间加入了 SSL/TLS 协议,可以很好的解决了上述的风险:<br><ul><li><b>信息加密</b>:交互信息无法被窃取,但你的号会因为「自身忘记」账号而没。</li><li><b>校验机制</b>:无法篡改通信内容,篡改了就不能正常显示,但百度「竞价排名」依然可以搜索垃圾广告。</li><li><b>身份证书</b>:证明淘宝是真的淘宝网,但你的钱还是会因为「剁手」而没。</li></ul>
HTTPS 是如何解决上面的三个风险的?<br>
<b>1. 混合加密</b><br>
<b>混合加密 </b>的方式实现信息的 <b>机密性</b>,解决了窃听的风险。
HTTPS 采用的是 <b>对称加密 </b>和 <b>非对称加密 </b>结合的「混合加密」方式:<br><ul><li>在通信建立前采用非对称加密的方式交换「会话秘钥」,后续就不再使用非对称加密。</li><li>在通信过程中全部使用对称加密的「会话秘钥」的方式加密明文数据。</li></ul>
采用「混合加密」的方式的原因:<br><ul><li>对称加密只使用一个密钥,运算速度快,密钥必须保密,无法做到安全的密钥交换。</li><li>非对称加密使用两个密钥:公钥和私钥,公钥可以任意分发而私钥保密,解决了密钥交换问题但速度慢。</li></ul>
<b>2. 摘要算法 + 数字签名</b><br>
<b>摘要算法 </b>的方式来实现 <b>完整性</b>,它能够为数据生成独一无二的「指纹」,<br>指纹用于校验数据的完整性,解决了篡改的风险。
为了保证传输的内容不被篡改,我们需要对内容计算出一个「指纹」,然后同内容一起传输给对方。<br><br>对方收到后,先是对内容也计算出一个「指纹」,然后跟发送方发送的「指纹」做一个比较,<br>如果「指纹」相同,说明内容没有被篡改,否则就可以判断出内容被篡改了。
那么,在计算机里会用 <b>摘要算法(哈希函数)来计算出内容的哈希值</b>,也就<br>是内容的「指纹」,这个 <b>哈希值是唯一的,且无法通过哈希值推导出内容</b>。
通过哈希算法可以确保内容不会被篡改,<b>但是并不能保证「内容 + 哈希值」<br>不会被中间人替换,因为这里缺少对客户端收到的消息是否来源于服务端的证明。</b>
那为了避免这种情况,计算机里会用 <b>非对称加密算法 </b>来解决,共有两个密钥:<br><ul><li>一个是公钥,这个是可以公开给所有人的;</li><li>一个是私钥,这个必须由本人管理,不可泄露。</li></ul>
这两个密钥可以 <b>双向加解密 </b>的,比如可以用公钥加密内容,然后用私钥解密,也可以用私钥加密内容,公钥解密内容。
流程的不同,意味着目的也不相同:<br><ul><li><b>公钥加密,私钥解密</b>。这个目的是为了保证内容传输的安全,因为被公钥加密的内容,<br>其他人是无法解密的,只有持有私钥的人,才能解密出实际的内容;</li><li><b>私钥加密,公钥解密</b>。这个目的是为了保证消息不会被冒充,因为私钥是不可泄露的,如果<br>公钥能正常解密出私钥加密的内容,就能证明这个消息是来源于持有私钥身份的人发送的。</li></ul>
一般我们不会用非对称加密来加密实际的传输内容,因为非对称加密的计算比较耗费性能的。
所以非对称加密的用途主要在于 <b>通过「私钥加密,公钥解密」的方式,来确认消息的身份</b>,我们常说<br>的 <b>数字签名算法</b>,就是用的是这种方式,不过私钥加密内容不是内容本身,而是 <b>对内容的哈希值加密</b>。
私钥是由服务端保管,然后服务端会向客户端颁发对应的公钥。如果<br>客户端收到的信息,能被公钥解密,就说明该消息是由服务器发送的。
<b>3. 数字证书</b>
前面我们知道:<br><ul><li>可以通过哈希算法来保证消息的完整性;</li><li>可以通过数字签名来保证消息的来源可靠性(能确认消息是由持有私钥的一方发送的);</li></ul>
但是这还远远不够,还缺少身份验证的环节,万一公钥是被伪造的呢?<br><br>例如,有个坏人伪造出了一对公私钥,将服务器给你的真公钥替换成了假的,然后这个坏人用假的私钥做了个「数字签名」。<br>当你拿到数字签名后,你并不知道服务器给你的公钥被替换了,所以你还是按照往常一样用此公钥解密,由于这个公钥和私<br>钥是配对的(因为此「数字签名」是坏人用假的私钥做的,所以当然可以匹配到此假的公钥),这样你就被这个坏人骗过了。
为了解决上述问题,就需要引入一个权威的机构 <b>CA</b>(数字证书认证机构)。你可以将你的公钥注册到 CA,CA 会用他们自己的私钥<br>对你的公钥做个「数字签名」,然后将你的「个人信息 + 公钥 + 数字签名」打包成一个 <b>数字证书</b>,也就是说这个<b>数字证书包含你的公钥。<br><br></b>这样当你拿到数字证书时,首先去 CA 验证这个数字证书是否合法,因为数字证书里面有 CA 的数字签名,CA 验证该证书合法后,才用他的公钥<br>解密,如果能解密成功,说明这个数字证书是在 CA 注册过的,就认为此数字证书是合法的,然后会把数字证书里的公钥(服务器的)给你。
由于通过 CA 验证了数字证书是合法的,那么就能证明这个公钥是服务器的,而不是坏人伪造的,<br>于是你就可以安心的用这个公钥解密出服务器给你发的信息了。正是通过这个 CA 来证明服务器的身份。
在计算机里,权威的机构 CA (数字证书认证机构)将服务器公钥放在<br><b>数字证书</b>(由数字证书认证机构颁发)中,只要证书是可信的,公钥就是可信的。
通过数字证书的方式保证服务器公钥的身份,解决冒充的风险。<br>
HTTPS 是如何建立连接的?
SSL/TLS 协议基本流程:<br><ul><li>客户端向服务器索要并验证服务器的公钥。</li><li>双方协商生产「会话秘钥」。</li><li>双方采用「会话秘钥」进行加密通信。</li></ul>前两步也就是 SSL/TLS 的建立过程,也就是 TLS 握手阶段。
SSL/TLS 的「握手阶段」涉及四次通信, 基于 RSA 握手过程的 HTTPS 见下图:
SSL/TLS 协议建立的详细流程<br>
1. ClientHello<br>
首先,由客户端向服务器发起加密通信请求,也就是 ClientHello 请求。
在这一步,客户端主要向服务器发送以下信息:<br>(1)客户端支持的 SSL/TLS 协议版本,如 TLS 1.2 版本。<br>(2)客户端生产的随机数(Client Random),后面用于生成「会话秘钥」条件之一。<br>(3)客户端支持的密码套件列表,如 RSA 加密算法。
2. SeverHello<br>
服务器收到客户端请求后,向客户端发出响应,也就是 SeverHello。服务器回应的内容有如下内容:<br>(1)确认 SSL/ TLS 协议版本,如果浏览器不支持,则关闭加密通信。<br>(2)服务器生产的随机数(Server Random),也是后面用于生产「会话秘钥」条件之一。<br>(3)确认的密码套件列表,如 RSA 加密算法。<br>(4)服务器的数字证书。
3.客户端回应<br>
客户端收到服务器的回应之后,首先通过浏览器或者操作系统中的 CA 公钥,确认服务器的数字证书的真实性。
如果证书没有问题,客户端会 <b>从数字证书中取出服务器的公钥</b>,然后使用它加密报文,向服务器发送如下信息:<br>(1)一个随机数(pre-master key)。该随机数会被服务器公钥加密。<br>(2)加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。<br>(3)客户端握手结束通知,表示客户端的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供服务端校验。
上面第一项的随机数是整个握手阶段的第三个随机数,会发给服务端,所以这个随机数客户端和服务端都是一样的。
<b>服务器和客户端有了这三个随机数(Client Random、Server Random、pre-master key),<br>接着就用双方协商的加密算法,各自生成本次通信的「会话秘钥」。</b>
4. 服务器的最后回应<br>
服务器收到客户端的第三个随机数(pre-master key)之后,通过协商的加密算法,计算出本次通信的「会话秘钥」。
然后,向客户端发送最后的信息:<br>(1)加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。<br>(2)服务器握手结束通知,表示服务器的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供客户端校验。
至此,整个 SSL/TLS 的握手阶段全部结束。接下来,客户端与服务器进入加密通信,就完全是使用普通的 HTTP 协议,只不过用「会话秘钥」加密内容。
客户端校验数字证书的流程是怎样的?
如下图所示,为数字证书签发和验证流程:
CA 签发证书的过程,如上图左边部分:<br><ul><li>首先 CA 会把持有者的公钥、用途、颁发者、有效时间等信息打成一个包,然后对这些信息进行 Hash 计算,得到一个 Hash 值;</li><li>然后 CA 会使用自己的私钥将该 Hash 值加密,生成 Certificate Signature,也就是 CA 对证书做了签名;</li><li>最后将 Certificate Signature 添加在文件证书上,形成数字证书;</li></ul>
客户端校验服务端的数字证书的过程,如上图右边部分:<br><ul><li>首先客户端会使用同样的 Hash 算法获取该证书的 Hash 值 H1;</li><li>通常浏览器和操作系统中集成了 CA 的公钥信息,浏览器收到证书后可以使用 CA 的公钥解密 Certificate Signature 内容,得到一个 Hash 值 H2 ;</li><li>最后比较 H1 和 H2,如果值相同,则为可信赖的证书,否则则认为证书不可信。</li></ul>
但事实上,证书的验证过程中还存在一个证书信任链的问题,因为我们向 CA 申请的证书一般不是根证书签发的,<br>而是由中间证书签发的,比如百度的证书,从下图你可以看到,证书的层级有三级:
对于这种三级层级关系的证书的验证过程如下:<br><ul><li>客户端收到 baidu.com 的证书后,发现这个证书的签发者不是根证书,就无法根据本地已有的根证书中的公钥去验证 baidu.com 证书是否可信。于是,客户端根据 baidu.com 证书中的签发者,找到该证书的颁发机构是 “GlobalSign Organization Validation CA - SHA256 - G2”,然后向 CA 请求该中间证书。</li><li>请求到证书后发现 “GlobalSign Organization Validation CA - SHA256 - G2” 证书是由 “GlobalSign Root CA” 签发的,由于 “GlobalSign Root CA” 没有再上级签发机构,说明它是根证书,也就是自签证书。应用软件会检查此证书有否已预载于根证书清单上,如果有,则可以利用根证书中的公钥去验证 “GlobalSign Organization Validation CA - SHA256 - G2” 证书,如果发现验证通过,就认为该中间证书是可信的。</li><li>“GlobalSign Organization Validation CA - SHA256 - G2” 证书被信任后,可以使用 “GlobalSign Organization Validation CA - SHA256 - G2” 证书中的公钥去验证 baidu.com 证书的可信性,如果验证通过,就可以信任 baidu.com 证书。</li></ul>
在这四个步骤中,最开始客户端只信任根证书 GlobalSign Root CA 证书的,然后 “GlobalSign Root CA” <br>证书信任 “GlobalSign Organization Validation CA - SHA256 - G2” 证书,而 “GlobalSign Organization <br>Validation CA - SHA256 - G2” 证书又信任 baidu.com 证书,于是客户端也信任 baidu.com 证书。
总括来说,由于用户信任 GlobalSign,所以由 GlobalSign 所担保的 baidu.com 可以被信任,<br>另外由于用户信任操作系统或浏览器的软件商,所以由软件商预载了根证书的 GlobalSign 都可被信任。
这样的一层层地验证就构成了一条信任链路,整个证书信任链验证流程如下图所示:
为什么需要证书链这么麻烦的流程?Root CA 为什么不直接颁发证书,而是要搞那么多中间层级呢?<br><br><b>这是为了确保根证书的绝对安全性,将根证书隔离地越严格越好,不然根证书如果失守了,那么整个信任链都会有问题。</b>
HTTPS 的应用数据是如何保证完整性的?
TLS 在实现上分为 <b>握手协议 </b>和 <b>记录协议 </b>两层:<br><ul><li>TLS 握手协议就是我们前面说的 TLS 四次握手的过程,负责协商加密算法和生成对称密钥,后续用此密钥来保护应用程序数据(即 HTTP 数据);</li><li>TLS 记录协议负责保护应用程序数据并验证其完整性和来源,所以对 HTTP 数据加密是使用记录协议;</li></ul>
TLS 记录协议主要负责消息(HTTP 数据)的压缩,加密及数据的认证,过程如下图:
具体过程如下:<br><ul><li>首先,消息被分割成多个较短的片段,然后分别对每个片段进行压缩。</li><li>接下来,经过压缩的片段会被 <b>加上消息认证码(MAC 值,这个是通过哈希算法生成的),这是为了保证完整性,并进行数据的认证。</b><br>通过附加消息认证码的 MAC 值,可以识别出篡改。与此同时,为了防止重放攻击,在计算消息认证码时,还加上了片段的编码。</li><li>再接下来,经过压缩的片段再加上消息认证码会一起通过对称密码进行加密。</li><li>最后,上述经过加密的数据再加上由数据类型、版本号、压缩后的长度组成的报头就是最终的报文数据。</li></ul><br>记录协议完成后,最终的报文数据将传递到传输控制协议 (TCP) 层进行传输。
HTTP 的演变
HTTP 1.1 相比 HTTP 1.0 提高了什么性能?
HTTP/1.1 相比 HTTP/1.0 性能上的改进:<br><ul><li>使用 <b><font color="#0000ff">长连接 </font></b>的方式改善了 HTTP/1.0 短连接造成的性能开销。</li><li>支持 <b><font color="#0000ff">管道(pipeline)网络传输</font></b>,只要第一个请求发出去了,不必等其回来,<br>就可以发第二个请求出去,可以减少整体的响应时间。</li></ul>
但 HTTP/1.1 还是有性能瓶颈:<br><ul><li><b><font color="#0000ff">请求 / 响应头部(Header)未经压缩就发送</font></b>,首部信息越多延迟越大。只能压缩 Body 的部分;</li><li><b><font color="#0000ff">发送冗长的首部</font></b>。每次互相发送相同的首部造成的浪费较多;</li><li><b><font color="#0000ff">服务器是按请求的顺序响应的</font></b>,如果服务器响应慢,会招致客户端一直请求不到数据,也就是队头阻塞;</li><li><b><font color="#0000ff">没有请求优先级控制</font></b>;</li><li><b><font color="#0000ff">请求只能从客户端开始</font></b>,服务器只能被动响应。</li></ul>
HTTP 2 做了什么优化?
<b><font color="#0000ff">HTTP/2 协议是基于 HTTPS 的</font></b>,所以 HTTP/2 的安全性也是有保障的。
HTTP/2 相比 HTTP/1.1 性能上的改进
1. 头部压缩<br>
HTTP/2 会 <b><font color="#0000ff">压缩头</font></b>(Header)如果你同时发出多个请求,他们的头是一样的或是相似的,<br>那么,协议会帮你 <b><font color="#0000ff">消除重复的部分</font>。</b>
这就是所谓的 <font color="#ff00ff"><i>HPACK</i></font> 算法:在客户端和服务器同时维护一张<font color="#ff00ff"><i>头信息表</i></font>,<font color="#ff00ff"><i>所有字段都会存入这个表</i></font>,<br>生成一个索引号,以后就不发送同样字段了,<font color="#ff00ff"><i>只发送索引号</i></font>,这样就 <b><font color="#0000ff">提高速度</font> </b>了。
2. 二进制格式
HTTP/2 不再像 HTTP/1.1 里的纯文本形式的报文,而是全面采用了<font color="#0000ff"> <b>二进制格式</b></font>,头信息和数据体都是二进制,<br>并且统称为帧(frame):<b><font color="#0000ff">头信息帧</font></b>(Headers Frame)和 <b><font color="#0000ff">数据帧</font></b>(Data Frame)。
这样虽然对人不友好,但是对计算机非常友好,因为计算机只懂二进制,那么收到报文后,<br>无需再将明文的报文转成二进制,而是直接解析二进制报文,这 <b><font color="#0000ff">增加了数据传输的效率</font></b>。
比如状态码 200 ,在 HTTP/1.1 是用 '2''0''0' 三个字符来表示(二进制:110010 110000 110000),如图:
在 HTTP/2 是用数字 200 表示(二进制:10001000),如图:
3. Stream 数据流
HTTP/2 的数据包 <b><font color="#0000ff">不是按顺序发送的</font></b>,同一个连接里面连续的数据包,可能属于不同的回应。<br>因此,<b><font color="#0000ff">必须要对数据包做标记</font></b>,指出它属于哪个回应。
在 HTTP/2 中每个请求或响应的所有数据包,称为一个数据流(Stream)。每个数据流都标记着一个<br>独一无二的编号(Stream ID),<b><font color="#0000ff">不同 Stream 的帧是可以乱序发送的(因此可以并发不同的 Stream )</font></b>,<br>因为每个帧的头部会携带 Stream ID 信息,所以接收端可以通过 Stream ID 有序组装成 HTTP 消息
客户端和服务器 <b><font color="#0000ff">双方都可以建立 Stream</font></b>, Stream ID 也是有区别的,<br>客户端建立的 Stream 必须是奇数号,而服务器建立的 Stream 必须是偶数号。
客户端还可以 <b><font color="#0000ff">指定数据流的优先级</font></b>。优先级高的请求,服务器就先响应该请求。
4. 多路复用
HTTP/2 是可以在 <b><font color="#0000ff">一个 TCP 连接中并发多个 HTTP 请求或回应,而不用按照顺序一一对应</font>。</b>
移除了 HTTP/1.1 中的串行请求,不需要排队等待,也就不会再出现<br>「队头阻塞」问题,<b><font color="#0000ff">降低了延迟,大幅度提高了连接的利用率</font>。</b>
举例来说,在一个 TCP 连接里,服务器收到了客户端 A 和 B 的两个请求,如果发现 A 处理过程非常耗时,<br>于是就回应 A 请求已经处理好的部分,接着回应 B 请求,完成后,再回应 A 请求剩下的部分。
5. 服务器推送
HTTP/2 还在一定程度上改善了传统的「请求 - 应答」工作模式,<br>服务端不再是被动地响应,可以 <b><font color="#0000ff">主动</font> </b>向客户端发送消息。
比如,客户端通过 HTTP/1.1 请求从服务器那获取到了 HTML 文件,而 HTML 可能还需要依赖 CSS <br>来渲染页面,这时客户端还要再发起获取 CSS 文件的请求,需要两次消息往返,如下图左边部分。<br><br>在 HTTP/2 中,客户端在访问 HTML 时,服务器可以直接主动推送 CSS 文件,减少了消息传递的次数,<br>如下图右边部分。<br>
HTTP/2 有什么缺陷?<br>
HTTP/2 通过 Stream 的并发能力,解决了 HTTP/1 队头阻塞的问题,看似很完美了,<br>但是 <b><font color="#0000ff">HTTP/2 还是存在“队头阻塞”的问题</font></b>,只不过问题不是在 HTTP 这一层面,而是 <b><font color="#0000ff">在 TCP 这一层</font></b>。
<b><font color="#0000ff">HTTP/2 是基于 TCP 协议来传输数据的,TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且连续的,这样<br>内核才会将缓冲区里的数据返回给 HTTP 应用,那么当「前 1 个字节数据」没有到达时,后收到的字节数据只能存放在<br>内核缓冲区里,只有等到这 1 个字节数据到达时,HTTP/2 应用层才能从内核中拿到数据,这就是 HTTP/2 队头阻塞问题</font>。</b>
而且,如果接收方没有及时回复 ACK,那么发送方的滑动窗口也不能继续向前滑动,因此发送方也会阻塞。<br>
举个例子,下图中发送方发送了很多个 packet,每个 packet 都有自己的序号,你可以认为是 TCP 的序列号,其中 packet 3 在网络<br>中丢失了,即使 packet 4-6 被接收方收到后,由于内核中的 TCP 数据不是连续的,于是接收方的应用层就无法从内核中读取到,只<br>有等到 packet 3 重传后,接收方的应用层才可以从内核中读取到数据,这就是 HTTP/2 的队头阻塞问题,是在 TCP 层面发生的。
所以,一旦发生了 <font color="#0000ff"><b>丢包 </b></font>现象,就会触发 TCP 的 <b><font color="#0000ff">重传机制</font></b>,这样在一个 TCP 连接中的 <b><font color="#0000ff">所有的 HTTP 请求都必须等待这个丢了的包被重传回来</font></b>。
HTTP 3 做了哪些优化?
前面我们知道了 HTTP/1.1 和 HTTP/2 都有队头阻塞的问题:<br><ul><li>HTTP/1.1 中的管道( pipeline)虽然解决了请求的队头阻塞,但是没有解决响应的队头阻塞,因为服务端需要按顺序响应收到的请求,<br>如果服务端处理某个请求消耗的时间比较长,那么只能等响应完这个请求后, 才能处理下一个请求,这属于 HTTP 层队头阻塞。</li><li>HTTP/2 虽然通过多个请求复用一个 TCP 连接解决了 HTTP 的队头阻塞 ,但是一旦发生丢包,就会阻塞住所有的 HTTP 请求,这属于 TCP 层队头阻塞。</li></ul>
HTTP/2 队头阻塞的问题是因为 TCP,所以 <b><font color="#0000ff">HTTP/3 把 HTTP 下层的 TCP 协议改成了 UDP</font></b>:
UDP 发送是不管顺序,也不管丢包的,所以不会出现像 HTTP/2 队头阻塞的问题。大家都<br>知道 UDP 是不可靠传输的,但基于 UDP 的 <b><font color="#0000ff">QUIC 协议</font></b> 可以实现类似 TCP 的可靠性传输。
QUIC 有 3 个特点
1、无队头阻塞<br>
QUIC 协议也有类似 HTTP/2 Stream 与多路复用的概念,也是可以在同一条连接上<br>并发传输多个 Stream,Stream 可以认为就是一条 HTTP 请求。
QUIC 有自己的一套机制可以保证传输的可靠性的。<b><font color="#0000ff">当某个流发生丢包时,只会阻塞这个流,其他流不会受到影响,<br>因此不存在队头阻塞问题</font></b>。这与 HTTP/2 不同,HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。
所以,QUIC 连接上的 <b><font color="#0000ff">多个 Stream 之间并没有依赖</font></b>,都是独立的,某个流发生丢包了,只会影响该流,其他流不受影响。
2、更快的连接建立<br>
对于 HTTP/1 和 HTTP/2 协议,<b><font color="#0000ff">TCP 和 TLS 是分层的</font></b>,分别属于内核实现的传输层、openssl <br>库实现的表示层,因此它们 <b><font color="#0000ff">难以合并在一起,需要分批次来握手</font></b>,先 TCP 握手,再 TLS 握手。
HTTP/3 在传输数据前虽然需要 <b><font color="#0000ff">QUIC 协议握手,这个握手过程只需要 1 RTT</font></b>,<br>握手的 <b><font color="#0000ff">目的是为确认双方的「连接 ID」</font></b>,连接迁移就是基于连接 ID 实现的。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是 <b><font color="#0000ff">QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”</font></b>,<br>再加上 <b><font color="#0000ff">QUIC 使用的是 TLS/1.3,因此仅需 1 个 RTT</font></b> 就可以「同时」完成建立连接与密钥协商,如下图:
甚至,在 <b><font color="#0000ff">第二次连接 </font></b>的时候,<b><font color="#0000ff">应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果</font></b>。
3、连接迁移<br>
基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接,<br>那么 <b><font color="#0000ff">当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立连接</font></b>。<br>而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络<br>突然卡顿了一下,因此连接的迁移成本是很高的。
而 <b><font color="#0000ff">QUIC 协议 </font></b>没有用四元组的方式来“绑定”连接,而是通过 <b><font color="#0000ff">连接 ID 来标记通信的两个端点</font></b>,客户端和服务器可以各自选择一组<br>ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,<b><font color="#0000ff">只要仍保有上下文信息(比如连接 ID、TLS 密钥等),<br>就可以“无缝”地复用原连接</font></b>,消除重连的成本,没有丝毫卡顿感,达到了 <b><font color="#0000ff">连接迁移</font> </b>的功能。
所以, QUIC 是一个在 UDP 之上的 <b><font color="#0000ff">伪</font></b> TCP + TLS + HTTP/2 的多路复用的协议。
QUIC 是新协议,对于很多网络设备,根本不知道什么是 QUIC,只会当做 UDP,这样会出现新的问题,因为有的网络设备是会<br>丢掉 UDP 包的,而 QUIC 是基于UDP 实现的,那么如果网络设备无法识别这个是 QUIC 包,那么就会当作 UDP包,然后被丢弃。<br><br>所以,HTTP/3 现在普及的进度非常的缓慢,不知道未来 UDP 是否能够逆袭 TCP。
HTTP/1.1 如何优化
可以从下面这三种优化思路来优化 HTTP/1.1 协议:<br><ul><li>尽量避免发送 HTTP 请求;</li><li>在需要发送 HTTP 请求时,考虑如何减少请求次数;</li><li>减少服务器的 HTTP 响应的数据大小;</li></ul>
1. 如何避免发送 HTTP 请求
不发送 HTTP 请求,那客户端还怎么和服务器交互数据?<br>此处并不是真正的不发,而是 <b><font color="#0000ff">不发无用、重复的请求</font></b>。
对于一些具有重复性的 HTTP 请求,比如每次请求得到的数据都一样的,我们可以把这对「请求-响应」的数据都 <b><font color="#0000ff">缓存在本地</font></b>,<br>那么下次就直接读取本地的数据,不必在通过网络获取服务器的响应了,这样的话 HTTP/1.1 的性能肯定肉眼可见的提升。
所以,避免发送 HTTP 请求的方法就是通过 <b><font color="#0000ff">缓存技术</font></b>,HTTP 设计者早在之前就考虑到了这点,<br>因此 HTTP 协议的头部有不少是针对缓存的字段。
那缓存是如何做到的呢?<br>
客户端会把第一次请求以及响应的数据保存在本地磁盘上,其中将<br>请求的 URL 作为 key,而响应作为 value,两者形成映射关系。
这样当后续发起相同的请求时,就可以先在本地磁盘上通过 key 查到对应的 value,也就是响应,<br>如果找到了,就直接从本地读取该响应。毋庸置疑,读取本地磁盘的速度肯定比网络请求快得多,如下图:
万一缓存的响应不是最新的,而客户端并不知情,那么该怎么办呢?
服务器在发送 HTTP 响应时,会估算一个过期的时间,并把这个信息放到响应头部中,<br>这样 <b><font color="#0000ff">客户端在查看响应头部的信息时,一旦发现缓存的响应是过期的,则就会重新发送网络请求</font></b>。
如果客户端请求时得到的响应头部中发现该响应过期了,客户端重新发送请求,但服务器<br>上的资源并没有变更(<b><font color="#0000ff">只是时间过期了而已</font></b>),那么还要在服务器的响应带上这个资源吗?
很显然不带的话,可以提高 HTTP 协议的性能,那具体如何做到呢?
只需要客户端在重新发送请求时,在请求的 Etag 头部带上第一次请求的响应头部中的摘要,<br>这个摘要是唯一标识响应的资源,当服务器收到请求后,会将 <b><font color="#0000ff">本地资源的摘要与请求中的摘要做个比较</font>:</b><br><ul><li>如果不同,那么说明客户端的缓存已经没有价值,服务器在响应中带上最新的资源。</li><li>如果相同,说明 <b><font color="#0000ff">客户端的缓存还是可以继续使用</font> </b>的,那么服务器 <b><font color="#0000ff">仅返回不含有包体的 304 Not Modified 响应</font></b>,<br>告诉客户端缓存仍然有效,让客户继续从缓存中获取资源,这样就可以减少响应资源在网络中传输的延时,如下图:</li></ul>
缓存真的是性能优化的一把万能钥匙,小到 CPU Cache、Page Cache、Redis Cache,大到 HTTP 协议的缓存。
2. 如何减少 HTTP 请求次数
减少 HTTP 请求次数自然也就提升了 HTTP 性能,可以从这 3 个方面入手:<br><ul><li>减少重定向请求次数;</li><li>合并请求;</li><li>延迟发送请求;</li></ul>
1. 减少重定向请求次数
服务器上的一个资源可能由于迁移、维护等原因从 url1 移至 url2 后,而客户端不知情,它还是继续请求 url1,<br>这时服务器不能粗暴地返回错误,而是通过 302 响应码和 Location 头部,告诉客户端该资源已经迁移至 url2 了,<br>于是客户端需要再发送 url2 请求以获得服务器的资源。
那么,如果重定向请求越多,那么客户端就要多次发起 HTTP 请求,每一次的 HTTP 请求都得经过网络,这无疑会越降低网络性能。
另外,服务端这一方往往不只有一台服务器,比如源服务器上一级是代理服务器,然后代理服务器才与客户端通信,<br>这时客户端重定向就会导致客户端与代理服务器之间需要 2 次消息传递,如下图:
如果 <b><font color="#0000ff">重定向的工作交由代理服务器完成,就能减少 HTTP 请求次数了</font></b>,如下图:
而且当 <b><font color="#0000ff">代理服务器知晓了重定向规则后,可以进一步减少消息传递次数</font></b>,如下图:
除了 302 重定向响应码,还有其他一些重定向的响应码,如下图。<br><br>其中,301 和 308 响应码是告诉客户端可以将重定向响应缓存到本地磁盘,<br>之后客户端就自动用 url2 替代 url1 访问服务器的资源。<br>
2. 合并请求
如果把多个访问小文件的请求合并成一个大的请求,虽然传输的总资源还是一样,<br>但是减少请求,也就意味着 <b><font color="#0000ff">减少了重复发送的 HTTP 头部</font></b>。
由于 HTTP/1.1 是请求响应模型,如果第一个发送的请求,未收到对应的响应,那么后续的请求就不会发送,<br>于是为了防止单个请求的阻塞,所以 <b><font color="#0000ff">一般浏览器会同时发起 5-6 个请求</font></b>,每一个请求都是不同的 TCP 连接,<br>那么如果合并了请求,也就会 <b><font color="#0000ff">减少 TCP 连接的数量,因而省去了 TCP 握手和慢启动过程耗费的时间</font>。</b>
有的网页会含有很多小图片、小图标,有多少个小图片,客户端就要发起多少次请求。那么对于这些小图片,<br>我们可以考虑使用 CSS Image Sprites 技术把它们合成一个大图片,这样浏览器就可以用一次请求获得一个<br>大图片,然后再根据 CSS 数据把大图片切割成多张小图片。
这种方式就是 <b><font color="#0000ff">通过将多个小图片合并成一个大图片来减少 HTTP 请求的次数,以减少 HTTP 请求的次数,从而减少网络的开销</font>。</b>
除了将小图片合并成大图片的方式,还有服务端使用 webpack 等打包工具将 js、css 等资源合并打包成大文件,也是能达到类似的效果。
另外,还可以将图片的二进制数据用 base64 编码后,以 URL 的形式潜入到 HTML 文件,跟随 HTML 文件一并发送。<br><image src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPoAAAFKCAIAAAC7M9WrAAAACXBIWXMAA ... /><br>
这样客户端收到 HTML 后,就可以直接解码出数据,然后直接显示图片,就不用再发起图片相关的请求,这样便减少了请求的次数。
可以看到,<b><font color="#0000ff">合并请求的方式就是合并资源,以一个大资源的请求替换多个小资源的请求</font>。</b>
但是这样的合并请求会带来新的问题,<b><font color="#0000ff">当大资源中的某一个小资源发生变化后,客户端必须<br>重新下载整个完整的大资源文件</font></b>,这显然带来了额外的网络消耗。
3. 延迟发送请求
不要一口气吃成大胖子,一般 HTML 里会含有很多 HTTP 的 URL,当前不需要的资源,我们<br>没必要也获取过来,于是可以通过<b><font color="#0000ff">「按需获取」</font></b>的方式,<b><font color="#0000ff">来减少第一时间的 HTTP 请求次数</font></b>。
请求网页的时候,没必要把全部资源都获取到,而是只获取当前用户所看到的页面资源,当<br>用户向下滑动页面的时候,再向服务器获取接下来的资源,这样就达到了延迟发送请求的效果。
3. 如何减少 HTTP 响应的数据大小
对于 HTTP 的请求和响应,通常 HTTP 的响应的数据大小会比较大,也就是服务器返回的资源会比较大。<br><br>于是,我们可以考虑对响应的资源进行 <b><font color="#0000ff">压缩</font></b>,这样就可以减少响应的数据大小,从而提高网络传输的效率。<br><br>压缩的方式一般分为 2 种,分别是:<br><ul><li>无损压缩;</li><li>有损压缩;</li></ul>
1. 无损压缩
无损压缩是指资源经过压缩后,信息不被破坏,还能完全恢复到压缩前的原样,<br>适合用在文本文件、程序可执行文件、程序源代码。
首先,我们针对代码的语法规则进行压缩,因为通常代码文件都有很多换行符或者空格,这些<br>是为了帮助程序员更好的阅读,但是机器执行时并不要这些符,把这些多余的符号给去除掉。
接下来,就是无损压缩了,需要对原始资源建立统计模型,利用这个统计模型,将常出现的数据用较短的二进制<br>比特序列表示,将不常出现的数据用较长的二进制比特序列表示,生成二进制比特序列一般是「霍夫曼编码」算法。<br>
gzip 就是比较常见的无损压缩。客户端支持的压缩算法,会在 HTTP 请求中通过头部中的 Accept-Encoding 字段告诉服务器:
服务器收到后,会从中选择一个服务器支持的或者合适的压缩算法,然后使用此压缩算法对响应资源进行压缩,<br>最后通过响应头部中的 content-encoding 字段告诉客户端该资源使用的压缩算法。
gzip 的压缩效率相比 Google 推出的 Brotli 算法还是差点意思,也就是上文中的 br,<br>所以如果可以,服务器应该选择压缩效率更高的 br 压缩算法。
2. 有损压缩
与无损压缩相对的就是有损压缩,经过此方法压缩,解压的数据会与原始数据不同但是非常接近。<br>
有损压缩主要将次要的数据舍弃,牺牲一些质量来减少数据量、提高压缩比,<br>这种方法经常用于压缩多媒体数据,比如音频、视频、图片。<br>
可以通过 HTTP 请求头部中的 Accept 字段里的「 q 质量因子」,告诉服务器期望的资源质量。
关于图片的压缩,目前压缩比较高的是 Google 推出的 WebP 格式,它与常见的 Png 格式图片的压缩比例对比如下图:
可以发现,相同图片质量下,WebP 格式的图片大小都比 Png 格式的图片小,所以对于<br>大量图片的网站,可以考虑使用 WebP 格式的图片,这将大幅度提升网络传输的性能。
关于音视频的压缩,音视频主要是动态的,每个帧都有时序的关系,通常时间连续的帧之间的变化是很小的。
比如,一个在看书的视频,画面通常只有人物的手和书桌上的书是会有变化的,而其他地方通常都是静态的,<br>于是只需要在一个静态的关键帧,使用 <b>增量数据 </b>来表达后续的帧,这样便减少了很多数据,提高了网络传输<br>的性能。对于视频常见的编码格式有 H264、H265 等,音频常见的编码格式有 AAC、AC3。
总结
第一个思路是,通过缓存技术来避免发送 HTTP 请求。客户端收到第一个请求的响应后,可以将其缓存在本地磁盘,<br>下次请求的时候,如果缓存没过期,就直接读取本地缓存的响应数据。如果缓存过期,客户端发送请求的时候带上<br>响应数据的摘要,服务器比对后发现资源没有变化,就发出不带包体的 304 响应,告诉客户端缓存的响应仍然有效。
第二个思路是,减少 HTTP 请求的次数,有以下的方法:<br><ul><li>将原本由客户端处理的重定向请求,交给代理服务器处理,这样可以减少重定向请求的次数;</li><li>将多个小资源合并成一个大资源再传输,能够减少 HTTP 请求次数以及 头部的重复传输,<br>再来减少 TCP 连接数量,进而省去 TCP 握手和慢启动的网络消耗;</li><li>按需访问资源,只访问当前用户看得到/用得到的资源,当客户往下滑动,再访问接下来的资源,<br>以此达到延迟请求,也就减少了同一时间的 HTTP 请求次数。</li></ul>
第三思路是,通过压缩响应资源,降低传输资源的大小,从而提高传输效率,所以应当选择更优秀的压缩算法。
不管怎么优化 HTTP/1.1 协议都是有限的,不然也不会出现 HTTP/2 和 HTTP/3 协议
HTTPS RSA 握手解析
TLS 握手过程
HTTP 由于是明文传输,所谓的明文,就是说客户端与服务端通信的信息都是肉眼可见的,<br>随意使用一个抓包工具都可以截获通信的内容。
所以安全上存在以下三个风险:<br><ul><li><b>窃听风险</b>,比如通信链路上可以获取通信内容,用户号容易没。</li><li><b>篡改风险</b>,比如强制植入垃圾广告,视觉污染,用户眼容易瞎。</li><li><b>冒充风险</b>,比如冒充淘宝网站,用户钱容易没。</li></ul>
HTTPS 在 HTTP 与 TCP 层之间加入了 TLS 协议,来解决上述的风险。
TLS 协议是如何解决 HTTP 的风险的呢?<br><ul><li><b>信息加密</b>: HTTP 交互信息是被加密的,第三方就无法被窃取;</li><li><b>校验机制</b>:校验信息传输过程中是否有被第三方篡改过,如果被篡改过,则会有警告提示;</li><li><b>身份证书</b>:证明淘宝是真的淘宝网;</li></ul>
可见,有了 TLS 协议,能保证 HTTP 通信是安全的了,那么在进行 HTTP 通信前,<br>需要先进行 TLS 握手。TLS 的握手过程,如下图:
上图简要概述了 TLS 的握手过程,其中每一个「框」都是一个 <b>记录(record)</b>,记录是 TLS 收发数据的基本单位,<br>类似于 TCP 里的 segment。多个记录可以组合成一个 TCP 包发送,所以 <b>通常经过「四个消息」就可以完成 TLS 握手,<br>也就是需要 2个 RTT 的时延,</b>然后就可以在安全的通信环境里发送 HTTP 报文,实现 HTTPS 协议。
所以可以发现,<b>HTTPS 是应用层协议,需要先完成 TCP 连接建立</b>,然后走 TLS 握手过程后,才能建立通信安全的连接。
事实上,不同的密钥交换算法,TLS 的握手过程可能会有一些区别。因为考虑到性能的问题,所以双方在加密应用信息时<br>使用的是对称加密密钥,而对称加密密钥是不能被泄漏的,为了保证对称加密密钥的安全性,所以使用非对称加密的方式<br>来保护对称加密密钥的协商,这个工作就是密钥交换算法负责的。
接下来就以最简单的 RSA 密钥交换算法,来看看它的 TLS 握手过程。
RSA 握手过程
整体过程
传统的 TLS 握手基本都是使用 RSA 算法来实现密钥交换的,在将 TLS 证书部署服务端时,证书文件中包含一对公私钥,<br>其中公钥会在 TLS 握手阶段传递给客户端,私钥则一直留在服务端,一定要确保私钥不能被窃取。
在 RSA 密钥协商算法中,客户端会生成随机密钥,并使用服务端的公钥加密后再传给服务端。根据非对称加密算法,<br>公钥加密的消息仅能通过私钥解密,这样服务端解密后,双方就得到了相同的密钥,再用它加密应用消息。
用 Wireshark 工具抓了用 RSA 密钥交换的 TLS 握手过程,可以从下面看到,一共经历来四次握手:
对应 Wireshark 的抓包,可以从下图很清晰地看到该过程:
TLS 第一次握手
客户端首先会发一个「Client Hello」消息,字面意思我们也能理解到,这是跟服务器「打招呼」。
消息里面有客户端使用的 TLS 版本号、支持的密码套件列表,以及生成的 <b>随机数(Client Random)</b>,<br>这个随机数会被服务端保留,它是生成对称加密密钥的材料之一。
TLS 第二次握手
当服务端收到客户端的「Client Hello」消息后,会确认 TLS 版本号是否支持,<br>和从密码套件列表中选择一个密码套件,以及生成 <b>随机数(Server Random)</b>。
接着,返回<b>「Server Hello」</b>消息,消息里面有服务器确认的 TLS 版本号,也给出了<br>随机数(Server Random),然后从客户端的密码套件列表选择了一个合适的密码套件。
这个密码套件看起来真让人头晕,好一大串,但是其实它是有固定格式和规范的。基本的形式是<br><b>「密钥交换算法 + 签名算法 + 对称加密算法 + 摘要算法」</b>, 一般 WITH 单词前面有两个单词,<br>第一个单词是约定密钥交换的算法,第二个单词是约定证书的验证算法。比如上面的密码套件的意思就是:<br><ul><li>由于 WITH 单词只有一个 RSA,则说明握手时密钥交换算法和签名算法都是使用 RSA;</li><li>握手后的通信使用 AES 对称算法,密钥长度 128 位,分组模式是 GCM;</li><li>摘要算法 SHA256 用于消息认证和产生随机数;</li></ul>
就前面这两个客户端和服务端相互「打招呼」的过程,客户端和服务端就已确认了 TLS 版本和使用的密码套件,<br>而且你可能发现客户端和服务端都会各自生成一个随机数,并且还会把随机数传递给对方。
那这个随机数有啥用呢?其实这两个随机数是后续作为生成「会话密钥」的条件,<br>所谓的会话密钥就是数据传输时,所使用的对称加密密钥。
然后,服务端为了证明自己的身份,会发送<b>「Server Certificate」</b>给客户端,这个消息里含有数字证书。
随后,服务端发了「Server Hello Done」消息,目的是告诉客户端,我已经把该给你的东西都给你了,本次打招呼完毕。
客户端验证证书
在这里刹个车,客户端拿到了服务端的数字证书后,要怎么校验该数字证书是真实有效的呢?
数字证书和 CA 机构<br>
在校验数字证书是否可信的过程前,先来看看数字证书是什么,一个数字证书通常包含了:<br><ul><li>公钥;</li><li>持有者信息;</li><li>证书认证机构(CA)的信息;</li><li>CA 对这份文件的数字签名及使用的算法;</li><li>证书有效期;</li><li>还有一些其他额外信息;</li></ul>
那数字证书的作用,是用来认证公钥持有者的身份,以防止第三方进行冒充。说简单些,证书<br>就是用来告诉客户端,该服务端是否是合法的,因为只有证书合法,才代表服务端身份是可信的。
用证书来认证公钥持有者的身份(服务端的身份),那证书又是怎么来的?又该怎么认证证书呢?
为了让服务端的公钥被大家信任,服务端的证书都是由 CA (证书认证机构)签名的,CA 就是网络世界里的公安局、<br>公证中心,具有极高的可信度,所以由它来给各个公钥签名,信任的一方签发的证书,那必然证书也是被信任的。
之所以要签名,是因为签名的作用可以避免中间人在获取证书时对证书内容的篡改。<br>
数字证书签发和验证流程<br>
下图为数字证书签发和验证流程:
CA 签发证书的过程,如上图左边部分:<br><ul><li>首先 CA 会把持有者的公钥、用途、颁发者、有效时间等信息打成一个包,<br>然后对这些信息进行 Hash 计算,得到一个 Hash 值;</li><li>然后 CA 会使用自己的私钥将该 Hash 值加密,生成 Certificate Signature,也就是 CA 对证书做了签名;</li><li>最后将 Certificate Signature 添加在文件证书上,形成数字证书;</li></ul>
客户端校验服务端的数字证书的过程,如上图右边部分:<br><ul><li>首先客户端会使用同样的 Hash 算法获取该证书的 Hash 值 H1;</li><li>通常浏览器和操作系统中集成了 CA 的公钥信息,浏览器收到证书后可以使用 CA 的公钥<br>解密 Certificate Signature 内容,得到一个 Hash 值 H2 ;</li><li>最后比较 H1 和 H2,如果值相同,则为可信赖的证书,否则则认为证书不可信。</li></ul>
证书链<br>
但事实上,证书的验证过程中还存在一个证书信任链的问题,因为我们向 CA 申请的证书一般不是<br>根证书签发的,而是由中间证书签发的,比如百度的证书,从下图你可以看到,证书的层级有三级:
对于这种三级层级关系的证书的验证过程如下(先向上寻到根证书,再逐一向下验证可信性):<br><br><ul><li>客户端收到 baidu.com 的证书后,发现这个证书的签发者不是根证书,就无法根据本地已有的根证书中的<br>公钥去验证 baidu.com 证书是否可信。于是,客户端根据 baidu.com 证书中的签发者,找到该证书的颁发<br>机构是 “GlobalSign Organization Validation CA - SHA256 - G2”,然后向 CA 请求该中间证书。</li><li>请求到证书后发现 “GlobalSign Organization Validation CA - SHA256 - G2” 证书是由 “GlobalSign Root CA” 签发的,<br>由于 “GlobalSign Root CA” 没有再上级签发机构,说明它是根证书,也就是自签证书。应用软件会检查此证书有否已预载于<br>根证书清单上,如果有,则可以利用根证书中的公钥去验证 “GlobalSign Organization Validation CA - SHA256 - G2” 证书,<br>如果发现验证通过,就认为该中间证书是可信的。</li><li><span style="font-size: inherit;">“GlobalSign Organization Validation CA - SHA256 - G2” 证书被信任后,可以使用</span></li></ul><span style="font-size: inherit;"> “GlobalSign Organization Validation CA - SHA256 - G2” 证书中的公钥去验证 baidu.com 证书的可信性,</span><br style="font-size: inherit;"><span style="font-size: inherit;"> 如果验证通过,就可以信任 baidu.com 证书。</span><br style="font-size: inherit;"><br style="font-size: inherit;"><span style="font-size: inherit;">最开始客户端只信任根证书 GlobalSign Root CA 证书的,然后“GlobalSign Root CA”证书信任“GlobalSign Organization Validation CA - SHA256 - G2”证书,而“GlobalSign Organization Validation CA - SHA256 - G2”证书又信任 baidu.com 证书,于是客户端也信任 baidu.com 证书。</span><br>
总括来说,由于用户信任 GlobalSign,所以由 GlobalSign 所担保的 baidu.com 可以被信任,另外<br>由于用户信任操作系统或浏览器的软件商,所以由软件商预载了根证书的 GlobalSign 都可被信任。
这样的一层层地验证就构成了一条信任链路,整个证书信任链验证流程如下图所示:
为什么需要证书链这么麻烦的流程?Root CA 为什么不直接颁发证书,而是要搞那么多中间层级呢?<br><br>这是为了确保根证书的绝对安全性,将根证书隔离地越严格越好,不然根证书如果失守了,那么整个信任链都会有问题。
TLS 第三次握手
客户端验证完证书后,认为可信则继续往下走。接着,客户端就会生成一个新的 <b>随机数 (pre-master)</b>,<br>用服务器的 RSA 公钥加密该随机数,通过「<b>Change Cipher Key Exchange</b>」消息传给服务端。
服务端收到后,用 RSA 私钥解密,得到客户端发来的随机数 (pre-master)。
至此,<b>客户端和服务端双方都共享了三个随机数,分别是 Client Random、Server Random、pre-master。</b>
于是,双方根据已经得到的三个随机数,生成 <b>会话密钥(Master Secret)</b>,它是对称密钥(一个密钥,速度快),<br>用于对后续的 HTTP 请求/响应的数据加解密。<br>
生成完会话密钥后,然后客户端发一个「<b>Change Cipher Spec</b>」,告诉服务端开始使用加密方式发送消息。
然后,客户端再发一个「<b>Encrypted Handshake Message(Finishd)</b>」消息,把之前所有发送的数据做个摘要,<br>再用会话密钥(master secret)加密一下,让服务器验证加密通信是否可用和之前握手信息是否有被中途篡改过。
可以发现,「Change Cipher Spec」之前传输的 TLS 握手数据都是明文,之后都是对称密钥加密的密文。
TLS 第四次握手
服务器也是同样的操作,发「Change Cipher Spec」和「Encrypted Handshake Message」消息,<br>如果双方都验证加密和解密没问题,那么握手正式完成。<br>
最后,就用「会话密钥」加解密 HTTP 请求和响应了。
RSA 算法的缺陷
<b><font color="#0000ff">使用 RSA 密钥协商算法的最大问题是不支持前向保密。</font></b>
因为客户端传递随机数(用于生成对称加密密钥的条件之一)给服务端时使用的是 <b><font color="#0000ff">公钥加密 </font></b>的,服务端收到到后,<br>会用私钥解密得到随机数。所以一旦 <b><font color="#0000ff">服务端的私钥泄漏了,之前被第三方截获的所有 TLS 通讯密文都会被破解</font></b>。
为了解决这个问题,后面就出现了 ECDHE 密钥协商算法,现在大多数网站使用的正是 ECDHE 密钥协商算法<br>
HTTPS ECDHE 握手解析
HTTPS 常用的密钥交换算法有两种,分别是 RSA 和 ECDHE 算法。
其中,RSA 是比较传统的密钥交换算法,它不具备前向安全(当前时段服务器的签名私钥泄露后,影响在泄露以前<br>时段的私钥与签名的安全)的性质,因此现在很少服务器使用的。而 <b><font color="#0000ff">ECDHE 算法具有前向安全</font></b>,所以被广泛使用。
离散对数
ECDHE 密钥协商算法是 DH 算法演进过来的,所以要先从 DH 算法说起。
DH 算法是非对称加密算法, 因此它可以用于密钥交换,该算法的核心数学思想是离散对数。
对数
要说起对数,必然要说指数,因为它们是互为反函数,指数就是幂运算,对数是指数的逆运算。
举个栗子,如果以 2 作为底数,那么指数和对数运算公式,如下图所示:
那么对于底数为 2 的时候, 32 的对数是 5,64 的对数是 6,计算过程如下:<br>
离散对数
对数运算的取值是可以连续的,而离散对数的取值是不能连续的,因此也以「离散」得名,
离散对数是在对数运算的基础上加了「模运算」,也就说取余数,对应编程语言的操作符是「%」,<br>也可以用 mod 表示。离散对数的概念如下图:
上图的,底数 a 和模数 p 是离散对数的公共参数,也就说是公开的,b 是真数,i 是对数。<br>知道了对数,就可以用上面的公式计算出真数。但反过来,知道真数却很难推算出对数。
<b>特别是当模数 p 是一个很大的质数,即使知道底数 a 和真数 b ,在现有的计算机的计算水平是<br>几乎无法算出离散对数的,这就是 DH 算法的数学基础。</b>
DH 算法
认识了离散对数,接着来看看 DH 算法是如何密钥交换的。<br>
现假设小红和小明约定使用 DH 算法来交换密钥,那么基于离散对数,小红和小明<br>需要先确定模数和底数作为算法的参数,这两个参数是公开的,用 P 和 G 来代称。
然后小红和小明各自生成一个随机整数作为私钥,双方的私钥要各自严格保管,不能泄漏,小红的私钥用 a 代称,小明的私钥用 b 代称。
现在小红和小明双方都有了 P 和 G 以及各自的私钥,于是就可以计算出 <b>公钥</b>:<br><ul><li>小红的公钥记作 A,A = G ^ a ( mod P );</li><li>小明的公钥记作 B,B = G ^ b ( mod P );</li></ul>
A 和 B 也是公开的,因为根据离散对数的原理,从真数(A 和 B)反向计算对数 a 和 b 是非常困难的,<br>至少在现有计算机的计算能力是无法破解的,如果量子计算机出来了,那就有可能被破解,当然如果<br>量子计算机真的出来了,那么密钥协商算法就要做大的升级了。
双方交换各自 DH 公钥后,小红手上共有 5 个数:P、G、a、A、B,小明手上也同样共有 5 个数:P、G、b、B、A。
然后小红执行运算: B ^ a ( mod P ),其结果为 K,因为离散对数的幂运算有交换律,所以小明执行运算: A ^ b ( mod P ),得到的结果也是 K。
这个 K 就是小红和小明之间用的 <b>对称加密密钥</b>,可以作为会话密钥使用。
可以看到,整个密钥协商过程中,小红和小明公开了 4 个信息:P、G、A、B,其中 P、G 是算法的参数,A 和 B 是公钥,<br>而 a、b 是双方各自保管的私钥,黑客无法获取这 2 个私钥,因此黑客只能从公开的 P、G、A、B 入手,计算出离散对数(私钥)。
前面也多次强调, 根据离散对数的原理,如果 P 是一个大数,在现有的计算机的计算能力是很<br>难破解出私钥 a、b 的,破解不出私钥,也就无法计算出会话密钥,因此 DH 密钥交换是安全的。
DHE 算法
根据私钥生成的方式,DH 算法分为两种实现:<br><ul><li>static DH 算法,这个是已经被废弃了;</li><li>DHE 算法,现在常用的;</li></ul>
static DH 算法里有一方的私钥是静态的,也就说每次密钥协商的时候有一方的私钥都是一样的,<br>一般是服务器方固定,即 a 不变,客户端的私钥则是随机生成的。
于是,DH 交换密钥时就只有客户端的公钥是变化,而服务端公钥是不变的,那么随着时间延长,<br>黑客就会截获海量的密钥协商过程的数据,因为密钥协商的过程有些数据是公开的,黑客就可以<br>依据这些数据暴力破解出服务器的私钥,然后就可以计算出会话密钥了,于是之前截获的加密数据<br>会被破解,所以 <b>static DH 算法不具备前向安全性</b>。
既然固定一方的私钥有被破解的风险,那么干脆就让双方的私钥在每次密钥交换通信时,<br>都是随机生成的、临时的,这个方式也就是 DHE 算法,E 全称是 ephemeral(临时性的)。
所以,即使有个牛逼的黑客破解了某一次通信过程的私钥,其他通信过程的私钥仍然是安全的,<br>因为 <b>每个通信过程的私钥都是没有任何关系的,都是独立的,这样就保证了「前向安全」。</b>
ECDHE 算法
DHE 算法由于计算性能不佳,因为需要做大量的乘法,为了提升 DHE 算法的性能,<br>所以就出现了现在广泛用于密钥交换算法 —— <b>ECDHE 算法</b>。
ECDHE 算法是在 DHE 算法的基础上利用了 ECC 椭圆曲线特性,可以用更少的计算量计算出公钥,以及最终的会话密钥。
小红和小明使用 ECDHE 密钥交换算法的过程:<br><br><ul><li>双方事先确定好使用哪种椭圆曲线,和曲线上的基点 G,这两个参数都是公开的;</li><li>双方各自随机生成一个随机数作为 <b>私钥d</b>,并与基点 G 相乘得到 <b>公钥Q</b>(Q = dG),此时小红的公私钥为 Q1 和 d1,小明的公私钥为 Q2 和 d2;</li><li>双方交换各自的公钥,最后小红计算点(x1,y1) = d1Q2,小明计算点(x2,y2) = d2Q1,由于椭圆曲线上是可以满足乘法交换和结合律,</li></ul> 所以 d1Q2 = d1d2G = d2d1G = d2Q1 ,因此 <b>双方的 x 坐标是一样的,所以它是共享密钥,也就是会话密钥</b>。<br>
这个过程中,双方的私钥都是随机、临时生成的,都是不公开的,即使根据公开的信息(椭圆曲线、公钥、基点 G)<br>也是很难计算出椭圆曲线上的离散对数(私钥)。
ECDHE 握手过程
知道了 ECDHE 算法基本原理后,用 Wireshark 工具抓了用 ECDHE 密钥协商算法的 TSL 握手过程,结合实际情况来看看。<br>可以看到是四次握手结:
可以发现,<b><font color="#0000ff">使用了 ECDHE,在 TLS 第四次握手前,客户端就已经发送了加密的 HTTP 数据</font></b>,<br>而对于 RSA 握手过程,必须要完成 TLS 四次握手,才能传输应用数据。
所以,<b><font color="#0000ff">ECDHE 相比 RSA 握手过程省去了一个消息往返的时间</font></b>,这个有点「抢跑」的意思,它被称为是「TLS False Start」,<br>跟「TCP Fast Open」有点像,都是在还没连接完全建立前,就发送了应用数据,这样便提高了传输的效率。
TLS 第一次握手
客户端首先会发一个「<b>Client Hello</b>」消息,消息里面有客户端使用的 TLS 版本号、<br>支持的密码套件列表,以及生成的 <b>随机数(Client Random)。</b>
TLS 第二次握手
服务端收到客户端的「打招呼」,同样也要回礼,会返回「<b>Server Hello</b>」消息,消息面有服务器确认的 TLS 版本号,<br>也生成了一个 <b>随机数(Server Random)</b>,然后从客户端的密码套件列表选择了一个合适的密码套件。
不过,这次选择的密码套件就和 RSA 不一样了,接下来分析一下这次的密码套件的意思。
密码套件「 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384」:<br><ul><li>密钥协商算法使用 ECDHE;</li><li>签名算法使用 RSA;</li><li>握手后的通信使用 AES 对称算法,密钥长度 256 位,分组模式是 GCM;</li><li>摘要算法使用 SHA384;</li></ul>
接着,服务端为了证明自己的身份,发送「<b>Certificate</b>」消息,会把证书也发给客户端。
这一步就和 RSA 握手过程有很大到区别了,因为服务端选择了 ECDHE 密钥协商算法,<br>所以会在发送完证书后,发送「<b>Server Key Exchange</b>」消息。
这个过程服务器做了三件事:<br><ul><li>选择了 <b>名为 x25519 的椭圆曲线</b>,选好了椭圆曲线相当于椭圆曲线基点 G 也定好了,这些都会公开给客户端;</li><li>生成随机数作为服务端椭圆曲线的私钥,保留到本地;</li><li>根据基点 G 和私钥计算出 <b>服务端的椭圆曲线公钥</b>,这个会公开给客户端。</li></ul>
为了保证这个椭圆曲线的公钥不被第三方篡改,服务端会用 <b>RSA 签名算法 </b>给服务端的椭圆曲线公钥做个签名。
随后,就是「<b>Server Hello Done</b>」消息,服务端跟客户端表明:“这些就是我提供的信息,打招呼完毕”。
至此,TLS 两次握手就已经完成了,目前客户端和服务端通过明文共享了这几个信息:<b>Client Random、Server Random 、<br>使用的椭圆曲线、椭圆曲线基点 G、服务端椭圆曲线的公钥</b>,这几个信息很重要,是后续生成会话密钥的材料。
TLS 第三次握手
客户端收到了服务端的证书后,自然要校验证书是否合法,如果证书合法,那么服务端到身份就是没问题的。校验证书的过程会走证书链逐级验证,<br>确认证书的真实性,再用证书的公钥验证签名,这样就能确认服务端的身份了,确认无误后,就可以继续往下走。
客户端会生成一个随机数作为客户端椭圆曲线的私钥,然后再根据服务端前面给的信息,<br>生成 <b>客户端的椭圆曲线公钥</b>,然后用「<b>Client Key Exchange</b>」消息发给服务端。
至此,双方都有对方的椭圆曲线公钥、自己的椭圆曲线私钥、椭圆曲线基点 G。于是,双方都就计算出点(x,y),<br>其中 x 坐标值双方都是一样的,前面说 ECDHE 算法时候,说 x 是会话密钥,<b>但实际应用中,x 还不是最终的会话密钥。</b>
还记得 TLS 握手阶段(阶段一、二),客户端和服务端都会生成了一个随机数传递给对方吗?<br><br><b><font color="#0000ff">最终的会话密钥,就是用「客户端随机数 + 服务端随机数 + x(ECDHE 算法算出的共享密钥) 」三个材料生成的</font>。</b><br>
之所以这么麻烦,是因为 TLS 设计者不信任客户端或服务器「伪随机数」的可靠性,为了保证真正的完全随机,<br>把三个不可靠的随机数混合起来,那么「随机」的程度就非常高了,足够让黑客计算不出最终的会话密钥,安全性更高。
算好会话密钥后,客户端会发一个「<b>Change Cipher Spec</b>」消息,告诉服务端后续改用 <b>对称算法 </b>加密通信。
接着,客户端会发「<b>Encrypted Handshake Message</b>」消息,把之前发送的数据做一个摘要,<br>再用对称密钥加密一下,让服务端做个验证,验证下本次生成的对称密钥是否可以正常使用。
TLS 第四次握手
最后,服务端也会有一个同样的操作,发「<b>Change Cipher Spec</b>」和「<b>Encrypted Handshake Message</b>」消息,<br>如果双方都验证加密和解密没问题,那么握手正式完成。于是,就可以正常收发加密的 HTTP 请求和响应了。
总结 RSA 和 ECDHE 握手过程的区别
RSA 密钥协商算法「不支持」前向保密,ECDHE 密钥协商算法「支持」前向保密;
使用了 RSA 密钥协商算法,TLS 完成四次握手后,才能进行应用数据传输,<br>而对于 ECDHE 算法,客户端可以不用等服务端的最后一次 TLS 握手,就可以提前发出加密的 HTTP 数据,<br>节省了一个消息的往返时间(这个是 RFC 文档规定的,具体原因文档没有说明);
使用 ECDHE, 在 TLS 第 2 次握手中,会出现服务器端发出的「Server Key Exchange」消息,而 RSA 握手过程没有该消息;
HTTPS 如何优化
为何要优化
由裸数据传输的 HTTP 协议转成加密数据传输的 HTTPS 协议,给应用数据套了个「保护伞」,提高安全性的同时也带来了性能消耗。
因为 HTTPS 相比 HTTP 协议多一个<b> TLS 协议握手</b>过程,<b>目的是为了通过非对称加密握手协商或者交换出对称加密密钥</b>,<br>这个过程最长可以花费掉 2 RTT,接着后续传输的应用数据都得使用对称加密密钥来加密/解密。
为了数据的安全性,我们不得不使用 HTTPS 协议,至今大部分网址都已从 HTTP 迁移至 HTTPS 协议,因此针对 HTTPS 的优化是非常重要的。
分析性能损耗
既然要对 HTTPS 优化,那得清楚哪些步骤会产生性能消耗,再对症下药。<br><br>产生性能消耗的两个环节:<br><ul><li>第一个环节, TLS 协议握手过程;</li><li>第二个环节,握手后的对称加密报文传输。</li></ul>
对于第二环节,现在主流的对称加密算法 AES、ChaCha20 性能都是不错的,而且一些 <br>CPU 厂商还针对它们做了硬件级别的优化,因此这个环节的性能消耗可以说非常地小。
而第一个环节,TLS 协议握手过程不仅增加了网络延时(最长可以花费掉 2 RTT),而且握手过程中的一些步骤也会产生性能损耗,比如:<br><ul><li>对于 ECDHE 密钥协商算法,握手过程中会客户端和服务端都需要临时生成椭圆曲线公私钥;</li><li>客户端验证证书时,会访问 CA 获取 CRL 或者 OCSP,目的是验证服务器的证书是否有被吊销;</li><li>双方计算 Pre-Master,也就是对称加密密钥;</li></ul>
为了更清楚这些步骤在 TLS 协议握手的哪一个阶段,可以看下图:
硬件优化
软件都是跑在物理硬件上,硬件越牛逼,软件跑的也越快,所以如果要优化 HTTPS 优化,最直接的方式就是花钱买性能参数更牛逼的硬件。
但是花钱也要花对方向,<b>HTTPS 协议是计算密集型,而不是 I/O 密集型</b>,所以不能把钱花在网卡、硬盘等地方,应该花在 CPU 上。
一个好的 CPU,可以提高计算性能,因为 HTTPS 连接过程中就有大量需要计算密钥的过程,所以这样可以加速 TLS 握手过程。
另外,如果可以,应该选择可以 <b>支持 AES-NI 特性 </b>的 CPU,因为这种款式的 CPU 能在指令级别优化了 AES 算法,这样便加速了数据的加解密传输过程。
如果是 Linux 系统,可以使用下面这行命令查看 CPU 是否支持 AES-NI 指令集:
如果我们的 CPU 支持 AES-NI 特性,那么对于对称加密的算法应该选择 AES 算法。否则可以选择 <br>ChaCha20 对称加密算法,因为 ChaCha20 算法的运算指令相比 AES 算法会对 CPU 更友好一点。
软件优化
如果公司预算充足对于新的服务器是可以考虑购买更好的 CPU,但是对于已经在使用的服务器,<br>硬件优化的方式可能就不太适合了,于是就要从软件的方向来优化了。
软件的优化方向可以分层两种,一个是 <b>软件升级</b>,一个是 <b>协议优化</b>。
先说第一个软件升级,软件升级就是将正在使用的软件升级到最新版本,因为最新版本不仅提供了最新的特性,也优化了以前软件的问题或性能。比如:<br><ul><li>将 Linux 内核从 2.x 升级到 4.x;</li><li>将 OpenSSL 从 1.0.1 升级到 1.1.1;</li><li>......</li></ul>
看似简单的软件升级,对于有成百上千服务器的公司来说,软件升级也跟硬件升级同样是一个棘手的问题,<br>因为要实行软件升级,会花费时间和人力,同时也存在一定的风险,也可能会影响正常的线上服务。
既然如此,我们把目光放到协议优化,也就是在现有的环节下,通过较小的改动,来进行优化。
协议优化
协议的优化就是对「密钥交换过程」进行优化。
密钥交换算法优化
TLS 1.2 版本如果使用的是 RSA 密钥交换算法,那么需要 4 次握手,也就是要花费 2 RTT,<br>才可以进行应用数据的传输,而且 RSA 密钥交换算法不具备前向安全性。<br><br><b>总之使用 RSA 密钥交换算法的 TLS 握手过程,不仅慢,而且安全性也不高。</b><br>
因此如果可以,尽量选用 <b>ECDHE 密钥交换算法 </b>替换 RSA 算法,因为该算法由于支持「<b>False Start</b>」,它是“抢跑”的意思,客户端可以在 TLS 协议的第 3 次握手后,第 4 次握手前,发送加密的应用数据,以此将 <b>TLS 握手的消息往返由 2 RTT 减少到 1 RTT,而且安全性也高,具备前向安全性。</b>
ECDHE 算法是基于椭圆曲线实现的,不同的椭圆曲线性能也不同,应该尽量选择 <b>x25519 曲线</b>,该曲线是目前最快的椭圆曲线。
比如在 Nginx 上,可以使用 ssl_ecdh_curve 指令配置想使用的椭圆曲线,把优先使用的放在前面:
对于对称加密算法方面,如果对安全性不是特别高的要求,可以选用 <b>AES_128_GCM</b>,它比 AES_256_GCM 快一些,因为密钥的长度短一些。
比如在 Nginx 上,可以使用 ssl_ciphers 指令配置想使用的非对称加密算法和对称加密算法,<br>也就是密钥套件,而且把性能最快最安全的算法放在最前面:
TLS 升级
当然,如果可以,直接把 TLS 1.2 升级成 TLS 1.3,TLS 1.3 大幅度简化了握手的步骤,<b>完成 TLS 握手只要 1 RTT</b>,而且安全性更高。
在 <b>TLS 1.2 的握手中,一般是需要 4 次握手</b>,先要通过 Client Hello (第 1 次握手)和 Server Hello(第 2 次握手) 消息协商出后续<br>使用的加密算法,再互相交换公钥(第 3 和 第 4 次握手),然后计算出最终的会话密钥;<br><br>而 <b>TLS 1.3 把 Hello 和公钥交换这两个消息合并成了一个消息,于是这样就减少到只需 1 RTT 就能完成 TLS 握手。</b><br><br>下图的左边部分就是 TLS 1.2 的握手过程,右边部分就是 TLS 1.3 的握手过程:
怎么合并的呢?具体的做法是,客户端在 Client Hello 消息里带上了支持的椭圆曲线,以及这些椭圆曲线对应的公钥。
服务端收到后,选定一个椭圆曲线等参数,然后返回消息时,带上服务端这边的公钥。经过这 1 个 RTT,<br>双方手上已经有生成会话密钥的材料了,于是客户端计算出会话密钥,就可以进行应用数据的加密传输了。
而且,TLS1.3 对密码套件进行“减肥”了, <b>对于密钥交换算法,废除了不支持前向安全性的 RSA 和 DH 算法,只支持 ECDHE 算法。</b>
对于对称加密和签名算法,只支持目前最安全的几个密码套件,比如 openssl 中仅支持下面 5 种密码套件:<br><ul><li>TLS_AES_256_GCM_SHA384</li><li>TLS_CHACHA20_POLY1305_SHA256</li><li>TLS_AES_128_GCM_SHA256</li><li>TLS_AES_128_CCM_8_SHA256</li><li>TLS_AES_128_CCM_SHA256</li></ul>
之所以 TLS1.3 仅支持这么少的密码套件,是因为 TLS1.2 由于支持各种古老且不安全的密码套件,<br>中间人可以利用 <b>降级攻击</b>,伪造客户端的 Client Hello 消息,替换客户端支持的密码套件为一些<br>不安全的密码套件,使得服务器被迫使用这个密码套件进行 HTTPS 连接,从而破解密文。
证书优化
为了验证的服务器的身份,服务器会在 TLS 握手过程中,把自己的证书发给客户端,以此证明自己身份是可信的。<br><br>对于证书的优化,可以有两个方向:<br><ul><li>一个是 <b>证书传输;</b></li><li>一个是 <b>证书验证;</b></li></ul>
证书传输优化
要让证书更便于传输,那必然是减少证书的大小,这样可以节约带宽,也能减少客户端的运算量。
所以,<b>对于服务器的证书应该选择椭圆曲线(ECDSA)证书,而不是 RSA 证书,因为在相同安全强度下, ECC 密钥长度比 RSA 短的多。</b>
证书验证优化
客户端在验证证书时,是个复杂的过程,会走证书链逐级验证,验证的过程不仅需要「用 CA 公钥解密证书」<br>以及「用签名算法验证证书的完整性」,而且为了知道证书是否被 CA 吊销,客户端有时还会再去访问 CA, <br>下载 CRL 或者 OCSP 数据,以此确认证书的有效性。
这个访问过程是 HTTP 访问,因此又会产生一系列网络通信的开销,如 DNS 查询、建立连接、收发数据等。
CRL
CRL 称为证书吊销列表(Certificate Revocation List),这个列表是由 CA 定期更新,列表内容都是被<br>撤销信任的证书序号,如果服务器的证书在此列表,就认为证书已经失效,不在的话,则认为证书是有效的。
但是 CRL 存在两个问题:<br><ul><li>由于 CRL 列表是由 CA 维护的,定期更新,如果一个证书刚被吊销后,客户端在更新 CRL 之前还是会信任这个证书,<b>实时性较差</b>;</li><li><b>随着吊销证书的增多,列表会越来越大,下载的速度就会越慢</b>,下载完客户端还得遍历这么大的列表,<br>那么就会导致客户端在校验证书这一环节的延时很大,进而拖慢了 HTTPS 连接。</li></ul>
OCSP
因此,现在基本都是使用 OCSP ,名为在线证书状态协议(Online Certificate Status Protocol)<br>来查询证书的有效性,它的工作方式是 <b>向 CA 发送查询请求,让 CA 返回证书的有效状态</b>。
不必像 CRL 方式客户端需要下载大大的列表,还要从列表查询,同时因为可以实时查询每一张证书的有效性,解决了 CRL 的实时性问题。
OCSP 需要向 CA 查询,因此也是要发生网络请求,而且还得看 CA 服务器的“脸色”,如果网络状态不好,<br>或者 CA 服务器繁忙,也会导致客户端在校验证书这一环节的延时变大。
OCSP Stapling
于是为了解决这一个网络开销,就出现了 OCSP Stapling,其原理是:<br>服务器向 CA 周期性地查询证书状态,获得一个带有时间戳和签名的响应结果并缓存它。
当有客户端发起连接请求时,服务器会把这个「响应结果」在 TLS 握手过程中发给客户端。由于有签名的存在,<br>服务器无法篡改,因此客户端就能得知证书是否已被吊销了,这样客户端就不需要再去查询。
会话复用
TLS 握手的目的就是为了协商出会话密钥,也就是对称加密密钥,那我们如果我们把首次 TLS 握手协商的对称加密<br>密钥缓存起来,待下次需要建立 HTTPS 连接时,直接「复用」这个密钥,不就减少 TLS 握手的性能损耗了吗?
这种方式就是 <b>会话复用(TLS session resumption)</b>,会话复用分两种:<br><ul><li>第一种叫 <b>Session ID</b>;</li><li>第二种叫 <b>Session Ticket</b>;</li></ul>
Session ID
Session ID 的工作原理是,<b>客户端和服务器首次 TLS 握手连接后,双方会在内存缓存会话密钥,<br>并用唯一的 Session ID 来标识</b>,Session ID 和会话密钥相当于 key-value 的关系。
当客户端再次连接时,hello 消息里会带上 Session ID,服务器收到后就会从内存找,如果找到就直接用该会话密钥恢复会话状态,<br>跳过其余的过程,只用一个消息往返就可以建立安全通信。当然为了安全性,内存中的会话密钥会定期失效。
但是它有两个缺点:<br><ul><li>服务器必须保持 <b>每一个客户端的会话密钥</b>,随着客户端的增多,<b>服务器的内存压力也会越大</b>。</li><li>现在网站服务一般是由 <b>多台服务器 </b>通过负载均衡提供服务的,<b>客户端再次连接不一定会命中上次访问过的服务器,于是还要走完整的 TLS 握手过程</b>;</li></ul>
Session Ticket
为了解决 Session ID 的问题,就出现了 Session Ticket,<b>服务器不再缓存每个客户端的会话密钥,<br>而是把缓存的工作交给了客户端</b>,类似于 HTTP 的 Cookie。
客户端与服务器首次建立连接时,服务器会加密「会话密钥」作为 Ticket 发给客户端,交给客户端缓存该 Ticket。
客户端再次连接服务器时,客户端会发送 Ticket,服务器解密后就可以获取上一次的会话密钥,<br>然后验证有效期,如果没问题,就可以恢复会话了,开始加密通信。
对于集群服务器的话,<b>要确保每台服务器加密 「会话密钥」的密钥是一致的</b>,这样客户端携带 Ticket 访问任意一台服务器时,都能恢复会话。
Session ID 和 Session Ticket <b>都不具备前向安全性</b>,因为一旦加密「会话密钥」<br>的密钥被破解或者服务器泄漏「会话密钥」,前面劫持的通信密文都会被破解。
同时应对 <b>重放攻击 </b>也很困难,这里简单介绍下重放攻击工作的原理。
假设 Alice 想向 Bob 证明自己的身份。 Bob 要求 Alice 的密码作为身份证明,爱丽丝应尽全力<br>提供(可能是在经过如哈希函数的转换之后)。与此同时,Eve 窃听了对话并保留了密码(或哈希)。<br><br>交换结束后,Eve(冒充 Alice )连接到 Bob。当被要求提供身份证明时,Eve 发送从 <br>Bob 接受的最后一个会话中读取的 Alice 的密码(或哈希),从而授予 Eve 访问权限。
重放攻击的危险之处在于,如果中间人截获了某个客户端的 Session ID 或 Session Ticket 以及 POST 报文,<br>而一般 POST 请求会改变数据库的数据,中间人就可以利用此截获的报文,不断向服务器发送该报文,<br>这样就会导致数据库的数据被中间人改变了,而客户是不知情的。
避免重放攻击的方式就是需要 <b>对会话密钥设定一个合理的过期时间</b>。
Pre-shared Key
前面的 Session ID 和 Session Ticket 方式都需要在 1 RTT 才能恢复会话。
而 TLS1.3 更为牛逼,对于 <b>重连 TLS1.3</b> <b>只需要 0 RTT</b>,原理和 Ticket 类似,只不过在重连时,<br>客户端会把 Ticket 和 HTTP 请求一同发送给服务端,这种方式叫 <b>Pre-shared Key</b>。
同样的,Pre-shared Key 也有重放攻击的危险。
如上图,假设中间人通过某种方式,截获了客户端使用会话重用技术的 POST 请求,通常 POST 请求<br>是会改变数据库的数据,然后中间人就可以把截获的这个报文发送给服务器,服务器收到后,也认为是<br>合法的,于是就恢复会话,致使数据库的数据又被更改,但是此时用户是不知情的。
所以,应对重放攻击可以给会话密钥设定一个合理的过期时间,以及只针对安全的 HTTP 请求如 GET/HEAD 使用会话重用。
总结
对于 <b>硬件优化 </b>的方向,因为 HTTPS 是属于计算密集型,应该选择 <b>计算力更强的 CPU</b>,而且最好选择 <b>支持 AES-NI 特性 </b>的 CPU,<br>这个特性可以在硬件级别优化 AES 对称加密算法,加快应用数据的加解密。
对于 <b>软件优化 </b>的方向,如果可以,把软件升级成较新的版本,比如将 Linux 内核 2.X 升级成 4.X,<br>将 openssl 1.0.1 升级到 1.1.1,因为新版本的软件不仅会提供新的特性,而且还会修复老版本的问题。
对于 <b>协议优化 </b>的方向:<br><ul><li>密钥交换算法应该选择 <b>ECDHE 算法</b>,而不用 RSA 算法,因为 ECDHE 算法具备前向安全性,<br>而且客户端可以在第三次握手之后,就发送加密应用数据,节省了 1 RTT。</li><li>将 TLS1.2 升级 <b>TLS1.3</b>,因为 TLS1.3 的握手过程只需要 1 RTT,而且安全性更强。</li></ul>
对于 <b>证书优化 </b>的方向:<br><ul><li>服务器应该选用 <b>ECDSA 证书</b>,而非 RSA 证书,因为在相同安全级别下,ECC 的密钥长度比 RSA 短很多,<br>这样可以提高证书传输的效率;</li><li>服务器应该开启 <b>OCSP Stapling 功能</b>,由服务器预先获得 OCSP 的响应,并把响应结果缓存起来,<br>这样 TLS 握手的时候就不用再访问 CA 服务器,减少了网络通信的开销,提高了证书验证的效率;</li></ul>
对于 <b>重连 HTTPS</b> 时,我们可以使用一些技术让客户端和服务端使用上一次 HTTPS 连接使用的会话密钥,<br>直接恢复会话,而不用再重新走完整的 TLS 握手过程。
常见的 <b>会话重用 </b>技术有 Session ID 和 Session Ticket,用了会话重用技术,当再次重连 HTTPS 时,<br>只需要 1 RTT 就可以恢复会话。对于 TLS1.3 使用 Pre-shared Key 会话重用技术,只需要 0 RTT 就可以恢复会话。
这些会话重用技术虽然好用,但是存在一定的安全风险,它们不仅不具备前向安全,<br>而且有重放攻击的风险,所以应当对 <b>会话密钥设定一个合理的过期时间</b>。<br>
HTTP 2 牛逼在哪
HTTP 1.1 协议的性能问题
我们得先要了解下 HTTP/1.1 协议存在的性能问题,因为 HTTP/2 协议就是把这些性能问题逐个攻破了。
现在的站点相比以前变化太多了,比如:<br><ul><li><b>消息的大小变大了</b>,从几 KB 大小的消息,到几 MB 大小的消息;</li><li><b>页面资源变多了</b>,从每个页面不到 10 个的资源,到每页超 100 多个资源;</li><li><b>内容形式变多样了</b>,从单纯到文本内容,到图片、视频、音频等内容;</li><li><b>实时性要求变高了</b>,对页面的实时性要求的应用越来越多;</li></ul>
这些变化带来的最大性能问题就是 <b>HTTP/1.1 的高延迟</b>,延迟高必然影响的就是用户体验。主要原因如下几个:<br><ul><li><b>延迟难以下降</b>,虽然现在网络的「带宽」相比以前变多了,但是延迟降到一定幅度后,就很难再下降了,说白了就是到达了延迟的下限;</li><li><b>并发连接有限</b>,谷歌浏览器最大并发连接数是 6 个,而且每一个连接都要经过 TCP 和 TLS 握手耗时,以及 TCP 慢启动过程给流量带来的影响;</li><li><b>队头阻塞问题</b>,同一连接只能在完成一个 HTTP 事务(请求和响应)后,才能处理下一个事务;</li><li><b>HTTP 头部巨大且重复</b>,由于 HTTP 协议是无状态的,每一个请求都得携带 HTTP 头部,<br>特别是对于有携带 cookie 的头部,而 cookie 的大小通常很大;</li><li><b>不支持服务器推送消息</b>,因此当客户端需要获取通知时,只能通过定时器不断地拉取消息,这无疑浪费大量了带宽和服务器资源。</li></ul>
为了解决 HTTP/1.1 性能问题,具体的优化手段看上面「HTTP 1.1 如何优化」,这里举例几个常见的优化手段:<br><ul><li>将多张小图合并成一张大图供浏览器 JavaScript 来切割使用,这样可以将多个请求合并成一个请求,<br>但是带来了新的问题,当某张小图片更新了,那么需要重新请求大图片,浪费了大量的网络带宽;</li><li>将图片的二进制数据通过 base64 编码后,把编码数据嵌入到 HTML 或 CSS 文件中,以此来减少网络请求次数;</li><li>将多个体积较小的 JavaScript 文件使用 webpack 等工具打包成一个体积更大的 JavaScript 文件,<br>以一个请求替代了很多个请求,但是带来的问题,当某个 js 文件变化了,需要重新请求同一个包里的所有 js 文件;</li><li>将同一个页面的资源分散到不同域名,提升并发连接上限,因为浏览器通常对同一域名的 HTTP 连接最大只能是 6 个;</li></ul>
尽管对 HTTP/1.1 协议的优化手段如此之多,但是效果还是不尽人意,因为这些手段都是对 HTTP/1.1 协议的“外部”做优化,<b>而一些关键的地方是没办法优化的,比如请求-响应模型、头部巨大且重复、并发连接耗时、服务器不能主动推送等,要改变这些必须重新设计 HTTP 协议,于是 HTTP/2 就出来了!</b>
兼容 HTTP 1.1
HTTP/2 出来的目的是为了改善 HTTP 的性能。协议升级有一个很重要的地方,就是要兼容老版本的协议,<br>否则新协议推广起来就相当困难,所幸 HTTP/2 做到了兼容 HTTP/1.1 。
那么,HTTP/2 是怎么做的呢?
第一点,HTTP/2 没有在 URI 里引入新的协议名,仍然用「http://」表示明文协议,用「https://」表示加密协议,<br>于是只需要浏览器和服务器在背后自动升级协议,这样可以让用户意识不到协议的升级,很好的实现了协议的平滑升级。
第二点,只在应用层做了改变,还是基于 TCP 协议传输,应用层方面为了保持功能上的兼容,HTTP/2 把 HTTP 分解成了 <br>「语义」和「语法」两个部分,「语义」层不做改动,与 HTTP/1.1 完全一致,比如请求方法、状态码、头字段等规则保留不变。<br><br>但是,HTTP/2 在「语法」层面做了很多改造,基本改变了 HTTP 报文的传输格式。
头部压缩
HTTP 协议的报文是由「Header + Body」构成的,对于 Body 部分,HTTP/1.1 协议可以使用头字段 「Content-Encoding」<br>指定 Body 的压缩方式,比如用 gzip 压缩,这样可以节约带宽,但报文中的另外一部分 Header,是没有针对它的优化手段。
HTTP/1.1 报文中 Header 部分存在的问题:<br><ul><li>含很多固定的字段,比如 Cookie、User Agent、Accept 等,这些字段加起来也高达几百字节甚至上千字节,所以有必要 <b>压缩</b>;</li><li>大量的请求和响应的报文里有很多字段值都是重复的,这样会使得大量带宽被这些冗余的数据占用了,所以有必须要 <b>避免重复性</b>;</li><li>字段是 ASCII 编码的,虽然易于人类观察,但效率低,所以有必要改成 <b>二进制编码</b>;</li></ul>
HTTP/2 对 Header 部分做了大改造,把以上的问题都解决了。
HTTP/2 没使用常见的 gzip 压缩方式来压缩头部,而是开发了 HPACK 算法,HPACK 算法主要包含三个组成部分:<br><ul><li>静态字典;</li><li>动态字典;</li><li>Huffman 编码(压缩算法);</li></ul>
客户端和服务器两端都会建立和维护「<b>字典</b>」,用长度较小的索引号表示重复的字符串,<br>再用 Huffman 编码压缩数据,<b>可达到 50%~90% 的高压缩率</b>。
静态表编码
HTTP/2 为 <b>高频出现在头部的字符串和字段 </b>建立了一张 <b>静态表</b>,它是写入到 HTTP/2 框架里的,不会变化的,静态表里共有 61 组,如下图:
表中的 Index 表示索引(Key),Header Value 表示索引对应的 Value,Header Name 表示字段的名字,<br>比如 Index 为 2 代表 GET,Index 为 8 代表状态码 200。
表中有的 Index 没有对应的 Header Value,这是因为这些 Value 并不是固定的而是变化的,<br>这些 Value 都会经过 Huffman 编码后,才会发送出去。
这么说有点抽象,我们来看个具体的例子,下面这个 server 头部字段,在 HTTP/1.1 的形式如下:<br><ul><li><u style=""><b style=""><font color="#212121">server: nghttpx\r\n</font></b></u></li></ul>
算上冒号空格和末尾的 \r\n,共占用了 17 字节,而使用了静态表和 Huffman 编码,可以将它压缩成 8 字节,压缩率大概 47 %。
抓个 HTTP/2 协议的网络包,可以从下图看到,高亮部分就是 server 头部字段,只用了 8 个字节来表示 server 头部数据。
根据 RFC7541 规范,如果头部字段属于静态表范围,并且 Value 是变化,<br>那么它的 HTTP/2 头部前 2 位固定为 01,所以整个头部格式如下图:
HTTP/2 头部由于基于 <b>二进制编码</b>,就不需要冒号空格和末尾的 \r\n 作为分隔符,<br>于是改用表示字符串长度(Value Length)来分割 Index 和 Value。
接下来,根据这个头部格式来分析上面抓包的 server 头部的二进制数据。
首先,从静态表中能查到 server 头部字段的 Index 为 54,二进制为 110110,再加上固定 01,<br>头部格式第 1 个字节就是 01110110,这正是上面抓包标注的红色部分的二进制数据。
然后,第二个字节的首个比特位表示 Value 是否经过 Huffman 编码,剩余的 7 位表示 Value 的长度,比如这次例子的第二个<br>字节为 10000110,首位比特位为 1 就代表 Value 字符串是经过 Huffman 编码的,经过 Huffman 编码的 Value 长度为 6。
最后,字符串 nghttpx 经过 Huffman 编码后压缩成了 6 个字节,Huffman 编码<br>的原理是将高频出现的信息用「较短」的编码表示,从而缩减字符串长度。
于是,在统计大量的 HTTP 头部后,HTTP/2 根据出现频率将 ASCII 码编码为了 Huffman 编码表,可以在 RFC7541 文档找到<br>这张静态 Huffman 表,就不把表的全部内容列出来了,只列出字符串 nghttpx 中每个字符对应的 Huffman 编码,如下图:
通过查表后,字符串 nghttpx 的 Huffman 编码在下图看到,共 6 个字节,每一个字符的 Huffman 编码,最后的 7 位是补位的。
最终,server 头部的二进制数据对应的静态头部格式如下:
动态表编码
静态表只包含了 61 种高频出现在头部的字符串,不在静态表范围内的头部字符串<br>就要自行构建 <b>动态表</b>,它的 Index 从 62 起步,会在编码解码的时候随时更新。
比如,第一次发送时头部中的「<b>user-agent</b> 」字段数据有上百个字节,经过 Huffman 编码发送出去后,客户端<br>和服务器双方都会更新自己的动态表,添加一个新的 Index 号 62。<b>那么在下一次发送的时候,就不用重复发这个<br>字段的数据了,只用发 1 个字节的 Index 号就好了,因为双方都可以根据自己的动态表获取到字段的数据。</b>
所以,使得动态表生效有一个前提:<b>必须同一个连接上,重复传输完全相同的 HTTP 头部</b>。如果消息<br>字段在 1 个连接上只发送了 1 次,或者重复传输时,字段总是略有变化,动态表就无法被充分利用了。
因此,随着在同一 HTTP/2 连接上发送的报文越来越多,客户端和服务器双方的「字典」积累的越来越多,<br>理论上最终每个头部字段都会变成 1 个字节的 Index,这样便避免了大量的冗余数据的传输,大大节约了带宽。
理想很美好,现实很骨感。动态表越大,占用的内存也就越大,如果占用了太多内存,是会影响服务器性能的,<br>因此 Web 服务器都会提供类似 <b>http2_max_requests</b> 的配置,用于限制一个连接上能够传输的请求数量,<br>避免动态表无限增大,请求数量到达上限后,就会关闭 HTTP/2 连接来释放内存。
综上,HTTP/2 头部的编码通过「静态表、动态表、Huffman 编码」共同完成的。
二进制帧
HTTP/2 厉害的地方在于将 HTTP/1 的文本格式改成二进制格式传输数据,<br>极大提高了 HTTP 传输效率,而且二进制数据使用位运算能高效解析。
可以从下图看到,HTTP/1.1 的响应 和 HTTP/2 的区别:
HTTP/2 把响应报文划分成了 <b>两类帧(Frame)</b>,图中的 HEADERS(首部)和 DATA(消息负载) 是帧的类型,<br>也就是说一条 HTTP 响应,划分成了两类帧来传输,并且采用二进制来编码。
HTTP/2 <b>二进制帧 </b>的结构如下图:
帧头(Frame Header)很小,只有 9 个字节,帧开头的前 3 个字节表示帧数据(Frame Playload)的 <b>长度</b>。
帧长度后面的一个字节是表示 <b>帧的类型</b>,HTTP/2 总共定义了 10 种类型的帧,一般分为 <b>数据帧 </b>和 <b>控制帧 </b>两类,如下表格:
帧类型后面的一个字节是 <b>标志位</b>,可以保存 8 个标志位,用于携带简单的控制信息,比如:<br><ul><li><b>END_HEADERS</b> 表示头数据结束标志,相当于 HTTP/1 里头后的空行(“\r\n”);</li><li><b>END_STREAM</b> 表示单方向数据发送结束,后续不会再有数据帧。</li><li><b>PRIORITY</b> 表示流的优先级;</li></ul>
帧头的最后 4 个字节是 <b>流标识符</b>(Stream ID),但最高位被保留不用,只有 31 位可以使用,<br>因此流标识符的最大值是 2^31,大约是 21 亿,它的作用是用来标识该 Frame 属于哪个 Stream,<br>接收方可以根据这个信息从乱序的帧里找到相同 Stream ID 的帧,从而有序组装信息。
最后面就是 <b>帧数据 </b>了,它存放的是通过 <b>HPACK 算法 </b>压缩过的 HTTP 头部和包体。
并发传输
知道了 HTTP/2 的帧结构后,再来看看它是如何实现并发传输的。
我们都知道 HTTP/1.1 的实现是基于请求-响应模型的。同一个连接中,HTTP 完成一个事务(请求与响应),<br>才能处理下一个事务,也就是说在发出请求等待响应的过程中,是没办法做其他事情的,如果响应迟迟不来,<br>那么后续的请求是无法发送的,也造成了 <b>队头阻塞 </b>的问题。
而 HTTP/2 就很牛逼了,通过 Stream 这个设计,<b>多个 Stream 复用一条 TCP 连接,<br>达到并发的效果</b>,解决了 HTTP/1.1 队头阻塞的问题,提高了 HTTP 传输的吞吐量。
为了理解 HTTP/2 的并发是怎样实现的,先来理解 HTTP/2 中的 Stream、Message、Frame 这 3 个概念。
可以从上图中看到:<br><ul><li>1 个 TCP 连接包含一个或者多个 Stream,Stream 是 HTTP/2 并发的关键技术;</li><li>Stream 里可以包含 1 个或多个 Message,Message 对应 HTTP/1 中的请求或响应,由 HTTP 头部和包体构成;</li><li>Message 里包含一条或者多个 Frame,Frame 是 HTTP/2 最小单位,以二进制压缩格式存放 HTTP/1 中的内容(头部和包体);</li></ul>
可以得出 2 个结论:HTTP 消息可以由多个 Frame 构成,以及 1 个 Frame 可以由多个 TCP 报文构成。
在 HTTP/2 连接上,<b>不同 Stream 的帧是可以乱序发送的(因此可以并发不同的 Stream )</b>,因为每个帧的头部会携带 <br>Stream ID 信息,所以接收端可以通过 Stream ID 有序组装成 HTTP 消息,而 <b>同一 Stream 内部的帧必须是严格有序的</b>。
客户端和服务器 <b>双方都可以建立 Stream</b>, Stream ID 也是有区别的,客户端<br>建立的 Stream 必须是奇数号,而服务器建立的 Stream 必须是偶数号。
同一个连接中的 Stream ID 是不能复用的,只能顺序递增,所以当 Stream ID 耗尽时,<br>需要发一个控制帧 GOAWAY,用来关闭 TCP 连接。
在 Nginx 中,可以通过 http2_max_concurrent_streams 配置来设置 Stream 的上限,默认是 128 个。
HTTP/2 通过 Stream 实现的并发,比 HTTP/1.1 通过 TCP 连接实现并发要牛逼的多,<b>因为当 <br>HTTP/2 实现 100 个并发 Stream 时,只需要建立一次 TCP 连接,而 HTTP/1.1 需要建立 100 <br>个 TCP 连接,每个 TCP 连接都要经过TCP 握手、慢启动以及 TLS 握手过程,这些都是很耗时的。</b>
HTTP/2 还可以对每个 Stream 设置不同 <b>优先级</b>,帧头中的「标志位」可以设置优先级,比如客户端访问 HTML/CSS 和<br>图片资源时,希望服务器先传递 HTML/CSS,再传图片,那么就可以通过设置 Stream 的优先级来实现,以此提高用户体验。
服务器主动推送资源
HTTP/1.1 不支持服务器主动推送资源给客户端,都是由客户端向服务器发起请求后,才能获取到服务器响应的资源。
比如,客户端通过 HTTP/1.1 请求从服务器那获取到了 HTML 文件,而 HTML 可能还需要依赖 CSS 来渲染页面,<br>这时客户端还要再发起获取 CSS 文件的请求,需要两次消息往返,如下图左边部分。<br><br>在 HTTP/2 中,客户端在访问 HTML 时,服务器可以直接主动推送 CSS 文件,减少了消息传递的次数,如下图左边部分。<br>
在 Nginx 中,如果你希望客户端访问 /test.html 时,服务器直接推送 /test.css,那么可以这么配置:
那 HTTP/2 的推送是怎么实现的?
客户端发起的请求,必须使用的是奇数号 Stream,服务器主动的推送,使用的是偶数号 Stream。<br>服务器在推送资源时,会通过 PUSH_PROMISE 帧传输 HTTP 头部,并通过帧中的 <br>Promised Stream ID 字段告知客户端,接下来会在哪个偶数号 Stream 中发送包体。
如上图,在 Stream 1 中通知客户端 CSS 资源即将到来,然后在 Stream 2 中发送 CSS 资源,注意 Stream 1 和 2 是可以 <b>并发 </b>的。
总结
HTTP/2 协议其实还有很多内容,比如流控制、流状态、依赖关系等等。
本文主要介绍了关于 HTTP/2 是如何提升性能的几个方向,<br>它相比 HTTP/1 大大提高了传输效率、吞吐能力。
第一点,对于常见的 HTTP 头部通过 <b>静态表 </b>和 <b>Huffman 编码 </b>的方式,将体积压缩了近一半,而且针对<br>后续的请求头部,还可以建立 <b>动态表</b>,将体积压缩近 90%,大大提高了编码效率,同时节约了带宽资源。<br><br>不过,动态表并非可以无限增大, 因为动态表是会占用内存的,动态表越大,内存也越大,<br>容易影响服务器总体的并发能力,因此服务器需要限制 HTTP/2 连接时长或者请求次数。<br>
第二点,HTTP/2 实现了 <b>Stream 并发</b>,多个 Stream 只需复用 1 个 TCP 连接,节约了 TCP 和 TLS 握手时间,以及减少了 TCP <br>慢启动阶段对流量的影响。不同的 Stream ID 才可以并发,即时乱序发送帧也没问题,但是同一个 Stream 里的帧必须严格有序。<br><br>另外,可以根据资源的渲染顺序来设置 <b>Stream 的优先级</b>,从而提高用户体验。<br>
第三点,<b>服务器支持主动推送资源</b>,大大提升了消息的传输性能,服务器推送资源时,会先发送 PUSH_PROMISE 帧,<br>告诉客户端接下来在哪个 Stream 发送资源,然后用偶数号 Stream 发送资源给客户端。
HTTP/2 通过 Stream 的并发能力,解决了 HTTP/1 队头阻塞的问题,看似很完美了,但是 HTTP/2 <br>还是存在“队头阻塞”的问题,只不过问题不是在 HTTP 这一层面,而是在 TCP 这一层。
<b>HTTP/2 是基于 TCP 协议来传输数据的,TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且连续的,<br>这样内核才会将缓冲区里的数据返回给 HTTP 应用,那么当「前 1 个字节数据」没有到达时,后收到的字节数据<br>只能存放在内核缓冲区里,只有等到这 1 个字节数据到达时,HTTP/2 应用层才能从内核中拿到数据,这就是 HTTP/2 队头阻塞问题。</b>
有没有什么解决方案呢?既然是 TCP 协议自身的问题,那干脆 <b>放弃 TCP 协议,<br>转而使用 UDP 协议 </b>作为传输层协议,这个大胆的决定, HTTP/3 协议做了!
HTTP 3 强势来袭
HTTP/3 现在还没正式推出,不过自 2017 年起, HTTP/3 已经更新到 34 <br>个草案了,基本的特性已经确定下来了,对于包格式可能后续会有变化。<br><br>所以,这次 HTTP/3 介绍不会涉及到包格式,只说它的特性。
美中不足的 HTTP 2
HTTP/2 通过头部压缩、二进制编码、多路复用、服务器推送等新特性大幅度提升了 HTTP/1.1 的性能,<br>而美中不足的是 HTTP/2 协议是基于 TCP 实现的,于是存在的缺陷有三个。<br><ul><li>队头阻塞;</li><li>TCP 与 TLS 的握手时延迟;</li><li>网络迁移需要重新连接;</li></ul>
队头阻塞<br>
HTTP/2 多个请求是跑在一个 TCP 连接中的,那么当 TCP 丢包时,<br>整个 TCP 都要等待重传,那么就会阻塞该 TCP 连接中的所有请求。
因为 TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且有序的,如果序列号较低的 TCP 段在网络传输中丢失了,<br>即使序列号较高的 TCP 段已经被接收了,应用层也无法从内核中读取到这部分数据,从 HTTP 视角看,就是请求被阻塞了。
举个例子,如下图:图中发送方发送了很多个 packet,每个 packet 都有自己的序号,你可以认为是 TCP 的序列号,<br>其中 packet 3 在网络中丢失了,即使 packet 4-6 被接收方收到后,由于内核中的 TCP 数据不是连续的,于是接收方<br>的应用层就无法从内核中读取到,只有等到 packet 3 重传后,接收方的应用层才可以从内核中读取到数据,这就是 <br>HTTP/2 的队头阻塞问题,是在 TCP 层面发生的。
TCP 与 TLS 的握手时延迟<br>
发起 HTTP 请求时,需要经过 TCP 三次握手和 TLS 四次握手(TLS 1.2)<br>的过程,因此共需要 3 个 RTT 的时延才能发出请求数据。
另外, TCP 由于具有「<b>拥塞控制</b>」的特性,所以刚建立连接的 TCP <br>会有个「<b>慢启动</b>」的过程,它会对 TCP 连接产生"减速"效果。
网络迁移需要重新连接<br>
一个 TCP 连接是由四元组(源 IP 地址,源端口,目标 IP 地址,目标端口)确定的,这意味着如果 IP 地址或者端口变动了,<br>就会导致需要 TCP 与 TLS 重新握手,这不利于移动设备切换网络的场景,比如 4G 网络环境切换成 WIFI。
这些问题都是 TCP 协议固有的问题,无论应用层的 HTTP/2 在怎么设计都无法逃脱。要解决这个问题,<br>就必须把 <b>传输层协议替换成 UDP</b>,这个大胆的决定,HTTP/3 做了!
QUIC 协议的特点
我们深知,UDP 是一个简单、不可靠的传输协议,而且 UDP 包之间是无序的,也没有依赖关系。<br>而且,UDP 是不需要连接的,也就不需要握手和挥手的过程,所以天然的就比 TCP 快。<br>
当然,HTTP/3 不仅仅只是简单将传输协议替换成了 UDP,还基于 UDP 协议在「应用层」实现了 <b>QUIC 协议</b>,它具有类似 TCP 的<br>连接管理、拥塞窗口、流量控制的网络特性,相当于将不可靠传输的 UDP 协议变成“<b>可靠</b>”的了,所以不用担心数据包丢失的问题。
QUIC 协议的优点有很多,这里举例几个,比如:<br><ul><li>无队头阻塞;</li><li>更快的连接建立;</li><li>连接迁移;</li></ul>
无队头阻塞<br>
QUIC 协议也有类似 HTTP/2 Stream 与多路复用的概念,也是可以在同一条<br>连接上并发传输多个 Stream,Stream 可以认为就是一条 HTTP 请求。
由于 QUIC 使用的传输协议是 UDP,UDP 不关心数据包的顺序,如果数据包丢失,UDP 也不关心。
不过 QUIC 协议会保证数据包的可靠性,每个数据包都有一个序号唯一标识。当某个流中的一个数据包丢失了,<br>即使该流的其他数据包到达了,数据也无法被 HTTP/3 读取,直到 QUIC 重传丢失的报文,数据才会交给 HTTP/3。
而其他 Stream 流的数据报文只要被完整接收,HTTP/3 就可以读取到数据。这与 HTTP/2 不同,<br>HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。
所以,<b>QUIC 连接上的多个 Stream 之间并没有依赖,都是独立的</b>,某个流发生丢包了,只会影响该流,其他流不受影响。
更快的连接建立
对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、openssl <br>库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手,再 TLS 握手。
HTTP/3 在传输数据前虽然需要 QUIC 协议握手,这个握手过程只需要 1 RTT,<br>握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是 <b>QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,<br>再加上 QUIC 使用的是 TLS1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,甚至在第二次连接的<br>时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。</b>
如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT:
连接迁移
在前面我们提到,基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)<br>确定一条 TCP 连接,那么当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开<br>连接,然后重新建立连接,而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的<br>减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。
而 QUIC 协议没有用四元组的方式来“绑定”连接,而是通过 <b>连接 ID </b>来标记通信的两个端点,客户端和服务器可以各自选择<br>一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),<br>就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了 <b>连接迁移 </b>的功能。
HTTP 3 协议
了解完 QUIC 协议的特点后,我们再来看看 HTTP/3 协议在 HTTP 这一层做了什么变化。
HTTP/3 同 HTTP/2 一样采用二进制帧的结构,不同的地方在于 HTTP/2 的二进制帧里需要定义 Stream,<br>而 HTTP/3 自身不需要再定义 Stream,直接使用 QUIC 里的 Stream,于是 HTTP/3 的帧的结构也变简单了。
从上图可以看到,HTTP/3 帧头只有两个字段:类型和长度。
根据帧类型的不同,大体上分为数据帧和控制帧两大类,HEADERS 帧(HTTP 头部)和 DATA 帧(HTTP 包体)属于数据帧。
HTTP/3 在 <b>头部压缩算法 </b>这一方面也做了升级,升级成了 <b>QPACK</b>。与 HTTP/2 <br>中的 HPACK 编码方式相似,QPACK 也采用了静态表、动态表及 Huffman 编码。
对于静态表的变化,HTTP/2 中的 HPACK 的静态表只有 61 项,而 HTTP/3 中的 QPACK 的静态表扩大到 91 项。
HTTP/2 和 HTTP/3 的 Huffman 编码并没有多大不同,但是动态表编解码方式不同。<br>
所谓的动态表,在首次请求-响应后,双方会将未包含在静态表中的 Header 项更新各自的动态表,接着后续传输时仅用 1 个数字表示,<br>然后对方可以根据这 1 个数字从动态表查到对应的数据,就不必每次都传输长长的数据,大大提升了编码效率。<br>
可以看到,<b>动态表是具有时序性的,如果首次出现的请求发生了丢包,后续的收到请求,对方就无法解码出 HPACK 头部,<br>因为对方还没建立好动态表,因此后续的请求解码会阻塞到首次请求中丢失的数据包重传过来。</b>
HTTP/3 的 QPACK 解决了这一问题,那它是如何解决的呢?
QUIC 会有两个特殊的单向流,(所谓的单项流只有一端可以发送消息,双向则指<br>两端都可以发送消息,传输 HTTP 消息时用的是双向流),这两个单向流的用法:<br><ul><li>一个叫 QPACK Encoder Stream, 用于将一个字典(key-value)传递给对方,<br>比如面对不属于静态表的 HTTP 请求头部,客户端可以通过这个 Stream 发送字典;</li><li>一个叫 QPACK Decoder Stream,用于响应对方,告诉它刚发的字典<br>已经更新到自己的本地动态表了,后续就可以使用这个字典来编码了。</li></ul>
这两个特殊的单向流是用来 <b>同步双方的动态表</b>,编码方收到解码方更新确认的通知后,才使用动态表编码 HTTP 头部。
总结
HTTP/2 虽然具有多个流并发传输的能力,但是传输层是 TCP 协议,于是存在以下缺陷:<br><ul><li><b>队头阻塞</b>,HTTP/2 多个请求跑在一个 TCP 连接中,如果序列号较低的 TCP 段在网络传输中丢失了,即使序列号较高<br>的 TCP 段已经被接收了,应用层也无法从内核中读取到这部分数据,从 HTTP 视角看,就是多个请求被阻塞了;</li><li><b>TCP 和 TLS 握手时延</b>,TCL 三次握手和 TLS 四次握手,共有 3-RTT 的时延;</li><li><b>连接迁移需要重新连接</b>,移动设备从 4G 网络环境切换到 WIFI 时,由于 TCP 是基于四元组来确认一条 TCP 连接的,那么<br>网络环境变化后,就会导致 IP 地址或端口变化,于是 TCP 只能断开连接,然后再重新建立连接,切换网络环境的成本高;</li></ul>
HTTP/3 就将传输层从 TCP 替换成了 UDP,并在 UDP 协议上开发了 QUIC 协议,来保证数据的可靠传输。
QUIC 协议的特点:<br><ul><li><b>无队头阻塞</b>,QUIC 连接上的多个 Stream 之间并没有依赖,都是独立的,<br>也不会有底层协议限制,某个流发生丢包了,只会影响该流,其他流不受影响;</li><li><b>建立连接速度快</b>,因为 QUIC 内部包含 TLS1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与 TLS 密钥协商,<br>甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。</li><li><b>连接迁移</b>,QUIC 协议没有用四元组的方式来“绑定”连接,而是通过「连接 ID 」来标记通信的两个端点,<br>客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,<br>只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本;</li></ul>
另外 HTTP/3 的 QPACK 通过两个特殊的单向流来同步双方的动态表,解决了 HTTP/2 的 HPACK 队头阻塞问题。
期待,HTTP/3 正式推出的那一天!<br>
既然有 HTTP 协议,为什么还要有 RPC
从 TCP 聊起
作为一个程序员,假设我们需要在A电脑的进程发一段数据到B电脑的进程,我们一般会在代码里使用socket 进行编程。
这时候,我们可选项一般也就 <b>TCP 和 UDP二选一;TCP可靠,UDP不可靠</b>。除非是马总这种神级<br>程序员(早期 QQ 大量使用UDP),否则,只要稍微对可靠性有些要求,一般无脑选 TCP 就对了。
fd = socket(AF_INET,SOCK_STREAM,0);<br>如上,其中SOCK_STREAM,是指使用 <b>字节流 </b>传输数据,说白了就是 <b>TCP协议</b>。
在定义了socket之后,我们就可以愉快的对这个 socket 进行操作,比如用 bind() 绑定 IP 端口,用 connect() 发起建连。
在连接建立之后,我们就可以使用 send() 发送数据,recv() 接收数据。<br><br>光这样一个纯裸的 TCP 连接,就可以做到收发数据了,那是不是就够了?<br>不行,这么用会有问题。
使用纯裸 TCP 会有什么问题
八股文常背,TCP是有三个特点,<b>面向连接、可靠、基于字节流。<br></b>这三个特点真的概括的非常精辟,这个八股文我们没白背。<b><br></b>
每个特点展开都能聊一篇文章,而今天我们需要关注的是基于字节流这一点。
字节流可以理解为一个双向的通道里流淌的数据,这个 <b>数据 </b>其实就是我们常说的二进制数据,简单来说就是<br>一大堆 <b>01 串</b>。纯裸 TCP 收发的这些 01 串之间是 <b>没有任何边界 </b>的,根本不知道到哪个地方才算一条完整消息。
正因为这个 <b>没有任何边界 </b>的特点,所以当我们选择使用TCP发送 <b>"夏洛"和"特烦恼" </b>的时候,<b>接收端收到的就是"夏洛特烦恼"</b>,<br>这时候接收端没发区分你是想要表达 <b>"夏洛"+"特烦恼" </b>还是 <b>"夏洛特"+"烦恼"</b>。
这就是所谓的 <b>粘包问题</b>,在讲 TCP 的时候会聊到。
说这个的目的是为了告诉大家,纯裸TCP是不能直接拿来用的,<br>你需要在这个基础上加入一些 <b>自定义的规则</b>,用于 <b>区分消息边界</b>。<br>
于是我们会把每条要发送的数据都包装一下,比如加入 <b>消息头</b>,<b>消息头里写清楚一个完整的包长度是多少</b>,<br>根据这个长度可以继续接收数据,截取出来后它们就是我们真正要传输的 <b>消息体</b>。
而这里头提到的 <b>消息头</b>,还可以放各种东西,比如消息体是否被压缩过和消息体格式之类的,<br>只要上下游都约定好了,互相都认就可以了,这就是所谓的 <b>协议</b>。
每个使用 TCP 的项目都可能会定义一套类似这样的协议解析标准,<b>他们可能有区别,但原理都类似</b>。<br><br><b>于是基于 TCP,就衍生了非常多的协议,比如 HTTP 和 RPC。</b>
HTTP 和 RPC
我们回过头来看网络的分层图。
<b>TCP 是传输层的协议</b>,而基于 TCP 造出来的 HTTP 和各类 RPC 协议,它们都只是定义了不同消息格式的 <b>应用层协议 </b>而已。
HTTP 协议(Hyper Text Transfer Protocol),又叫做超文本传输协议。我们用的比较多,<br>平时上网在浏览器上敲个网址就能访问网页,这里用到的就是HTTP协议。
而 <b>RPC(Remote Procedure Call)</b>,又叫做 <b>远程过程调用</b>。它本身并不是一个具体的协议,而是一种 <b>调用方式</b>。
举个例子,我们平时调用一个本地方法就像下面这样。<br>res = localFunc(req)<br>
如果现在这不是个本地方法,而是个 <b>远端服务器 </b>暴露出来的一个方法remoteFunc(),<br>如果我们还能像调用本地方法那样去调用它,这样就可以 <b>屏蔽掉一些网络细节</b>,用起来更方便,岂不美哉?<br>res = remoteFunc(req)<br>
基于这个思路,大佬们造出了非常多款式的 RPC 协议,比如比较有名的 gRPC,thrift。
值得注意的是,虽然大部分 RPC 协议底层使用 TCP,但实际上它们不一定非得使用 TCP,<br>改用 UDP 或者 HTTP,其实也可以做到类似的功能。
到这里,我们回到文章标题的问题。既然有 HTTP 协议,为什么还要有 RPC?
其实,TCP 是 70 年代出来的协议,而 HTTP 是 90 年代才开始流行的。而直接使用裸 TCP 会有问题,<br>可想而知,这中间这么多年有多少自定义的协议,而这里面就有 80 年代出来的 RPC。<br><br>所以我们该问的 <b>不是既然有 HTTP 协议为什么要有 RPC,而是为什么有 RPC 还要有 HTTP 协议。</b><br>
那既然有 RPC 了,为什么还要有HTTP呢?
现在电脑上装的各种联网软件,它们都作为 <b>客户端(client)需要跟服务端(server)建立连接收发消息,此时都会用到应用层协议,<br>在这种 client/server (c/s) </b>架构下,它们可以使用自家造的 RPC 协议,因为它只管连自己公司的服务器就行了。
但有个软件不同,<b>浏览器(browser)</b>,不管是 Chrome 还是 Edge,它们不仅要能访问自家公司的 <b>服务器(server)</b>,还需要访问其他公司的网站服务器,因此它们需要有个统一的标准,不然大家没法交流。于是,HTTP 就是那个时代用于统一 <b>browser/server (b/s)</b> 的协议。
也就是说在多年以前,<b>HTTP 主要用于 b/s</b> 架构,而 <b>RPC 更多用于 c/s 架构</b>。但现在其实已经没分那么清了,<b>b/s 和 c/s 在慢慢融合</b>。<br><b>很多软件同时支持多端</b>,既要支持网页版,还要支持手机端和 pc 端,如果通信协议都用 HTTP 的话,那服务器只用同一套就够了。<br>而 <b>RPC</b> 就开始退居幕后,<b>一般用于公司内部集群里,各个微服务之间的通讯</b>。
那这么说的话,<b>都用 HTTP 得了,还用什么 RPC ?</b><br>仿佛又回到了开头的样子,那这就要从它们之间的区别开始说起。<br>
HTTP 和 RPC 有什么区别
服务发现<br>
首先要向某个服务器发起请求,你得先建立连接,而建立连接的前提是,你得<br>知道 <b>IP地址和端口</b>。这个找到服务对应的 IP 端口的过程,其实就是 <b>服务发现</b>。
在 <b>HTTP </b>中,你知道服务的域名,就可以通过 <b>DNS 服务 </b>去解析得到它背后的 IP 地址,默认 80 端口。
而 <b>RPC </b>的话,就有些区别,一般会有专门的 <b>中间服务 </b>去保存服务名和 IP 信息,比如 consul 或者 etcd,甚至是 redis。想要访问某个服务,<br>就去这些中间服务去获得 IP 和端口信息。由于 dns 也是服务发现的一种,所以也有基于 dns 去做服务发现的组件,比如 CoreDNS。
可以看出服务发现这一块,两者是有些区别,但不太能分高低。<br>
底层连接形式<br>
以主流的 <b>HTTP1.1</b> 协议为例,其默认在建立底层 TCP 连接之后会一直 <br><b>保持这个连接(keep alive)</b>,之后的请求和响应都会复用这条连接。
而 <b>RPC </b>协议,也跟 HTTP 类似,也是通过建立 TCP 长链接进行数据交互,但不同的地方在于,RPC 协议一般还会再建个 <b>连接池</b>,<br>在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,<b>用完放回去,下次再复用</b>,可以说非常环保。
<b>由于连接池有利于提升网络请求性能,所以不少编程语言的网络库里都会给 HTTP 加个连接池</b>,比如 Golang 。<br>
可以看出这一块两者也没太大区别,所以也不是关键。
传输的内容
基于 TCP 传输的消息,说到底,无非都是 <b>消息头 header 和消息体 body。</b>
<b>header</b> 是用于标记一些特殊信息,其中最重要的是 <b>消息体长度</b>。
<b>body </b>则是放我们真正需要传输的内容,而这些内容只能是二进制 01 串,毕竟计算机只认识这玩意。所以 TCP 传<br>字符串和数字都问题不大,因为字符串可以转成编码再变成 01 串,而数字本身也能直接转为二进制。但结构体呢,<br>我们得想个办法将它也转为二进制 01 串,这样的方案现在也有很多现成的,比如 <b>json,protobuf</b>。
这个将结构体转为二进制数组的过程就叫 <b>序列化</b>,反过来将二进制数组复原成结构体的过程叫 <b>反序列化</b>。
对于主流的 HTTP1.1,虽然它现在叫 <b>超文本协议</b>,支持音频视频,但 HTTP 设计初是用于做网页 <b>文本 </b>展示的,<br>所以它传的内容以字符串为主。header 和 body 都是如此。在 body 这块,它使用 <b>json</b> 来序列化结构体数据。
我们可以随便截个图直观看下。<br>
可以看到这里面的内容非常多的 <b>冗余</b>,显得非常啰嗦。最明显的,像 header 里的那些信息,其实如果我们 <b>约定好头部的第几位是 content-type</b>,<br>就 <b>不需要每次都真的把 "content-type" 这个字段都传过来</b>,类似的情况其实在 body 的 json 结构里也特别明显。
而 RPC,因为它定制化程度更高,可以采用体积更小的 protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑<br>各种浏览器行为,比如 302 重定向跳转等。<b>因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因。</b>
当然上面说的 HTTP,其实 <b>特指的是现在主流使用的 HTTP 1.1</b>,<b>HTTP 2</b> 在前者的基础上做了很多改进,<br>所以 <b>性能可能比很多 RPC 协议还要好</b>,甚至连 gRPC 底层都直接用的 HTTP2。
那么问题又来了,为什么既然有了 HTTP 2,还要有 RPC 协议?<br><ul><li>这个是由于 HTTP 2 是 2015 年出来的。那时候很多公司内部的 RPC</li></ul> 协议都已经跑了好些年了,基于历史原因,一般也没必要去换了。<br>
总结
纯裸 TCP 是能收发数据,但它是个 <b>无边界 </b>的数据流,上层需要定义 <b>消息格式 </b>用于定义 <b>消息边界</b>。<br>于是就有了各种协议,HTTP和各类RPC协议就是在TCP之上定义的应用层协议。
<b>RPC 本质上不算是协议,而是一种调用方式</b>,而像 gRPC 和 thrift 这样的具体实现,才是协议,它们是实现了 RPC 调用的协议。<br>目的是希望程序员能像调用本地方法那样去调用远端的服务方法。同时 <b>RPC 有很多种实现方式</b>,<b>不一定非得基于 TCP 协议</b>。
从发展历史来说,<b>HTTP 主要用于 b/s 架构,而 RPC 更多用于 c/s 架构。但现在其实已经没分那么清了,b/s 和 c/s在慢慢融合。<br></b>很多软件同时支持多端,所以 <b>对外一般用 HTTP 协议</b>,而 <b>内部集群的微服务之间则采用 RPC 协议 </b>进行通讯。
RPC 其实比 HTTP 出现的要早,且比目前主流的 <b>HTTP1.1 性能要更好</b>,所以大部分公司内部都还在使用 RPC。
<b>HTTP 2</b> 在 HTTP1.1 的基础上做了优化,<b>性能可能比很多 RPC 协议都要好</b>,但由于是这几年才出来的,所以也<b>不太可能取代掉 RPC</b>。
Collect
Get Started
Collect
Get Started
Collect
Get Started
Collect
Get Started
评论
0 条评论
下一页