This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
<audio id="audio" title="14 | 优化TLS/SSL性能该从何下手" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/79/79/797e80fb602ca9d9c4146edef5928279.mp3"></audio>
你好,我是陶辉。
从这一讲开始,我们进入应用层协议的处理。
信息安全在当下越来越重要绝大多数站点访问时都使用https://替代了http://这就是在用TLS/SSL协议下文简称为TLS协议来保障应用层消息的安全。但另一方面你会发现很多图片类门户网站还在使用http://这是因为TLS协议在对信息加解密的同时必然会降低性能和用户体验这些站点在权衡后选择了性能优先。
实际上TLS协议由一系列加密算法及规范组成这些算法的安全性和性能各不相同甚至与你的系统硬件相关。比如当主机的CPU支持AES-NI指令集时选择AES对称加密算法便可以大幅提升性能。然而要想选择合适的算法需要了解算法所用到的一些数学知识而很多同学由于忽视了数学原理便难以正确地配置TLS算法。
同时TLS协议优化时也需要了解网络和软件工程知识比如我们可以在网络的不同位置缓存密钥来优化性能。而且TLS协议还可以优化其他应用层协议的性能比如从HTTP/1升级到HTTP/2协议便可以通过TLS协议减少1个RTT的时间。
优化TLS性能究竟该从何下手呢在我看来主要有两个方向一是对称加密算法的性能优化二是如何高效地协商密钥。下面我们来详细看看优化细节。
## 如何提升对称加密算法的性能?
如果你用Wireshark等工具对HTTPS请求抓包分析会发现在TCP传输层之上的消息全是乱码这是因为TCP之上的TLS层把HTTP请求用对称加密算法重新进行了编码。**当然用Chrome浏览器配合Wireshark可以解密消息帮助你分析TLS协议的细节**(具体操作方法可参考[《Web协议详解与抓包实战》第51课](https://time.geekbang.org/course/detail/175-104932))。
现代对称加密算法的特点是,即使把加密流程向全社会公开,攻击者也从公网上截获到密文,但只要他没有拿到密钥,就无法从密文中反推出原始明文。如何同步密钥我们稍后在谈,先来看如何优化对称加密算法。
目前主流的对称加密算法叫做AESAdvanced Encryption Standard它在性能和安全上表现都很优秀。而且它不只在访问网站时最为常用甚至你日常使用的WINRAR等压缩软件也在使用AES算法见[官方FAQ](https://www.win-rar.com/encryption-faq.html?&amp;L=0))。**因此AES是我们的首选对称加密算法**下面来看看AES算法该如何优化。
**AES只支持3种不同的密钥长度分别是128位、192位和256位它们的安全性依次升高运算时间也更长。**比如当密钥为128比特位时需要经过十轮操作其中每轮要用移位法、替换法、异或操作等对明文做4次变换。而当密钥是192位时则要经过12轮操作密钥为256比特位时则要经过14轮操作如下图所示。
[<img src="https://static001.geekbang.org/resource/image/8a/28/8ae363f2b0b8cb722533b596b9201428.png" alt="" title="AES128的10轮加密流程[br]此图由Ahmed Ghanim Wadday上传于www.researchgate.net">](http://www.researchgate.net)
密钥越长虽然性能略有下降但安全性提升很多。比如早先的DES算法只有56位密钥在1999年便被破解。**在TLS1.2及更早的版本中仍然允许通讯双方使用DES算法这是非常不安全的行为你应该在服务器上限制DES算法套件的使用**Nginx上限制加密套件的方法参见《Nginx 核心知识100讲》[第96课](https://time.geekbang.org/course/detail/138-75878) 和[第131课](https://time.geekbang.org/course/detail/138-79618)。也正因为密钥长度对安全性的巨大影响美国政府才不允许出口256位密钥的AES算法。
只有数百比特的密钥到底该如何对任意长度的明文加密呢主流对称算法会将原始明文分成等长的多组明文再分别用密钥生成密文最后把它们拼接在一起形成最终密文。而AES算法是按照128比特16字节对明文进行分组的最后一组不足128位时会填充0或者随机数。为了防止分组后密文出现明显的规律造成攻击者容易根据概率破解出原文我们就需要对每组的密钥做一些变换**这种分组后变换密钥的算法就叫做分组密码工作模式下文简称为分组模式它是影响AES性能的另一个因素。**
[<img src="https://static001.geekbang.org/resource/image/46/d1/460d594465b9eb9a04426c6ee35da4d1.png" alt="" title="优秀的分组密码工作模式[br]更难以从密文中发现规律图参见wiki">](https://zh.wikipedia.org/wiki/%E5%88%86%E7%BB%84%E5%AF%86%E7%A0%81%E5%B7%A5%E4%BD%9C%E6%A8%A1%E5%BC%8F)
比如CBC分组模式中只有第1组明文加密完成后才能对第2组加密因为第2组加密时会用到第1组生成的密文。因此CBC必然无法并行计算。在材料科学出现瓶颈、单核频率不再提升的当下CPU都在向多核方向发展而CBC分组模式无法使用多核的并行计算能力性能受到很大影响。**所以通常我们应选择可以并行计算的GCM分组模式这也是当下互联网中最常见的AES分组算法。**
由于AES算法中的替换法、行移位等流程对CPU指令并不友好所以Intel在2008年推出了支持[AES-NI指令集](https://zh.wikipedia.org/wiki/AES%E6%8C%87%E4%BB%A4%E9%9B%86)的CPU能够将AES算法的执行速度从每字节消耗28个时钟周期参见[这里](https://www.cryptopp.com/benchmarks-p4.html)降低至3.5个时钟周期(参见[这里](https://groups.google.com/forum/#!msg/cryptopp-users/5x-vu0KwFRk/CO8UIzwgiKYJ)。在Linux上你可以用下面这行命令查看CPU是否支持AES-NI指令集
```
# sort -u /proc/crypto | grep module |grep aes
module : aesni_intel
```
**因此如果CPU支持AES-NI特性那么应选择AES算法否则可以选择[CHACHA20](https://tools.ietf.org/html/rfc7539) 对称加密算法它主要使用ARX操作add-rotate-xorCPU执行起来更快。**
说完对称加密算法的优化,我们再来看加密时的密钥是如何传递的。
## 如何更快地协商出密钥?
无论对称加密算法有多么安全一旦密钥被泄露信息安全就是一纸空谈。所以TLS建立会话的第1个步骤是在握手阶段协商出密钥。
早期解决密钥传递的是[RSA](https://en.wikipedia.org/wiki/RSA_(cryptosystem)) 密钥协商算法。当你部署TLS证书到服务器上时证书文件中包含一对公私钥参见[非对称加密](https://zh.wikipedia.org/wiki/%E5%85%AC%E5%BC%80%E5%AF%86%E9%92%A5%E5%8A%A0%E5%AF%86)其中公钥会在握手阶段传递给客户端。在RSA密钥协商算法中客户端会生成随机密钥事实上是生成密钥的种子参数并使用服务器的公钥加密后再传给服务器。根据非对称加密算法公钥加密的消息仅能通过私钥解密这样服务器解密后双方就得到了相同的密钥再用它加密应用消息。
**RSA密钥协商算法的最大问题是不支持前向保密**[Forward Secrecy](https://zh.wikipedia.org/wiki/%E5%89%8D%E5%90%91%E4%BF%9D%E5%AF%86)一旦服务器的私钥泄露过去被攻击者截获的所有TLS通讯密文都会被破解。解决前向保密的是[DHDiffieHellman密钥协商算法](https://zh.wikipedia.org/wiki/%E8%BF%AA%E8%8F%B2-%E8%B5%AB%E7%88%BE%E6%9B%BC%E5%AF%86%E9%91%B0%E4%BA%A4%E6%8F%9B)。
我们简单看下DH算法的工作流程。通讯双方各自独立生成随机的数字作为私钥而后依据公开的算法计算出各自的公钥并通过未加密的TLS握手发给对方。接着根据对方的公钥和自己的私钥双方各自独立运算后能够获得相同的数字这就可以作为后续对称加密时使用的密钥。**即使攻击者截获到明文传递的公钥查询到公开的DH计算公式后在不知道私钥的情况下也是无法计算出密钥的。**这样DH算法就可以在握手阶段生成随机的新密钥实现前向保密。
<img src="https://static001.geekbang.org/resource/image/9f/1d/9f5ab0e7f64497c825a927782f58f31d.png" alt="">
DH算法的计算速度很慢如上图所示计算公钥以及最终的密钥时需要做大量的乘法运算而且为了保障安全性这些数字的位数都很长。为了提升DH密钥交换算法的性能诞生了当下广为使用的[ECDH密钥交换算法](https://zh.wikipedia.org/wiki/%E6%A9%A2%E5%9C%93%E6%9B%B2%E7%B7%9A%E8%BF%AA%E8%8F%B2-%E8%B5%AB%E7%88%BE%E6%9B%BC%E9%87%91%E9%91%B0%E4%BA%A4%E6%8F%9B)**ECDH在DH算法的基础上利用[ECC椭圆曲线](https://zh.wikipedia.org/wiki/%E6%A4%AD%E5%9C%86%E6%9B%B2%E7%BA%BF)特性,可以用更少的计算量计算出公钥以及最终的密钥。**
依据解析几何,椭圆曲线实际对应一个函数,而不同的曲线便有不同的函数表达式,目前不被任何已知专利覆盖的最快椭圆曲线是[X25519曲线](https://en.wikipedia.org/wiki/Curve25519)它的表达式是y<sup>2</sup> = x<sup>3</sup> + 486662x<sup>2</sup> + x。因此当通讯双方协商使用X25519曲线用于ECDH算法时只需要传递X25519这个字符串即可。在Nginx上你可以使用ssl_ecdh_curve指令配置想使用的曲线
```
ssl_ecdh_curve X25519:secp384r1;
```
选择密钥协商算法是通过ssl_ciphers指令完成的
```
ssl_ciphers 'EECDH+ECDSA+AES128+SHA:RSA+AES128+SHA';
```
可见ssl_ciphers可以同时配置对称加密算法及密钥强度等信息。注意当ssl_prefer_server_ciphers设置为on时ssl_ciphers指定的多个算法是有优先顺序的**我们应当把性能最快且最安全的算法放在最前面。**
提升密钥协商速度的另一个思路是减少密钥协商的次数主要包括以下3种方式。
首先最为简单有效的方式是在一个TLS会话中传输多组请求对于HTTP协议而言就是使用长连接在请求中加入Connection: keep-alive头部便可以做到。
其次客户端与服务器在首次会话结束后缓存下session密钥并用唯一的session ID作为标识。这样下一次握手时客户端只要把session ID传给服务器且服务器在缓存中找到密钥后为了提升安全性缓存会定期失效双方就可以加密通讯了。这种方式的问题在于当N台服务器通过负载均衡提供TLS服务时客户端命中上次访问过的服务器的概率只有1/N所以大概率它们还得再次协商密钥。
session ticket方案可以解决上述问题它把服务器缓存密钥改为由服务器把密钥加密后作为ticket票据发给客户端由客户端缓存密文。其中集群中每台服务器对session加密的密钥必须相同这样客户端携带ticket密文访问任意一台服务器时都能通过解密ticket获取到密钥。
当然使用session缓存或者session ticket既没有前向安全性应对[重放攻击](https://en.wikipedia.org/wiki/Replay_attack)也更加困难。提升TLS握手性能的更好方式是把TLS协议升级到1.3版本。
## 为什么应当尽快升级到TLS1.3
TLS1.3(参见[RFC8446](https://tools.ietf.org/html/rfc8446)对性能的最大提升在于它把TLS握手时间从2个RTT降为1个RTT。
在TLS1.2的握手中先要通过Client Hello和Server Hello消息协商出后续使用的加密算法再互相交换公钥并计算出最终密钥。**TLS1.3中把Hello消息和公钥交换合并为一步这就减少了一半的握手时间**,如下图所示:
[<img src="https://static001.geekbang.org/resource/image/49/20/4924f22447eaf0cc443aac9b2d483020.png" alt="" title="TLS1.3相对TLS1.2减少了1个RTT的握手时间[br]图片来自www.ssl2buy.com">](https://www.ssl2buy.com/wiki/tls-1-3-protocol-released-move-ahead-to-advanced-security-and-privacy)
那TLS1.3握手为什么只需要1个RTT就可以完成呢因为TLS1.3支持的密钥协商算法大幅度减少了这样客户端尽可以把常用DH算法的公钥计算出来并与协商加密算法的HELLO消息一起发送给服务器服务器也作同样处理这样仅用1个RTT就可以协商出密钥。
而且TLS1.3仅支持目前最安全的几个算法比如openssl中仅支持下面5种安全套件
- TLS_AES_256_GCM_SHA384
- TLS_CHACHA20_POLY1305_SHA256
- TLS_AES_128_GCM_SHA256
- TLS_AES_128_CCM_8_SHA256
- TLS_AES_128_CCM_SHA256
相较起来TLS1.2支持各种古老的算法,中间人可以利用[降级攻击](https://en.wikipedia.org/wiki/Downgrade_attack)在握手阶段把加密算法替换为不安全的算法从而轻松地破解密文。如前文提到过的DES算法由于密钥位数只有56位很容易破解。
因此,**无论从性能还是安全角度上你都应该尽快把TLS版本升级到1.3。**你可以用[这个网址](https://www.ssllabs.com/ssltest/index.html)测试当前站点是否支持TLS1.3。
<img src="https://static001.geekbang.org/resource/image/a8/57/a816a361d7f47303cbfeb10035a96d57.png" alt="">
如果不支持,还可以参见[每日一课《TLS1.3原理及在Nginx上的应用》](https://time.geekbang.org/dailylesson/detail/100028440)升级Nginx到TLS1.3版本。
## 小结
这一讲我们介绍了TLS协议的优化方法。
应用消息是通过对称加密算法编码的而目前AES还是最安全的对称加密算法。不同的分组模式也会影响AES算法的性能而GCM模式能够充分利用多核CPU的并行计算能力所以AES_GCM是我们的首选。当你的CPU支持AES-NI指令集时AES算法的执行会非常快否则可以考虑对CPU更友好的CHACHA20算法。
再来看对称加密算法的密钥是如何传递的它决定着TLS系统的安全也对HTTP小对象的传输速度有很大影响。DH密钥协商算法速度并不快因此目前主要使用基于椭圆曲线的ECDH密钥协商算法其中不被任何专利覆盖的X25519椭圆曲线速度最快。为了减少密钥协商次数我们应当尽量通过长连接来复用会话。在TLS1.2及早期版本中session缓存和session ticket也能减少密钥协商时的计算量但它们既没有前向安全性也更难防御重放攻击所以为了进一步提升性能应当尽快升级到TLS1.3。
TLS1.3将握手时间从2个RTT降为1个RTT而且它限制了目前已经不再安全的算法这样中间人就难以用降级攻击来破解密钥。
密码学的演进越来越快,加密与破解总是在道高一尺、魔高一丈的交替循环中发展,当下安全的算法未必在一年后仍然安全。而且,当量子计算机真正诞生后,它强大的并行计算能力可以轻松地暴力破解当下还算安全的算法。然而,这种划时代的新技术出现时总会有一个时间窗口,而在窗口内也会涌现出能够防御住量子破解的新算法。所以,我们应时常关注密码学的进展,更换更安全、性能也更优秀的新算法。
## 思考题
最后留给你一道思考题TLS体系中还有许多性能优化点比如在服务器上部署[OSCP Stapling](https://zh.wikipedia.org/wiki/OCSP%E8%A3%85%E8%AE%A2)用于更快地发现过期证书也可以提升网站的访问性能你还用过哪些方式优化TLS的性能呢欢迎你在留言区与我探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,132 @@
<audio id="audio" title="15 | 如何提升HTTP/1.1性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/09/71766c707c12801cc505d62333674409.mp3"></audio>
你好,我是陶辉。
上一讲介绍了为应用层信息安全保驾护航的TLS/SSL协议这一讲我们来看看最常用的应用层协议HTTP/1.1该如何优化。
由于门槛低、易监控、自表达等特点HTTP/1.1在互联网诞生之初就成为最广泛使用的应用层协议。然而它的性能却很差最为人诟病的是HTTP头部的传输占用了大量带宽。由于HTTP头部使用ASCII编码方式这造成它往往达到几KB而且滥用的Cookie头部进一步增大了体积。与此同时REST架构的无状态特性还要求每个请求都得重传HTTP头部这就使消息的有效信息比重难以提高。
你可能听说过诸如缓存、长连接、图片拼接、资源压缩等优化HTTP协议性能的方式这些优化方案有些从底层的传输层优化入手有些从用户使用浏览器的体验入手有些则从服务器资源的编码入手五花八门导致我们没有系统化地优化思路往往在性能提升上难尽全功。
那么如何全面地提升HTTP/1.1协议的性能呢我认为在不升级协议的情况下有3种优化思路首先是通过缓存避免发送HTTP请求其次如果不得不发起请求那么就得思考如何才能减少请求的个数最后则是减少服务器响应的体积。
接下来我们就沿着这3个思路看看具体的优化方法。
## 通过缓存避免发送HTTP请求
如果不走网络就能获得HTTP响应这样性能肯定最高。HTTP协议设计之初就考虑到了这一点缓存能够让客户端在免于发送HTTP请求的情况下获得服务器的资源。
缓存到底是如何做到的呢其实很简单它从时间维度上做文章把第1份请求及其响应保存在客户端的本地磁盘上其中请求的URL作为关键字部分HTTP头部也会加入关键字例如确定服务器域名的Host头部而响应就是值。这样后续发起相同的请求时就可以先在本地磁盘上通过关键字查找如果找到了就直接将缓存作为服务器响应使用。读取本地磁盘耗时不过几十毫秒这远比慢了上百倍且不稳定的网络请求快得多。
<img src="https://static001.geekbang.org/resource/image/9d/ab/9dea133d832d8b7ab642bb74b48502ab.png" alt="">
你可能会问服务器上的资源更新后客户端并不知道它若再使用过期的缓存就会出错这该如何解决因此服务器会在发送响应时预估一个过期时间并在响应头部中告诉客户端而客户端一旦发现缓存过期则重新发起网络请求。HTTP协议控制缓存过期的头部非常多而且通常这是在服务器端设置的我会在本专栏的第4部分“分布式系统优化”结合服务器操作再来介绍这里暂时略过。
当然过期的缓存也仍然可以提升性能如下图所示当客户端发现缓存过期后会取出缓存的摘要摘要是从第1次请求的响应中拿到的把它放在请求的Etag头部中再发给服务器。而服务器获取到请求后会将本地资源的摘要与请求中的Etag相比较如果不同那么缓存没有价值重新发送最新资源即可如果摘要与Etag相同那么仅返回不含有包体的304 Not Modified响应告知客户端缓存仍然有效即可这就省去传递可能高达千百兆的文件资源。
<img src="https://static001.geekbang.org/resource/image/a3/62/a394b7389d0cdb5f0866223681e19b62.png" alt="">
至于Etag摘要究竟是怎样生成的各类Web服务器的处理方式不尽相同比如Nginx会将文件大小和修改时间拼接为一个字符串作为文件摘要详见[《Nginx核心知识100讲》第97课](https://time.geekbang.org/course/detail/138-76358))。过期缓存在分布式系统中可以有效提升可用性,[第25课] 还会站在反向代理的角度介绍过期缓存的用法。
浏览器上的缓存只能为一个用户使用故称为私有缓存。代理服务器上的缓存可以被许多用户使用所以称为共享缓存。可见共享缓存带来的性能收益被庞大的客户端群体放大了。你可以看到在下面的REST架构图中表示缓存的$符号缓存的英文名称是cache由于它的发音与cash很像所以许多英文文档中用美元符号来表示缓存既存在于User Agent浏览器中也存在于Proxy正向代理服务器和Gateway反向代理上。
<img src="https://static001.geekbang.org/resource/image/b6/ff/b6950aa62483c56438c41bc4b2f43bff.png" alt="" title="图片来自网络">
可见缓存与互联网世界的网络效率密切相关用好缓存是提升HTTP性能最重要的手段。
## 如何降低HTTP请求的次数
如果不得不发起请求就应该尽量减少HTTP请求的次数这可以从减少重定向次数、合并请求、延迟发送请求等3个方面入手。
首先来看看什么是重定向请求一个资源由于迁移、维护等原因从url1移至url2后在客户端访问原先的url1时服务器不能简单粗暴地返回错误而是通过302响应码及Location头部告诉客户端资源已经改到url2了而客户端再用url2发起新的HTTP请求。
可见重定向增加了请求的数量。尤其客户端在公网中时由于公网速度慢、成本高、路径长、不稳定而且为了信息安全性还要用TLS协议加密这些都降低了网络性能。从上面的REST架构图可以看到HTTP请求会经过多个代理服务器如果将重定向工作交由代理服务器完成就能减少网络消耗如下图所示
<img src="https://static001.geekbang.org/resource/image/0d/2a/0d1701737956ce65f3d5ec8fa8009f2a.png" alt="">
更进一步客户端还可以缓存重定向响应。RFC规范定义了5个重定向响应码如下表所示其中客户端接收到301和308后都可以将重定向响应缓存至本地之后客户端会自动用url2替代url1访问网络资源。
<img src="https://static001.geekbang.org/resource/image/85/70/85b55f50434fc1acd2ead603e5c57870.jpg" alt="">
其次我们来看如何合并请求。当多个访问小文件的请求被合并为一个访问大文件的请求时这样虽然传输的总资源体积未变但减少请求就意味着减少了重复发送的HTTP头部同时也减少了TCP连接的数量因而省去了TCP握手和慢启动过程消耗的时间参见第12课。我们具体看几种合并请求的方式。
一些WEB页面往往含有几十、上百个小图片用[CSS Image Sprites技术](https://www.tutorialrepublic.com/css-tutorial/css-sprites.php)可以将它们合成一张大图片而浏览器获得后可以根据CSS数据把它切割还原为多张小图片这可以大幅减少网络消耗。
[<img src="https://static001.geekbang.org/resource/image/e4/ed/e4a760a95542701d99b8a38ccddaebed.png" alt="" title="图片来自[黑染枫林的CSDN博客]">](https://blog.csdn.net/weixin_38055381/article/details/81504716)
类似地在服务器端用webpack等打包工具将Javascript、CSS等资源合并为大文件也能起到同样的效果。
除此以外还可以将多媒体资源用base64编码后以URL的方式嵌入HTML文件中以减少小请求的个数参见[RFC2397](https://tools.ietf.org/html/rfc2397))。
<img src="https://static001.geekbang.org/resource/image/27/fb/27aa119e0104f41a6718987cdf71c8fb.png" alt="">
用Chrome浏览器开发者工具的Network面板可以轻松地判断各站点是否使用了这种技术。关于Network面板的用法我们在此就不赘述了可参考[《Web协议详解与抓包实战》第9课](https://time.geekbang.org/course/detail/175-93594),那里有详细的介绍)。
<img src="https://static001.geekbang.org/resource/image/ab/74/ab75920b0c43e4f7fcbc5933005afb74.png" alt="">
当然这种合并请求的方式也会带来一个新问题当其中一个资源发生变化后客户端必须重新下载完整的大文件这显然会带来额外的网络消耗。在落后的HTTP/1.1协议中合并请求还算一个不错的解决方案在下一讲将介绍的HTTP/2出现后这种技术就没有用武之地了。
最后我们还可以从浏览页面的体验角度上减少HTTP请求的次数。比如有些HTML页面上引用的资源其实在当前页面上用不上而是供后续页面使用的这就可以使用懒加载[lazy loading](https://zh.wikipedia.org/wiki/%E6%83%B0%E6%80%A7%E8%BC%89%E5%85%A5) 技术延迟发起请求。
当不得不发起请求时,还可以从服务器的角度通过减少响应包体的体积来提升性能。
## 如何重新编码以减少响应的大小?
减少资源体积的唯一方式是对资源重新编码压缩,其中又分为[无损压缩](https://zh.wikipedia.org/wiki/%E6%97%A0%E6%8D%9F%E6%95%B0%E6%8D%AE%E5%8E%8B%E7%BC%A9)与[有损压缩](https://zh.wikipedia.org/wiki/%E6%9C%89%E6%8D%9F%E6%95%B0%E6%8D%AE%E5%8E%8B%E7%BC%A9)两种。
先来看无损压缩,这是指压缩后不会损失任何信息,可以完全恢复到压缩前的原样。因此,文本文件、二进制可执行文件都会使用这类压缩方法。
源代码也是文本文件,但它有自身的语法规则,所以可以依据语法先做一轮压缩。比如[jQuery](https://jquery.com/download/) 是用javascript语言编写的库而标准版的jQuery.js为了帮助程序员阅读含有许多空格、回车等符号但机器解释执行时并不需要这些符号。因此根据语法规则将这些多余的符号去除掉就可以将jQuery文件的体积压缩到原先的三分之一。
<img src="https://static001.geekbang.org/resource/image/de/ba/de0fc2a64a100bc83fc48eb2606fedba.png" alt="">
接着可以基于信息熵原理进行通用的无损压缩这需要对原文建立统计模型将出现频率高的数据用较短的二进制比特序列表示而将出现频率低的数据用较长的比特序列表示。我们最常见的Huffman算法就是一种执行速度较快的实践在下一讲的HTTP/2协议中还会用到它。在上图中可以看到最小版的jQuery文件经过Huffman等算法压缩后体积还会再缩小三分之二。
支持无损压缩算法的客户端会在请求中通过Accept-Encoding头部明确地告诉服务器
```
Accept-Encoding: gzip, deflate, br
```
而服务器也会在响应的头部中告诉客户端包体中的资源使用了何种压缩算法Nginx开启资源压缩的方式参见《Nginx核心知识100讲》[第131课](https://time.geekbang.org/course/detail/138-79618)和[第134课](https://time.geekbang.org/course/detail/138-79621)
```
content-encoding: gzip
```
虽然目前gzip使用最为广泛但它的压缩效率以及执行速度其实都很一般Google于2015年推出的[Brotli](https://zh.wikipedia.org/wiki/Brotli) 算法在这两方面表现都更优秀也就是上文中的br其对比数据如下
[<img src="https://static001.geekbang.org/resource/image/62/cb/62e01433ad8ef23ab698e7c47b7cc8cb.png" alt="" title="图片来源:[https://quixdb.github.io/squash-benchmark/]">](https://quixdb.github.io/squash-benchmark/)
再来看有损压缩它通过牺牲质量来提高压缩比主要针对的是图片和音视频。HTTP请求可以通过Accept头部中的q质量因子参见[RFC7231](https://tools.ietf.org/html/rfc7231#section-5.3.2)),告诉服务器期望的资源质量:
```
Accept: audio/*; q=0.2, audio/basic
```
先来看图片的压缩。目前压缩比较高的开源算法是Google在2010年推出的[WebP格式](https://zh.wikipedia.org/wiki/WebP),你可以在[这个页面](https://isparta.github.io/compare-webp/index.html#12345)看到它与png格式图片的对比图。对于大量使用图片的网站使用它代替传统格式会有显著的性能提升。
动态的音视频压缩比要比表态的图片高很多!由于音视频数据有时序关系,且时间连续的帧之间变化很小,因此可以在静态的关键帧之后,使用增量数据表达后续的帧,因此在质量略有损失的情况下,音频体积可以压缩到原先的几十分之一,视频体积则可以压缩到几百分之一,比图片的压缩比要高得多。因此,对音视频做有损压缩,能够大幅提升网络传输的性能。
对响应资源做压缩不只用于HTTP/1.1协议事实上它对任何信息传输场景都有效消耗一些CPU计算力在事前或者事中做压缩通常会给性能带来不错的提升。
## 小结
这一讲我们从三个方面介绍了HTTP/1.1协议的优化策略。
首先客户端缓存响应可以在有效期内避免发起HTTP请求。即使缓存过期后如果服务器端资源未改变仍然可以通过304响应避免发送包体资源。浏览器上的私有缓存、服务器上的共享缓存都对HTTP协议的性能提升有很大意义。
其次是降低请求的数量如将原本由客户端处理的重定向请求移至代理服务器处理可以减少重定向请求的数量。或者从体验角度使用懒加载技术延迟加载部分资源也可以减少请求数量。再比如将多个文件合并后再传输能够少传许多HTTP头部而且减少TCP连接数量后也省去握手和慢启动的消耗。当然合并文件的副作用是小文件的更新会导致整个合并后的大文件重传。
最后可以通过压缩响应来降低传输的字节数选择更优秀的压缩算法能够有效降低传输量比如用Brotli无损压缩算法替换gzip或者用WebP格式替换png等格式图片等。
但其实在HTTP/1.1协议上做优化效果总是有限的下一讲我们还将介绍在URL、头部等高层语法上向前兼容的HTTP/2协议它在性能上有大幅度提升是如gRPC等应用层协议的基础。
## 思考题
除了我今天介绍的方法以外使用KeepAlive长连接替换短连接也能提升性能你还知道有哪些提升HTTP/1.1性能的方法吗?欢迎你在留言区与我一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,133 @@
<audio id="audio" title="16 | HTTP/2是怎样提升性能的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ef/fb/ef86354bab99bd133610c355793d2efb.mp3"></audio>
你好,我是陶辉。
上一讲我们从多个角度优化HTTP/1的性能但获得的收益都较为有限而直接将其升级到兼容HTTP/1的HTTP/2协议性能会获得非常大的提升。
HTTP/2协议既降低了传输时延也提升了并发性已经被主流站点广泛使用。多数HTTP头部都可以被压缩90%以上的体积这节约了带宽也提升了用户体验像Google的高性能协议gRPC也是基于HTTP/2协议实现的。
目前常用的Web中间件都已支持HTTP/2协议然而如果你不清楚它的原理对于Nginx、Tomcat等中间件新增的流、推送、消息优先级等HTTP/2配置项你就不知是否需要调整。
同时许多新协议都会参考HTTP/2优秀的设计如果你不清楚HTTP/2的性能究竟高在哪里也就很难对当下其他应用层协议触类旁通。而且HTTP/2协议也并不是毫无缺点到2020年3月时它的替代协议[HTTP/3](https://zh.wikipedia.org/wiki/HTTP/3) 已经经历了[27个草案](https://tools.ietf.org/html/draft-ietf-quic-http-27)推出在即。HTTP/3的目标是优化传输层协议它会保留HTTP/2协议在应用层上的优秀设计。如果你不懂HTTP/2也就很难学会未来的HTTP/3协议。
所以这一讲我们就将介绍HTTP/2对HTTP/1.1协议都做了哪些改进从消息的编码、传输等角度说清楚性能提升点这样你就能理解支持HTTP/2的中间件为什么会提供那些参数以及如何权衡HTTP/2带来的收益与付出的升级成本。
## 静态表编码能节约多少带宽?
HTTP/1.1协议最为人诟病的是ASCII头部编码效率太低浪费了大量带宽。HTTP/2使用了静态表、动态表两种编码技术合称为HPACK极大地降低了HTTP头部的体积搞清楚编码流程你自然就会清楚服务器提供的http2_max_requests等配置参数的意义。
我们以一个具体的例子来观察编码流程。每一个HTTP/1.1请求都会有Host头部它指示了站点的域名比如
```
Host: test.taohui.tech\r\n
```
算上冒号空格以及结尾的\r\n它占用了24字节。**使用静态表及Huffman编码可以将它压缩为13字节也就是节约了46%的带宽!**这是如何做到的呢?
我用Chrome访问站点test.taohui.tech并用Wireshark工具抓包关于如何用Wireshark抓HTTP/2协议的报文如果你还不太清楚可参见[《Web协议详解与抓包实战》第51课](https://time.geekbang.org/course/detail/175-104932)下图高亮的头部就是第1个请求的Host头部其中每8个蓝色的二进制位是1个字节报文中用了13个字节表示Host头部。
<img src="https://static001.geekbang.org/resource/image/09/1f/097e7f4549eb761c96b61368c416981f.png" alt="">
HTTP/2能够用13个字节编码原先的24个字节是依赖下面这3个技术。
首先基于二进制编码,就不需要冒号、空格和\r\n作为分隔符转而用表示长度的1个字节来分隔即可。比如上图中的01000001就表示Host而10001011及随后的11个字节表示域名。
其次使用静态表来描述Host头部。什么是静态表呢HTTP/2将61个高频出现的头部比如描述浏览器的User-Agent、GET或POST方法、返回的200 SUCCESS响应等分别对应1个数字再构造出1张表并写入HTTP/2客户端与服务器的代码中。由于它不会变化所以也称为静态表。
<img src="https://static001.geekbang.org/resource/image/5c/98/5c180e1119c1c0eb66df03a9c10c5398.png" alt="">
这样收到01000001时根据[RFC7541](https://tools.ietf.org/html/rfc7541) 规范前2位为01时表示这是不包含Value的静态表头部
<img src="https://static001.geekbang.org/resource/image/cd/37/cdf16023ab2c2f4f67f0039b8da47837.png" alt="">
再根据索引000001查到authority头部Host头部在HTTP/2协议中被改名为authority。紧跟的字节表示域名其中首个比特位表示域名是否经过Huffman编码而后7位表示了域名的长度。在本例中10001011表示域名共有11个字节8+2+1=11且使用了Huffman编码。
最后使用静态Huffman编码可以将16个字节的test.taohui.tech压缩为11个字节这是怎么做到的呢根据信息论高频出现的信息用较短的编码表示后可以压缩体积。因此在统计互联网上传输的大量HTTP头部后HTTP/2依据统计频率将ASCII码重新编码为一张表参见[这里](https://tools.ietf.org/html/rfc7541#page-27)。test.taohui.tech域名用到了10个字符我把这10个字符的编码列在下表中。
<img src="https://static001.geekbang.org/resource/image/81/de/81d2301553c825a466b1f709924ba6de.jpg" alt="">
这样接收端在收到下面这串比特位最后3位填1补位通过查表请注意每个字符的颜色与比特位是一一对应的就可以快速解码为
<img src="https://static001.geekbang.org/resource/image/57/50/5707f3690f91fe54045f4d8154fe4e50.jpg" alt="">
由于8位的ASCII码最小压缩为5位所以静态Huffman的最大压缩比只有5/8。关于Huffman编码是如何构造的你可以参见[每日一课《HTTP/2 能带来哪些性能提升?》](https://time.geekbang.org/dailylesson/detail/100028441)。
## 动态表编码能节约多少带宽?
虽然静态表已经将24字节的Host头部压缩到13字节**但动态表可以将它压缩到仅1字节这就能节省96%的带宽!**那动态表是怎么做到的呢?
你可能注意到当下许多页面含有上百个对象而REST架构的无状态特性要求下载每个对象时都得携带完整的HTTP头部。如果HTTP/2能在一个连接上传输所有对象那么只要客户端与服务器按照同样的规则对首次出现的HTTP头部用一个数字标识随后再传输它时只传递数字即可这就可以实现几十倍的压缩率。所有被缓存的头部及其标识数字会构成一张表它与已经传输过的请求有关是动态变化的因此被称为动态表。
静态表有61项所以动态表的索引会从62起步。比如下图中的报文中访问test.taohui.tech的第1个请求有13个头部需要加入动态表。其中Host: test.taohui.tech被分配到的动态表索引是74索引号是倒着分配的
<img src="https://static001.geekbang.org/resource/image/69/e0/692a5fad16d6acc9746e57b69b4f07e0.png" alt="">
这样后续请求使用到Host头部时只需传输1个字节11001010即可。其中首位1表示它在动态表中而后7位1001010值为64+8+2=74指向服务器缓存的动态表第74项
<img src="https://static001.geekbang.org/resource/image/9f/31/9fe864459705513bc361cee5eafd3431.png" alt="">
静态表、Huffman编码、动态表共同完成了HTTP/2头部的编码其中前两者可以将体积压缩近一半而后者可以将反复传输的头部压缩95%以上的体积!
<img src="https://static001.geekbang.org/resource/image/c0/0c/c08db9cb2c55cb05293c273b8812020c.png" alt="">
那么是否要让一条连接传输尽量多的请求呢并不是这样。动态表会占用很多内存影响进程的并发能力所以服务器都会提供类似http2_max_requests这样的配置限制一个连接上能够传输的请求数量通过关闭HTTP/2连接来释放内存。**因此http2_max_requests并不是越大越好通常我们应当根据用户浏览页面时访问的对象数量来设定这个值。**
## 如何并发传输请求?
HTTP/1.1中的KeepAlive长连接虽然可以传输很多请求但它的吞吐量很低因为在发出请求等待响应的那段时间里这个长连接不能做任何事而HTTP/2通过Stream这一设计允许请求并发传输。因此HTTP/1.1时代Chrome通过6个连接访问页面的速度远远比不上HTTP/2单连接的速度具体测试结果你可以参考这个[页面](https://http2.akamai.com/demo)。
为了理解HTTP/2的并发是怎样实现的你需要了解Stream、Message、Frame这3个概念。HTTP请求和响应都被称为Message消息它由HTTP头部和包体构成承载这二者的叫做Frame帧它是HTTP/2中的最小实体。Frame的长度是受限的比如Nginx中默认限制为8Khttp2_chunk_size配置因此我们可以得出2个结论HTTP消息可以由多个Frame构成以及1个Frame可以由多个TCP报文构成TCP MSS通常小于1.5K)。
再来看Stream流它与HTTP/1.1中的TCP连接非常相似当Stream作为短连接时传输完一个请求和响应后就会关闭当它作为长连接存在时多个请求之间必须串行传输。在HTTP/2连接上理论上可以同时运行无数个Stream这就是HTTP/2的多路复用能力它通过Stream实现了请求的并发传输。
[<img src="https://static001.geekbang.org/resource/image/b0/c8/b01f470d5d03082159e62a896b9376c8.png" alt="" title="图片来源https://developers.google.com/web/fundamentals/performance/http2">](https://developers.google.com/web/fundamentals/performance/http2)
虽然RFC规范并没有限制并发Stream的数量但服务器通常都会作出限制比如Nginx就默认限制并发Stream为128个http2_max_concurrent_streams配置以防止并发Stream消耗过多的内存影响了服务器处理其他连接的能力。
HTTP/2的并发性能比HTTP/1.1通过TCP连接实现并发要高。这是因为**当HTTP/2实现100个并发Stream时只经历1次TCP握手、1次TCP慢启动以及1次TLS握手但100个TCP连接会把上述3个过程都放大100倍**
HTTP/2还可以为每个Stream配置1到256的权重权重越高服务器就会为Stream分配更多的内存、流量这样按照资源渲染的优先级为并发Stream设置权重后就可以让用户获得更好的体验。而且Stream间还可以有依赖关系比如若资源A、B依赖资源C那么设置传输A、B的Stream依赖传输C的Stream即可如下图所示
[<img src="https://static001.geekbang.org/resource/image/9c/97/9c068895a9d2dc66810066096172a397.png" alt="" title="图片来源https://developers.google.com/web/fundamentals/performance/http2">](https://developers.google.com/web/fundamentals/performance/http2)
## 服务器如何主动推送资源?
HTTP/1.1不支持服务器主动推送消息因此当客户端需要获取通知时只能通过定时器不断地拉取消息。HTTP/2的消息推送结束了无效率的定时拉取节约了大量带宽和服务器资源。
<img src="https://static001.geekbang.org/resource/image/f0/16/f0dc7a3bfc5709adc434ddafe3649316.png" alt="">
HTTP/2的推送是这么实现的。首先所有客户端发起的请求必须使用单号Stream承载其次所有服务器进行的推送必须使用双号Stream承载最后服务器推送消息时会通过PUSH_PROMISE帧传输HTTP头部并通过Promised Stream ID告知客户端接下来会在哪个双号Stream中发送包体。
<img src="https://static001.geekbang.org/resource/image/a1/62/a1685cc8e24868831f5f2dd961ad3462.png" alt="">
在SDK中调用相应的API即可推送消息而在Web资源服务器中可以通过配置文件做简单的资源推送。比如在Nginx中如果你希望客户端访问/a.js时服务器直接推送/b.js那么可以这么配置
```
location /a.js {
http2_push /b.js;
}
```
服务器同样也会控制并发推送的Stream数量如http2_max_concurrent_pushes配置以减少动态表对内存的占用。
## 小结
这一讲我们介绍了HTTP/2的高性能是如何实现的。
静态表和Huffman编码可以将HTTP头部压缩近一半的体积但这只是连接上第1个请求的压缩比。后续请求头部通过动态表可以压缩90%以上这大大提升了编码效率。当然动态表也会导致内存占用过大影响服务器的总体并发能力因此服务器会限制HTTP/2连接的使用时长。
HTTP/2的另一个优势是实现了Stream并发这节约了TCP和TLS协议的握手时间并减少了TCP的慢启动阶段对流量的影响。同时Stream之间可以用Weight权重调节优先级还可以直接设置Stream间的依赖关系这样接收端就可以获得更优秀的体验。
HTTP/2支持消息推送从HTTP/1.1的拉模式到推模式信息传输效率有了巨大的提升。HTTP/2推消息时会使用PUSH_PROMISE帧传输头部并用双号的Stream来传递包体了解这一点对定位复杂的网络问题很有帮助。
HTTP/2的最大问题来自于它下层的TCP协议。由于TCP是字符流协议在前1字符未到达时后接收到的字符只能存放在内核的缓冲区里即使它们是并发的Stream应用层的HTTP/2协议也无法收到失序的报文这就叫做队头阻塞问题。解决方案是放弃TCP协议转而使用UDP协议作为传输层协议这就是HTTP/3协议的由来。
<img src="https://static001.geekbang.org/resource/image/38/d1/3862dad08cecc75ca6702c593a3c9ad1.png" alt="">
## 思考题
最后留给你一道思考题。为什么HTTP/2要用静态Huffman查表法对字符串编码基于连接上的历史数据统计信息做动态Huffman编码不是更有效率吗欢迎你在留言区与我一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,110 @@
<audio id="audio" title="17 | Protobuf是如何进一步提高编码效率的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8e/20/8e66aeee4819a94f8edbf01c09c26320.mp3"></audio>
你好,我是陶辉。
上一讲介绍的HTTP/2协议在编码上拥有非常高的空间利用率这一讲我们看看相比其中的HPACK编码技术Protobuf又是通过哪些新招式进一步提升编码效率的。
Google在2008年推出的Protobuf是一个针对具体编程语言的编解码工具。它面向Windows、Linux等多种平台也支持Java、Python、Golang、C++、Javascript等多种面向对象编程语言。使用Protobuf编码消息速度很快消耗的CPU计算力也不多而且编码后的字符流体积远远小于JSON等格式能够大量节约昂贵的带宽因此gRPC也把Protobuf作为底层的编解码协议。
然而很多同学并不清楚Protobuf到底是怎样做到这一点的。这样当你希望通过更换通讯协议这个高成本手段提升整个分布式系统的性能时面对可供选择的众多通讯协议仅凭第三方的性能测试报告你仍将难以作出抉择。
而且面对分布式系统中的疑难杂症往往需要通过分析抓取到的网络报文确定到底是哪个组件出现了问题。可是由于Protobuf编码太过紧凑即使对照着Proto消息格式文件在不清楚编码逻辑时你也很难解析出消息内容。
下面我们将基于上一讲介绍过的HPACK编码技术看看Protobuf是怎样进一步缩减编码体积的。
## 怎样用最少的空间编码字段名?
消息由多个名、值对组成比如HTTP请求中头部Host: www.taohui.pub就是一个名值对其中Host是字段名称而www.taohui.pub是字段值。我们先来看Protobuf如何编码字段名。
对于多达几十字节的HTTP头部HTTP/2静态表仅用一个数字来表示其中映射数字与字符串对应关系的表格被写死在HTTP/2实现框架中。这样的编码效率非常高**但通用的HTTP/2框架只能将61个最常用的HTTP头部映射为数字它能发挥出的作用很有限。**
动态表可以让更多的HTTP头部编码为数字在上一讲的例子中动态表将Host头部减少了96%的体积效果惊人。但动态表生效得有一个前提必须在一个会话连接上反复传输完全相同的HTTP头部。**如果消息字段在1个连接上只发送了1次或者反复传输时字段总是略有变动动态表就无能为力了。**
有没有办法既使用静态表的预定义映射关系,又享受到动态表的灵活多变呢?**其实只要把由HTTP/2框架实现的字段名映射关系交由应用程序自行完成即可。**而Protobuf就是这么做的。比如下面这段39字节的JSON消息虽然一目了然但字段名name、id、sex其实都是多余的因为客户端与服务器的处理代码都清楚字段的含义。
```
{&quot;name&quot;:&quot;John&quot;,&quot;id&quot;:1234,&quot;sex&quot;:&quot;MALE&quot;}
```
Protobuf将这3个字段名预分配了3个数字定义在proto文件中
```
message Person {
string name = 1;
uint32 id = 2;
enum SexType {
MALE = 0;
FEMALE = 1;
}
SexType sex = 3;
}
```
接着通过protoc程序便可以针对不同平台、编程语言将它生成编解码类最后通过类中自动生成的SerializeToString方法将消息序列化编码后的信息仅有11个字节。其中报文与字段的对应关系我放在下面这张图中。
<img src="https://static001.geekbang.org/resource/image/12/b4/12907732b38fd0c0f41330985bb02ab4.png" alt="">
从图中可以看出Protobuf是按照字段名、值类型、字段值的顺序来编码的由于编码极为紧凑所以分析时必须基于二进制比特位进行。比如红色的00001、00010、00011等前5个比特位就分别代表着name、id、sex字段。
图中字段值的编码方式我们后面再解释这里想必大家会有疑问如果只有5个比特位表示字段名的值那不是限制消息最多只有31个2<sup>5</sup> - 1字段吗当然不是字段名的序号可以从1到536870911即2<sup>29</sup> - 1可是多数消息不过只有几个字段这意味着可以用很小的序号表示它们。因此对于小于16的序号Protobuf仅有5个比特位表示这样加上3位值类型只需要1个字节表示字段名。对于大于16小于2027的序号也只需要2个字节表示。
Protobuf可以用1到5个字节来表示一个字段名因此每个字节的第1个比特位保留它为0时表示这是字段名的最后一个字节。下表列出了几种典型序号的编码值请把黑色的二进制位从右至左排列比如2049应为000100000000001即2048+1
<img src="https://static001.geekbang.org/resource/image/43/33/43983f7fcba1d26eeea952dc0934d833.jpg" alt="">
说完字段名,我们再来看字段值是如何编码的。
## 怎样高效地编码字段值?
Protobuf对不同类型的值采用6种不同的编码方式如下表所示
<img src="https://static001.geekbang.org/resource/image/b2/67/b20120a8bac33d985275b5a2768ad067.jpg" alt="">
字符串用Length-delimited方式编码顾名思义在值长度后顺序添加ASCII字节码即可。比如上文例子中的John对应的ASCII码如下表所示
<img src="https://static001.geekbang.org/resource/image/9f/cb/9f472ea914f98a81c03a7ad309f687cb.jpg" alt="">
这样,"John"需要5个字节进行编码如下图所示绿色表示长度紫色表示ASCII码
<img src="https://static001.geekbang.org/resource/image/6e/ae/6e45b5c7bb5e8766f6baef8c0e8b7bae.png" alt="">
这里需要注意字符串长度的编码逻辑与字段名相同当长度小于1282<sup>7</sup>1个字节就可以表示长度。若长度从128到163842<sup>14</sup>则需要2个字节以此类推。
由于字符串编码时未做压缩,所以并不会节约空间,但胜在速度快。**如果你的消息中含有大量字符串那么使用Huffman等算法压缩后再编码效果更好。**
我们再来看id1234这个数字是如何编码的。其实Protobuf中所有数字的编码规则是一致的字节中第1个比特位仅用于指示由哪些字节编码1个数字。例如图中的1234将由14个比特位00010011010010表示1024+128+64+16+2正好是1234
**由于消息中的大量数字都很小,这种编码方式可以带来很高的空间利用率!**当然如果你确定数字很大这种编码方式不但不能节约空间而且会导致原先4个字节的大整数需要用5个字节来表示时你也可以使用fixed32、fixed64等类型定义数字。
Protobuf还可以通过enum枚举类型压缩空间。回到第1幅图sex: FEMALE仅用2个字节就编码完成正是枚举值FEMALE使用数字1表示所达到的效果。
<img src="https://static001.geekbang.org/resource/image/c9/c7/c9b6c10399a34d7a0e577a0397cd5ac7.png" alt="">
而且由于Protobuf定义了每个字段的默认值因此当消息使用字段的默认值时Protobuf编码时会略过该字段。以sex: MALE为例由于MALE=0是sex的默认值因此在第2幅示例图中这2个字节都省去了。
另外当使用repeated语法将多个数字组成列表时还可以通过打包功能提升编码效率。比如下图中对numbers字段添加101、102、103、104这4个值后如果不使用打包功能共需要8个字节编码其中每个数字前都需要添加字段名。而使用打包功能后仅用6个字节就能完成编码显然列表越庞大节约的空间越多。
<img src="https://static001.geekbang.org/resource/image/ce/47/ce7ed2695b1e3dd869b59c438ee66147.png" alt="">
在Protobuf2版本中需要显式设置 [packed=True] 才能使用打包功能而在Protobuf3版本中这是默认功能。
最后,从[这里](https://github.com/protocolbuffers/protobuf/blob/master/docs/performance.md)可以查看Protobuf的编解码性能测试报告你能看到在保持高空间利用率的前提下Protobuf仍然拥有飞快的速度
## 小结
这一讲我们介绍了Protobuf的编码原理。
通过在proto文件中为每个字段预分配1个数字编码时就省去了完整字段名占用的空间。而且数字越小编码时用掉的空间也越小实际网络中大量传输的是小数字这带来了很高的空间利用率。Protobuf的枚举类型也通过类似的原理用数字代替字符串可以节约许多空间。
对于字符串Protobuf没有做压缩因此如果消息中的字符串比重很大时建议你先压缩后再使用Protobuf编码。对于拥有默认值的字段Protobuf编码时会略过它。对于repeated列表使用打包功能可以仅用1个字段前缀描述所有数值它在列表较大时能带来可观的空间收益。
## 思考题
下一讲我将介绍gRPC协议它结合了HTTP/2与Protobuf的优点在应用层提供方便而高效的RPC远程调用协议。你也可以提前思考下既然Protobuf的空间效率远甚过HPACK技术为什么gRPC还要使用HTTP/2协议呢
在Protobuf的性能测试报告中C++语言还拥有arenas功能你可以通过option cc_enable_arenas = true语句打开它。请结合[[第2讲]](https://time.geekbang.org/column/article/230221) 的内容谈谈arenas为什么能提升消息的解码性能欢迎你在留言区与我一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,147 @@
<audio id="audio" title="18 | 如何通过gRPC实现高效远程过程调用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e8/e8/e894713ff9692c1c167f23df35b9b8e8.mp3"></audio>
你好,我是陶辉。
这一讲我们将以一个实战案例基于前两讲提到的HTTP/2和ProtoBuf协议看看gRPC如何将结构化消息编码为网络报文。
直接操作网络协议编程容易让业务开发过程陷入复杂的网络处理细节。RPC框架以编程语言中的本地函数调用形式向应用开发者提供网络访问能力这既封装了消息的编解码也通过线程模型封装了多路复用对业务开发很友好。
其中Google推出的gRPC是性能最好的RPC框架之一它支持Java、JavaScript、Python、GoLang、C++、Object-C、Android、Ruby等多种编程语言还支持安全验证等特性得到了广泛的应用比如微服务中的Envoy、分布式机器学习中的TensorFlow甚至华为去年推出重构互联网的New IP技术都使用了gRPC框架。
然而网络上教你使用gRPC框架的教程很多却很少去谈gRPC是如何编码消息的。这样一旦在大型分布式系统中出现疑难杂症需要通过网络报文去定位问题发生在哪个系统、主机、进程中时你就会毫无头绪。即使我们掌握了HTTP/2和Protobuf协议但若不清楚gRPC的编码规则还是无法分析抓取到的gRPC报文。而且gRPC支持单向、双向的流式RPC调用编程相对复杂一些定位流式RPC调用引发的bug时更需要我们掌握gRPC的编码原理。
这一讲我就将以gRPC官方提供的example[data_transmisstion](https://github.com/grpc/grpc/tree/master/examples/python/data_transmission) 为例介绍gRPC的编码流程。在这一过程中会顺带回顾HTTP/2和Protobuf协议加深你对它们的理解。虽然这个示例使用的是Python语言但基于gRPC框架你可以轻松地将它们转换为其他编程语言。
## 如何使用gRPC框架实现远程调用
我们先来简单地看下gRPC框架到底是什么。RPC的全称是Remote Procedure Call即远程过程调用它通过本地函数调用封装了跨网络、跨平台、跨语言的服务访问大大简化了应用层编程。其中函数的入参是请求而函数的返回值则是响应。
gRPC就是一种RPC框架在你定义好消息格式后针对你选择的编程语言gRPC为客户端生成发起RPC请求的Stub类以及为服务器生成处理RPC请求的Service类服务器只需要继承、实现类中处理请求的函数即可。如下图所示很明显gRPC主要服务于面向对象的编程语言。
<img src="https://static001.geekbang.org/resource/image/c2/a1/c20e6974a05b5e71823aec618fc824a1.jpg" alt="">
gRPC支持QUIC、HTTP/1等多种协议但鉴于HTTP/2协议性能好应用场景又广泛因此HTTP/2是gRPC的默认传输协议。gRPC也支持JSON编码格式但在忽略编码细节的RPC调用中高效的Protobuf才是最佳选择因此这一讲仅基于HTTP/2和Protobuf介绍gRPC的用法。
gRPC可以简单地分为三层包括底层的数据传输层中间的框架层框架层又包括C语言实现的核心功能以及上层的编程语言框架以及最上层由框架层自动生成的Stub和Service类如下图所示
[<img src="https://static001.geekbang.org/resource/image/2a/4a/2a3f82f3eaabd440bf1ee449e532944a.png" alt="" title="图片来源https://platformlab.stanford.edu/Seminar%20Talks/gRPC.pdf">](https://platformlab.stanford.edu/Seminar%20Talks/gRPC.pdf)
接下来我们以官网上的[data_transmisstion](https://github.com/grpc/grpc/tree/master/examples/python/data_transmission) 为例先看看如何使用gRPC。
构建Python语言的gRPC环境很简单你可以参考官网上的[QuickStart](https://grpc.io/docs/quickstart/python/)。
使用gRPC前先要根据Protobuf语法编写定义消息格式的proto文件。在这个例子中只有1种请求和1种响应且它们很相似各含有1个整型数字和1个字符串如下所示
```
package demo;
message Request {
int64 client_id = 1;
string request_data = 2;
}
message Response {
int64 server_id = 1;
string response_data = 2;
}
```
请注意这里的包名demo以及字段序号1、2都与后续的gRPC报文分析相关。
接着定义service所有的RPC方法都要放置在service中这里将它取名为GRPCDemo。GRPCDemo中有4个方法后面3个流式访问的例子我们呆会再谈先来看简单的一元访问模式SimpleMethod 方法它定义了1个请求对应1个响应的访问形式。其中SimpleMethod的参数Request是请求返回值Response是响应。注意分析报文时会用到这里的类名GRPCDemo以及方法名SimpleMethod。
```
service GRPCDemo {
rpc SimpleMethod (Request) returns (Response);
}
```
用grpc_tools中的protoc命令就可以针对刚刚定义的service生成含有GRPCDemoStub类和GRPCDemoServicer类的demo_pb2_grpc.py文件实际上还包括完成Protobuf编解码的demo_pb2.py应用层将使用这两个类完成RPC访问。我简化了官网上的Python客户端代码如下所示
```
with grpc.insecure_channel(&quot;localhost:23333&quot;) as channel:
stub = demo_pb2_grpc.GRPCDemoStub(channel)
request = demo_pb2.Request(client_id=1,
request_data=&quot;called by Python client&quot;)
response = stub.SimpleMethod(request)
```
示例中客户端与服务器都在同一台机器上通过23333端口访问。客户端通过Stub对象的SimpleMethod方法完成了RPC访问。而服务器端的实现也很简单只需要实现GRPCDemoServicer父类的SimpleMethod方法返回response响应即可
```
class DemoServer(demo_pb2_grpc.GRPCDemoServicer):
def SimpleMethod(self, request, context):
response = demo_pb2.Response(
server_id=1,
response_data=&quot;Python server SimpleMethod Ok!!!!&quot;)
return response
```
可见gRPC的开发效率非常高接下来我们分析这次RPC调用中消息是怎样编码的。
## gRPC消息是如何编码的
**定位复杂的网络问题,都需要抓取、分析网络报文。**如果你在Windows上抓取网络报文可以使用Wireshark工具可参考[《Web协议详解与抓包实战》第37课](https://time.geekbang.org/course/detail/175-100973)如果在Linux上抓包可以使用tcpdump工具可参考[第87课](https://time.geekbang.org/course/detail/175-118169))。当然,你也可以从[这里](https://github.com/russelltao/geektime_distrib_perf/blob/master/18-gRPC/data_transmission.pkt)下载我抓取好的网络报文用Wireshark打开它。需要注意23333不是HTTP常用的80或者443端口所以Wireshark默认不会把它解析为HTTP/2协议。你需要鼠标右键点击报文选择“解码为”Decode as将23333端口的报文设置为HTTP/2解码器如下图所示
<img src="https://static001.geekbang.org/resource/image/74/f7/743e5038dc22676a0d48b56c453c1af7.png" alt="">
图中蓝色方框中TCP连接的建立过程请参见[[第9讲]](https://time.geekbang.org/column/article/237612)而HTTP/2会话的建立可参见[《Web协议详解与抓包实战》第52课](https://time.geekbang.org/course/detail/175-105608)还是比较简单的如果你都清楚就可以直接略过。我们重点看红色方框中的gRPC请求与响应点开请求可以看到下图中的信息
<img src="https://static001.geekbang.org/resource/image/ba/41/ba4d9e9a6ce212e94a4ced829eeeca41.png" alt="">
先来分析蓝色方框中的HTTP/2头部。请求中有2个关键的HTTP头部path和content-type它们决定了RPC方法和具体的消息编码格式。path的值为“/demo.GRPCDemo/SimpleMethod”通过“/包名.服务名/方法名”的形式确定了RPC方法。content-type的值为“application/grpc”确定消息编码使用Protobuf格式。如果你对其他头部的含义感兴趣可以看下这个[文档](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md)注意这里使用了ABNF元数据定义语言如果你还不了解ABNF可以看下[《Web协议详解与抓包实战》第4课](https://time.geekbang.org/course/detail/175-93589))。
HTTP/2包体并不会直接存放Protobuf消息而是先要添加5个字节的Length-Prefixed Message头部其中用4个字节明确Protobuf消息的长度1个字节表示消息是否做过压缩即上图中的桔色方框。为什么要多此一举呢这是因为gRPC支持流式消息即在HTTP/2的1条Stream中通过DATA帧发送多个gRPC消息而Length-Prefixed Message就可以将不同的消息分离开。关于流式消息我们在介绍完一元模式后再加以分析。
最后分析Protobuf消息这里仅以client_id字段为例对上一讲的内容做个回顾。在proto文件中client_id字段的序号为1因此首字节00001000中前5位表示序号为1的client_id字段后3位表示字段的值类型是varint格式的数字因此随后的字节00000001表示字段值为1。序号为2的request_data字段请你结合上一讲的内容试着做一下解析看看字符串“called by Python client”是怎样编码的。
再来看服务器发回的响应点开Wireshark中的响应报文后如下图所示
<img src="https://static001.geekbang.org/resource/image/b8/cf/b8e71a1b956286b2def457c2fae78bcf.png" alt="">
其中DATA帧同样包括Length-Prefixed Message和Protobuf与RPC请求如出一辙这里就不再赘述了我们重点看下HTTP/2头部。你可能留意到响应头部被拆成了2个部分其中grpc-status和grpc-message是在DATA帧后发送的这样就允许服务器在发送完消息后再给出错误码。关于gRPC的官方错误码以及message描述信息是如何取值的你可以参考[这个文档。](https://github.com/grpc/grpc/blob/master/doc/statuscodes.md)
这种将部分HTTP头部放在包体后发送的技术叫做Trailer[RFC7230文档](https://tools.ietf.org/html/rfc7230#page-39)对此有详细的介绍。其中RPC请求中的TE: trailers头部就说明客户端支持Trailer头部。在RPC响应中grpc-status头部都会放在最后发送因此它的帧flags的EndStream标志位为1。
可以看到gRPC中的HTTP头部与普通的HTTP请求完全一致因此它兼容当下互联网中各种七层负载均衡这使得gRPC可以轻松地跨越公网使用。
## gRPC流模式的协议编码
说完一元模式我们再来看流模式RPC调用的编码方式。
所谓流模式是指RPC通讯的一方可以在1次RPC调用中持续不断地发送消息这对订阅、推送等场景很有用。流模式共有3种类型包括客户端流模式、服务器端流模式以及两端双向流模式。在[data_transmisstion](https://github.com/grpc/grpc/tree/master/examples/python/data_transmission) 官方示例中对这3种流模式都定义了RPC方法如下所示
```
service GRPCDemo {
rpc ClientStreamingMethod (stream Request) returns Response);
rpc ServerStreamingMethod (Request) returns (stream Response);
rpc BidirectionalStreamingMethod (stream Request) returns (stream Response);
}
```
不同的编程语言处理流模式的代码很不一样这里就不一一列举了但通讯层的流模式消息编码是一样的而且很简单。这是因为HTTP/2协议中每个Stream就是天然的1次RPC请求每个RPC消息又已经通过Length-Prefixed Message头部确立了边界这样在Stream中连续地发送多个DATA帧就可以实现流模式RPC。我画了一张示意图你可以对照它理解抓取到的流模式报文。
<img src="https://static001.geekbang.org/resource/image/4b/e0/4b1b9301b5cbf0e0544e522c2a8133e0.jpg" alt="">
## 小结
这一讲介绍了gRPC怎样使用HTTP/2和Protobuf协议编码消息。
在定义好消息格式以及service类中的RPC方法后gRPC框架可以为编程语言生成Stub和Service类而类中的方法就封装了网络调用其中方法的参数是请求而方法的返回值则是响应。
发起RPC调用后我们可以这么分析抓取到的网络报文。首先分析应用层最外层的HTTP/2帧根据Stream ID找出一次RPC调用。客户端HTTP头部的path字段指明了service和RPC方法名而content-type则指明了消息的编码格式。服务器端的HTTP头部被分成2次发送其中DATA帧发送完毕后才会发送grpc-status头部这样可以明确最终的错误码。
其次分析包体时可以通过Stream中Length-Prefixed Message头部确认DATA帧中含有多少个消息因此可以确定这是一元模式还是流式调用。在Length-Prefixed Message头部后则是Protobuf消息按照上一讲的内容进行分析即可。
## 思考题
最后留给你一道练习题。gRPC默认并不会压缩字符串你可以通过在获取channel对象时加入grpc.default_compression_algorithm参数的形式要求gRPC压缩消息此时Length-Prefixed Message中1个字节的压缩位将会由0变为1。你可以观察下执行压缩后的gRPC消息有何不同欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。