mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-03 15:53:44 +08:00
mod
This commit is contained in:
141
极客时间专栏/系统性能调优必知必会/分布式系统优化/19 | 如何通过监控找到性能瓶颈?.md
Normal file
141
极客时间专栏/系统性能调优必知必会/分布式系统优化/19 | 如何通过监控找到性能瓶颈?.md
Normal file
@@ -0,0 +1,141 @@
|
||||
<audio id="audio" title="19 | 如何通过监控找到性能瓶颈?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/c8/71acd68a7f1f14f7b3c33f7c1ca117c8.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
从这一讲开始,我们将进入分布式系统层面,站在更宏观的角度去探讨系统性能的优化。
|
||||
|
||||
如果优化系统性能时,只是依据自己的经验,对感觉存在性能提升空间的代码,无一例外地做一遍优化,这既是一件事倍功半的事,也很容易遗漏下关键的优化点,无法大幅提升系统的性能。根据帕累托法则(也叫二八定律),**只有优化处于性能瓶颈的那些少量代码,才能用最小的成本获得最大的收益。**
|
||||
|
||||
然而,找到性能瓶颈却不是一件容易的事。我们通常会采用各种监控手段来发现性能瓶颈,但**如果监控动作自身的开发成本过高,或者施行监控时显著降低了业务请求的性能,或者无法全面覆盖潜在的问题,都会影响性能优化目标的实现。**
|
||||
|
||||
这一讲,我将介绍2个简单而又行之有效的方案,分别从微观上快速地找出进程内的瓶颈函数,以及从宏观上找出整个分布式系统中的瓶颈组件。这样,我们就可以事半功倍地去优化系统性能。
|
||||
|
||||
## 单机:如何通过火焰图找到性能瓶颈?
|
||||
|
||||
对于工作在一台主机上的进程而言,有许多监控方案可用于寻找性能瓶颈。比如在Linux下,你可以通过iostat命令监控磁盘状态,也可以通过top命令监控CPU、内存的使用。这些方案都是在旁敲侧击着寻找性能瓶颈,然而,有一种**最直接有效的方式,就是从代码层面直接寻找调用次数最频繁、耗时最长的函数,通常它就是性能瓶颈。**
|
||||
|
||||
要完成这样的目标,通常还有3个约束条件。
|
||||
|
||||
1. **没有代码侵入性。**比如,在函数执行的前后,分别打印一行日志记录时间,这当然能获取到函数的调用频次和执行时长,但并不可取,它的开发、维护成本太高了。
|
||||
1. **覆盖到代码中的全部函数。**如果只是监控事先猜测的、有限的几个函数,往往会因为思维死角,遗漏掉真正的瓶颈函数。因此,只有监控所有函数的执行情况,并以一种全局的、直观的方式分析聚合后的监控结果,才能快速、准确地找到性能瓶颈。
|
||||
1. **搭建环境的成本足够低。**我曾经使用过systemtap来监控函数的执行状况,搭建这样的监控环境需要很多步骤,比如要重新编译Linux内核,这些过于繁琐的成本会阻碍很多应用开发者的性能优化脚步。
|
||||
|
||||
带着这3个约束条件,最合适的方案已经呼之欲出了:那就是[Brendan Gregg](https://en.wikipedia.org/wiki/Brendan_Gregg) 发明的[火焰图](http://www.brendangregg.com/flamegraphs.html),以及为火焰图提供监控数据的工具。我们先来直观感受下火焰图到底是什么样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/d8/9ca4ff41238f067341d70edbf87c4cd8.png" alt="">
|
||||
|
||||
火焰图可以将监控期间涉及到的所有函数调用栈都列出来,就像上图对Nginx worker进程的监控一样,函数非常密集,因此通常采用可缩放、可互动的[SVG矢量格式](https://en.wikipedia.org/wiki/Scalable_Vector_Graphics)图片来展示。由于极客时间暂时还没法直接展示矢量图片,所以我把矢量图片放在了我的个人网站上,你可以点击[这个链接](http://www.taohui.pub/nginx_perf.svg)跳转到矢量图片页面。在SVG图片上你可以用Ctrl+F,通过名称搜索函数,而且这是支持正则表达式匹配的。你还可以点击函数方块,只查看它相关的函数调用栈。
|
||||
|
||||
火焰图中最重要的信息,就是表示函数执行时间的X轴,以及表示函数调用栈的Y轴。
|
||||
|
||||
先来看X轴。X轴由多个方块组成,每个方块表示一个函数,**其长度是定时采样得到的函数调用频率,因此你可以简单粗暴地把它近似为执行时间。**需要注意的是,如果函数A中调用了函数B、C、D,监控采样时会形成A->B、A->C、A->D这3个函数调用栈,但火焰图会将3个调用栈中表示A函数的方块合并,并将B、C、D放在上一层中,并以字母表顺序排列它们。这样更有助于你找到耗时最长的函数。
|
||||
|
||||
再来看Y轴,它表示函数的调用栈。这里,我们既可以看到内核API的函数调用栈,也可以看到用户态函数的调用栈,非常强大。如果你正在学习开源组件的源码,推荐你先生成火焰图,再对照图中的Y轴调用栈,理解源码中函数的调用关系。注意,**函数方块的颜色是随机的,并没有特别的意义,只是为了在视觉上更容易区分开。**
|
||||
|
||||
结合X轴和Y轴,我们再来看如何使用火焰图找到性能瓶颈。
|
||||
|
||||
首先,火焰图很容易从全局视角,通过寻找长方块,找到调用频率最高的几个函数。
|
||||
|
||||
其次,函数方块长,有些是因为函数自身执行了很消耗CPU的代码,而有些则是调用的子函数耗时长造成的。怎么区分这两种场景呢?很简单,**如果函数方块的长度,远大于调用栈中子函数方块的长度之和,那么这个函数就执行了比较耗费CPU的计算。**比如,下图中执行TLS握手的ngx_ssl_handshake_handler函数,自身并没有消耗CPU多少计算力。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/14/c2/14c155f5ef577fa2aa6a127145b2acc2.png" alt="">
|
||||
|
||||
而更新Nginx时间的ngx_time_update函数就不同,它在做时间格式转换时消耗了许多CPU,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/67/b8b40ab7b4d358b45e600d8721b1d867.png" alt="">
|
||||
|
||||
这样,我们可以直观地找到最耗时的函数,再有针对性地优化它的性能。
|
||||
|
||||
怎么生成火焰图呢?在Linux上这非常简单,因为Linux内核默认支持perf工具,你可以用perf生成函数的采样数据,再用FlameGraph脚本生成火焰图。当然,如果你在使用Java、GoLang、Python等高级语言,也可以使用各自语言生态中封装过的Profile类工具,生成采样数据。这里我以最基本的perf、FlameGraph为例,介绍下生成火焰图的5步流程。
|
||||
|
||||
首先,你可以通过yum或者apt-get安装perf工具,再通过git来下载FlameGraph:
|
||||
|
||||
```
|
||||
yum install perf
|
||||
git clone --depth 1 https://github.com/brendangregg/FlameGraph.git
|
||||
|
||||
```
|
||||
|
||||
接着,针对运行中的进程PID,使用perf采样函数的调用频率(对于C/C++语言,为了能够显示完整的函数栈,你需要在编译时加入-g选项),如下所示:
|
||||
|
||||
```
|
||||
perf record -F 99 -p 进程PID -g --call-graph dwarf
|
||||
|
||||
```
|
||||
|
||||
上述命令行中各参数的含义,可以参见[这里](http://www.brendangregg.com/perf.html)。
|
||||
|
||||
再将二进制信息转换为ASCII格式的文件,方便FlameGraph处理:
|
||||
|
||||
```
|
||||
perf script > out.perf
|
||||
|
||||
```
|
||||
|
||||
再然后,需要汇聚函数调用栈,转化为FlameGraph生成火焰图的数据格式:
|
||||
|
||||
```
|
||||
FlameGraph/stackcollapse-perf.pl out.perf > out.folded
|
||||
|
||||
```
|
||||
|
||||
最后一步,生成SVG格式的矢量图片:
|
||||
|
||||
```
|
||||
FlameGraph/flamegraph.pl out.folded > out.svg
|
||||
|
||||
```
|
||||
|
||||
需要注意,**上面的火焰图只能找到消耗CPU计算力最多的函数,因此它也叫做On CPU火焰图,由于CPU工作时会发热,所以色块都是暖色调。**有些使用了阻塞API的代码,则会导致进程休眠,On CPU火焰图无法找到休眠时间最长的函数,此时可以使用Off CPU火焰图,它按照函数引发进程的休眠时间的长短,确定函数方块的长度。由于进程休眠时不使用CPU,所以色块会使用冷色调。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/8f/823f2666f8f8b57714e751501f178a8f.png" alt="">
|
||||
|
||||
图片来源:[http://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html](http://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html),你可以点击[这个链接](http://www.brendangregg.com/FlameGraphs/off-mysqld-busy.svg)查看SVG图片。
|
||||
|
||||
生成Off CPU火焰图的步骤稍微繁琐点,你可以参照[这个页面](http://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html)。关于火焰图的设计思路和详细使用方式,推荐你参考[Brendan Gregg](https://en.wikipedia.org/wiki/Brendan_Gregg) 的[这篇论文](https://queue.acm.org/detail.cfm?id=2927301)。
|
||||
|
||||
## 分布式系统:如何通过全链路监控找到性能瓶颈?
|
||||
|
||||
说完微观上性能瓶颈的定位,我们再来看宏观上的分布式系统,它通过网络把不同类型的硬件、操作系统、编程语言组合在一起,结构很复杂,因此,现在我们的目标变成寻找更大尺度的瓶颈组件。
|
||||
|
||||
当然,与单机不同的是,找到分布式系统的性能瓶颈组件后,除了可以继续通过火焰图优化组件中某个进程的性能外,还有下面这样3个新收益。
|
||||
|
||||
1. 首先,可以通过简单的扩容动作,利用负载均衡机制提升性能。
|
||||
1. 其次,在云计算和微服务时代,扩容可以在分钟级时间内完成。因此,如果性能瓶颈可以实时检测到,就可以自动化地完成扩容和流量迁移。这可以提升服务器资源的利用率。
|
||||
1. 最后,既然能够找到瓶颈组件,同样也能找出资源富裕的组件,这样就可以通过反向的缩容,减少服务器资源的浪费。
|
||||
|
||||
接下来,我将从4个角度介绍分布式系统中的性能监控体系。
|
||||
|
||||
首先,监控基于日志来做,对系统的侵入性最低,因此,你要把格式多样化的文本日志,进行结构化管理。我们知道,1个分布式系统会涉及到多种编程语言,这样各组件基于完全异构的中间件,产生的日志格式也是五花八门!如果定义一个统一的日志格式标准,再要求每个组件遵照它重新开发一个版本,这样代价太高,基本无法实现!
|
||||
|
||||
比较幸运的是,几乎每个组件都会输出文本类型的访问日志,而且它们通常包括访问对象(例如URL)、错误码、处理时间等共性信息。因此,可以针对每个组件特定格式的文本日志,写一个正则表达式,将我们需要的结构化信息提取出来。这样,结构化数据的提取是在组件之外的,对于组件中的代码也没有影响。
|
||||
|
||||
其次,同时搜集系统中每个组件的日志并汇总在一起,这样日志数据的规模就非常可观,每天可能会产生TB级别的数据,因此关系型数据库并不可取,因为它们为了照顾事务、关联查询等操作,不具备很好的可伸缩性。同时,结构化的日志源于我们必须针对某类请求、某类组件做聚合分析,因此纯粹的Key/Value数据库也无法实现这类功能。因此,像HBase这样的半结构化列式数据库往往是监控数据的首选落地形式。
|
||||
|
||||
再次,我们分析日志时,首先会从横向上,聚合分析成百上千个组件中时延、吞吐量、错误码等数据项的统计值。这样,计算力就是一个很大的问题,因此通常选择支持Map Reduce算法的系统(比如Hadoop)进行计算,并将结果以可视化的方式展现出来,协助性能分析。由于计算时间比较久,所以这也称为离线计算。其次会在纵向上,监控一个请求在整个生命周期内,各个参与组件的性能状态。因此必须设计一个请求ID,能够贯穿在各个组件内使用,由于分布式系统中的请求数量非常大,这个ID的碰撞概率必须足够低。所以,请求ID通常会同时包含时间和空间元素(比如IP地址)。这个方案还有个很威武的名字,叫做全链路监控(可以参考Google的[这篇论文](https://storage.googleapis.com/pub-tools-public-publication-data/pdf/36356.pdf)),它还可以用于组件依赖关系的优化。
|
||||
|
||||
最后,你还得对性能瓶颈做实时监控,这是实现分布式系统自动化扩容、缩容的前提。由于监控数据规模庞大,所以我们通常要把流量在时间维度上分片,仅对每个时间小窗口中有限的数据,做快速的增量计算。像Storm、Spark、Flink都是这样的实时流计算中间件,你可以基于它们完成实时数据的汇聚分析。
|
||||
|
||||
以上是我对分布式系统性能监控方案的一些思考。
|
||||
|
||||
一提到分布式系统,涉及到的知识点就非常多。好在我们后续的课程对MapReduce、实时流计算等还有进一步的分析,因此监控方案中对它们就一带而过了。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲,我们介绍了单机上如何通过火焰图找到性能瓶颈函数,以及在分布式系统中,如何通过全链路监控找到性能瓶颈组件。
|
||||
|
||||
在Linux系统中,你可以用内核支持的perf工具,快速地生成火焰图。其他高级编程语言生态中,都有类似的Profiler工具,可生成火焰图。
|
||||
|
||||
火焰图中可以看到函数调用栈,它对你分析源码很有帮助。图中方块的长度表示函数的调用频率,当采样很密集时,你可以把它近似为函数的执行时长。父方块长度减去所有子方块长度的和,就表示了父函数自身代码对CPU计算力的消耗。因此,火焰图可以直观地找到调用次数最频繁且最耗时的函数。
|
||||
|
||||
除了上面介绍的On CPU火焰图,你也可以用同样的工具生成Off CPU火焰图,它用于找到频率引发进程休眠,进而降低了性能的瓶颈函数。
|
||||
|
||||
对于分布式系统,性能监控还有利于系统的扩容和缩容运维。搭建性能监控体系包括以下四个关键点:首先要把五花八门的日志用正则表达式提取为结构化的监控数据;其次用半结构化的列式数据库存放集群中的所有日志,便于后续的汇聚分析;第三,使用统一的请求ID将各组件串联在一起,并使用MapReduce算法对大量的监控数据做离线分析;最后,通过实时流计算框架,对监控数据做实时汇聚分析。
|
||||
|
||||
性能监控是一个很大的话题,这节课我只希望能从不同的角度带给你思考,课后还需要你进行更深入的探索。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后给你留一道练习题,请你参考On CPU火焰图的制作,对你熟悉的程序做一张Off CPU火焰图,看看能带给你哪些新的启发?期待你的总结。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
97
极客时间专栏/系统性能调优必知必会/分布式系统优化/20 | CAP理论:怎样舍弃一致性去换取性能?.md
Normal file
97
极客时间专栏/系统性能调优必知必会/分布式系统优化/20 | CAP理论:怎样舍弃一致性去换取性能?.md
Normal file
@@ -0,0 +1,97 @@
|
||||
<audio id="audio" title="20 | CAP理论:怎样舍弃一致性去换取性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/00/d2/00e03249adac4f0c4b3dec4df91c99d2.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲我们介绍了如何通过监控找到性能瓶颈,从这一讲开始,我们将具体讨论如何通过分布式系统来提升性能。
|
||||
|
||||
在第一部分课程中,我介绍了多种提升单机处理性能的途径,然而,进程的性能必然受制于一台服务器上各硬件的处理能力上限。如果需要进一步地提升服务性能,那只有整合多台主机组成分布式系统才能办到。
|
||||
|
||||
然而,当多台主机通过网络协同处理用户请求时,如果主机上的进程含有数据状态,那么必然需要在多台主机之间跨网络同步数据,由于网络存在时延,也并不稳定,因此不可靠的数据同步操作将会增加请求的处理时延。
|
||||
|
||||
CAP理论指出,当数据同时存放在多个主机上时,可用性与一致性是不可兼得的。根据CAP的指导性思想,我们可以通过牺牲一致性,来提升可用性中的核心因素:性能。当然,在实践中对一致性与性能并不是非黑即白的选择,而是从概率上进行不同程度的取舍。
|
||||
|
||||
这一讲,我们将基于分布式系统中的经典理论,从总体上看看如何设计一致性模型,通过牺牲部分数据的一致性来提升性能。
|
||||
|
||||
## 如何权衡性能与一致性?
|
||||
|
||||
首先,这节课针对的是**有状态服务**的性能优化。所谓有状态服务,是指进程会在处理完请求后,仍然保存着影响下次请求结果的用户数据,而无状态服务则只从每个请求的输入参数中获取数据,在请求处理完成后并不保存任何会话信息。因此,无状态服务拥有最好的可伸缩性,但涉及数据持久化时,则必须由有状态服务处理,这是CAP理论所要解决的问题。
|
||||
|
||||
什么是CAP理论呢?这是2000年[University of California, Berkeley](https://en.wikipedia.org/wiki/University_of_California,_Berkeley) 的计算机教授[Eric Brewer](https://en.wikipedia.org/wiki/Eric_Brewer_(scientist))(也是谷歌基础设施VP)提出的理论。所谓CAP,是以下3个单词的首字母缩写,它们都是分布式系统最核心的特性:
|
||||
|
||||
- **C**onsistency一致性
|
||||
- **A**vailability可用性
|
||||
- **P**artition tolerance分区容错性
|
||||
|
||||
我们通过以下3张示意图,快速理解下这3个词的意义。下图中N1、N2两台主机上运行着A进程和B进程,它们操作着同一个用户的数据(数据的初始值是V0),这里N1和N2主机就处于不同的Partition分区中,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/f4/744834f9a5dd04244e5f83719fb3f6f4.jpg" alt="">
|
||||
|
||||
正常情况下,当用户请求到达N1主机上的A进程,并将数据V0修改为V1后,A进程将会把这一修改行为同步到N2主机上的B进程,最终N1、N2上的数据都是V1,这就保持了系统的Consistency一致性。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/23/25/23ab7127cbc89bcbe83c2a3669d66125.jpg" alt="">
|
||||
|
||||
然而,一旦N1和N2之间网络异常,数据同步行为就会失败。这时,N1和N2之间数据不一致,如果我们希望在分区间网络不通的情况下,N2能够继续为用户提供服务,就必须容忍数据的不一致,此时系统的Availability可用性更高,系统的并发处理能力更强,比如Cassandra数据库。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/fb/a7302dc2491229a019aa27f043ba08fb.jpg" alt="">
|
||||
|
||||
反之,如果A、B进程一旦发现数据同步失败,那么B进程自动拒绝新请求,仅由A进程独立提供服务,那么虽然降低了系统的可用性,但保证了更强的一致性,比如MySQL的主备同步模式。
|
||||
|
||||
这就是CAP中三者只能取其二的简要示意,对于这一理论,2002年MIT的[Seth Gilbert](http://lpd.epfl.ch/sgilbert/) 、 [Nancy Lynch](http://people.csail.mit.edu/lynch/) 在[这篇论文](https://dl.acm.org/doi/pdf/10.1145/564585.564601)中,证明了这一理论。当然,[可用性](https://en.wikipedia.org/wiki/High_availability)是一个很大的概念,它描述了分布式系统的持续服务能力,如下表所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/df/0c11b949c35e4cce86233843ccb152df.jpg" alt="">
|
||||
|
||||
当用户、流量不断增长时,系统的性能将变成衡量可用性的关键因素。当我们希望拥有更强的性能时,就不得不牺牲数据的一致性。当然,一致性并不是只有是和否这两种属性,我们既可以从时间维度上设计短暂不一致的同步模型,也可以从空间维度上为不含有因果、时序关系的用户数据设计并发模型。
|
||||
|
||||
在学术上,通常会按照2个维度对[一致性模型](https://en.wikipedia.org/wiki/Consistency_model#Client-centric_consistency_models%5B19%5D)分类。首先是从数据出发设计出的一致性模型,比如顺序一致性必须遵照读写操作的次序来保持一致性,而弱一些的因果一致性则允许不具备因果关系的读写操作并发执行。其次是从用户出发设计出的一致性模型,比如单调读一致性保证客户端不会读取到旧值,而单调写一致性则保证写操作是串行的。
|
||||
|
||||
实际工程中一致性与可用性的边界会模糊很多,因此又有了[最终一致性](https://en.wikipedia.org/wiki/Eventual_consistency)这样一个概念,这个“最终”究竟是多久,将由业务特性、网络故障率等因素综合决定。伴随最终一致性的是BASE理论:
|
||||
|
||||
- **B**asically **A**vailable基本可用性
|
||||
- **S**oft state软状态
|
||||
- **E**ventually consistent最终一致性
|
||||
|
||||
BASE与CAP一样并没有很精确的定义,它们最主要的用途是从大方向上给你指导性的思想。接下来,我们看看如何通过最终一致性来提升性能。
|
||||
|
||||
## 怎样舍弃一致性提升性能?
|
||||
|
||||
服务的性能,主要体现在请求的时延和系统的并发性这两个方面,而它们都与上面提到的最终一致性有关。我通常会把分布式系统分为纵向、横向两个维度,其中**纵向是请求的处理路径,横向则是同类服务之间的数据同步路径。**这样,在纵向上在离客户端更近的位置增加数据的副本,并把它存放在处理速度更快的物理介质上,就可以作为缓存降低请求的时延;而在横向上对数据增加副本,并在这些主机间同步数据,这样工作在数据副本上的进程也可以同时对客户端提供服务,这就增加了系统的并发性,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/06/5a6dbac922c500eea108d374dccc6406.png" alt="">
|
||||
|
||||
我们先来看纵向上的缓存,是如何在降低请求处理时延时,保持最终一致性的。
|
||||
|
||||
缓存可以由读、写两个操作触发更新,[[第15课]](https://time.geekbang.org/column/article/242667) 介绍过的HTTP私有缓存,就是工作在浏览器上基于读操作触发的缓存。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/ab/9dea133d832d8b7ab642bb74b48502ab.png" alt="">
|
||||
|
||||
工作在代理服务器上的HTTP共享缓存,也是由读操作触发的。这些缓存与源服务器上的数据最终是一致的,但在CacheControl等HTTP头部指定的时间内,缓存完全有可能与源数据不同,这就是牺牲一致性来提升性能的典型例子。
|
||||
|
||||
事实上,也有很多以写操作触发缓存更新的设计,它们通常又分为write back和write through两种模式。其中,**write back牺牲了更多的一致性,但带来了更低的请求时延。<strong>比如[[第4课]](https://time.geekbang.org/column/article/232676) 介绍过的Linux磁盘高速缓存就采用了write back这种设计,它虽然是单机内的一种缓存设计,但在分布式系统中缓存的设计方式也是一样的。而**write through会在更新数据成功后再更新缓存,虽然带来了很好的一致性,但写操作的时延会更久</strong>,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/ac/de0ed171a392b63a87af28b9aa6ec7ac.png" alt="">
|
||||
|
||||
write through的一致性非常好,它常常是我们的首选设计。然而,一旦缓存到源数据的路径很长、延时很高的时候,就值得考虑write back模式,此时一致性模型虽然复杂了许多,但可以带来显著的性能提升。比如机械磁盘相对内存延时高了很多,因此磁盘高速缓存会采用write back模式。
|
||||
|
||||
虽然缓存也可以在一定程度上增加系统的并发处理连接,但这更多是缘于缓存采用了更快的算法以及存储介质带来的收益。从水平方向上,在更多的主机上添加数据副本,并在其上用新的进程提供服务,这才是提升系统并发处理能力最有效的手段。此时,进程间同步数据主要包含两种方式:同步方式以及异步方式,前者会在每个更新请求完成前,将数据成功同步到每个副本后,请求才会处理完成,而后者则会在处理完请求后,才开始异步地同步数据到副本中,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/f6/d770cf24f61d671ab1f0c1a6170627f6.png" alt="">
|
||||
|
||||
同步方式下系统的一致性最好,但由于请求的处理时延包含了副本间的通讯时长,所以性能并不好。而异步方式下系统的一致性要差,特别是在主进程宕机后,副本上的进程很容易出现数据丢失,但异步方式的性能要好得多,这不只由于更新请求的返回更快,而且异步化后主进程可以基于合并、批量操作等技巧,进行效率更高的数据同步。比如MySQL的主备模式下,默认就使用异步方式同步数据,当然你也可以改为同步方式,包括Full-synchronous Replication(同步所有数据副本后请求才能返回)以及Semi-synchronous Replication(仅成功同步1个副本后请求就可以返回)两种方式。
|
||||
|
||||
当然,在扩容、宕机恢复等场景下,副本之间的数据严重不一致,如果仍然基于单个操作同步数据,时间会很久,性能很差。此时应结合定期更新的Snapshot快照,以及实时的Oplog操作日志,协作同步数据,这样效率会高很多。其中,快照是截止时间T0时所有数据的状态,而操作日志则是T0时间到当下T1之间的所有更新操作,这样,副本载入快照恢复至T0时刻的数据后,再通过有限的操作日志与主进程保持一致。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们介绍了分布式系统中的CAP理论,以及根据CAP如何通过一致性来换取性能。
|
||||
|
||||
CAP理论指出,可用性、分区容错性、一致性三者只能取其二,因此当分布式系统需要服务更多的用户时,只能舍弃一致性,换取可用性中的性能因子。当然,性能与一致性并不是简单的二选一,而是需要你根据网络时延、故障概率设计出一致性模型,在提供高性能的同时,保持时间、空间上可接受的最终一致性。
|
||||
|
||||
具体的设计方法,可以分为纵向上添加缓存,横向上添加副本进程两种做法。对于缓存的更新,write through模式保持一致性更容易,但写请求的时延偏高,而一致性模型更复杂的write back模式时延则更低,适用于性能要求很高的场景。
|
||||
|
||||
提升系统并发性可以通过添加数据副本,并让工作在副本上的进程同时对用户提供服务。副本间的数据同步是由写请求触发的,其中包括同步、异步两种同步方式。异步方式的最终一致性要差一些,但写请求的处理时延更快。在宕机恢复、系统扩容时,采用快照加操作日志的方式,系统的性能会好很多。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后留给你一道讨论题,当数据副本都在同一个IDC内部时,网络时延很低,带宽大而且近乎免费使用。然而,一旦数据副本跨IDC后,特别是IDC之间物理距离很遥远,那么网络同步就变得很昂贵。这两种不同场景下,你是如何考虑一致性模型的?它与性能之间有多大的影响?期待你的分享。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
103
极客时间专栏/系统性能调优必知必会/分布式系统优化/21 | AKF立方体:怎样通过可扩展性来提高性能?.md
Normal file
103
极客时间专栏/系统性能调优必知必会/分布式系统优化/21 | AKF立方体:怎样通过可扩展性来提高性能?.md
Normal file
@@ -0,0 +1,103 @@
|
||||
<audio id="audio" title="21 | AKF立方体:怎样通过可扩展性来提高性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/41/0d/415yy34cdbfd00e6df7a974e24d5a70d.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲我们谈到,调低一致性可以提升有状态服务的性能。这一讲我们扩大范围,结合无状态服务,看看怎样提高分布式系统的整体性能。
|
||||
|
||||
当你接收到运维系统的短信告警,得知系统性能即将达到瓶颈,或者会议上收到老板兴奋的通知,接下来市场开缰拓土,业务访问量将要上一个大台阶时,一定会马上拿起计算器,算算要加多少台机器,系统才能扛得住新增的流量。
|
||||
|
||||
然而,有些服务虽然可以通过加机器提升性能,但可能你加了一倍的服务器,却发现系统的吞吐量没有翻一倍。甚至有些服务无论你如何扩容,性能都没有半点提升。这缘于我们扩展分布式系统的方向发生了错误。
|
||||
|
||||
当我们需要分布式系统提供更强的性能时,该怎样扩展系统呢?什么时候该加机器?什么时候该重构代码?扩容时,究竟该选择哈希算法还是最小连接数算法,才能有效提升性能?
|
||||
|
||||
在面对Scalability可伸缩性问题时,我们必须有一个系统的方法论,才能应对日益复杂的分布式系统。这一讲我将介绍AKF立方体理论,它定义了扩展系统的3个维度,我们可以综合使用它们来优化性能。
|
||||
|
||||
## 如何基于AKF X轴扩展系统?
|
||||
|
||||
AKF立方体也叫做[scala cube](https://en.wikipedia.org/wiki/Scale_cube),它在《The Art of Scalability》一书中被首次提出,旨在提供一个系统化的扩展思路。AKF把系统扩展分为以下三个维度:
|
||||
|
||||
- X轴:直接水平复制应用进程来扩展系统。
|
||||
- Y轴:将功能拆分出来扩展系统。
|
||||
- Z轴:基于用户信息扩展系统。
|
||||
|
||||
如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/aa/61633c7e7679fd10b915494b72abb3aa.jpg" alt="">
|
||||
|
||||
我们日常见到的各种系统扩展方案,都可以归结到AKF立方体的这三个维度上。而且,我们可以同时组合这3个方向上的扩展动作,使得系统可以近乎无限地提升性能。为了避免对AKF的介绍过于抽象,下面我用一个实际的例子,带你看看这3个方向的扩展到底该如何应用。
|
||||
|
||||
假定我们开发一个博客平台,用户可以申请自己的博客帐号,并在其上发布文章。最初的系统考虑了MVC架构,将数据状态及关系模型交给数据库实现,应用进程通过SQL语言操作数据模型,经由HTTP协议对浏览器客户端提供服务,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/e0/cda814dcfca8820c81024808fe96b1e0.jpg" alt="">
|
||||
|
||||
在这个架构中,处理业务的应用进程属于无状态服务,用户数据全部放在了关系数据库中。因此,当我们在应用进程前加1个负载均衡服务后,就可以通过部署更多的应用进程,提供更大的吞吐量。而且,初期增加应用进程,RPS可以获得线性增长,很实用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/62/d7f75f3f5a5e6f8a07b1c47501606962.png" alt="">
|
||||
|
||||
这就叫做沿AKF X轴扩展系统。这种扩展方式最大的优点,就是开发成本近乎为零,而且实施起来速度快!在搭建好负载均衡后,只需要在新的物理机、虚拟机或者微服务上复制程序,就可以让新进程分担请求流量,而且不会影响事务Transaction的处理。
|
||||
|
||||
当然,AKF X轴扩展最大的问题是只能扩展无状态服务,当有状态的数据库出现性能瓶颈时,X轴是无能为力的。例如,当用户数据量持续增长,关系数据库中的表就会达到百万、千万行数据,SQL语句会越来越慢,这时可以沿着AKF Z轴去分库分表提升性能。又比如,当请求用户频率越来越高,那么可以把单实例数据库扩展为主备多实例,沿Y轴把读写功能分离提升性能。下面我们先来看AKF Y轴如何扩展系统。
|
||||
|
||||
## 如何基于AKF Y轴扩展系统?
|
||||
|
||||
当数据库的CPU、网络带宽、内存、磁盘IO等某个指标率先达到上限后,系统的吞吐量就达到了瓶颈,此时沿着AKF X轴扩展系统,是没有办法提升性能的。
|
||||
|
||||
在现代经济中,更细分、更专业的产业化、供应链分工,可以给社会带来更高的效率,而AKF Y轴与之相似,当遇到上述性能瓶颈后,拆分系统功能,使得各组件的职责、分工更细,也可以提升系统的效率。比如,当我们将应用进程对数据库的读写操作拆分后,就可以扩展单机数据库为主备分布式系统,使得主库支持读写两种SQL,而备库只支持读SQL。这样,主库可以轻松地支持事务操作,且它将数据同步到备库中也并不复杂,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/86/74/865885bb7213e62b8e1b715d85c9a974.png" alt="">
|
||||
|
||||
当然,上图中如果读性能达到了瓶颈,我们可以继续沿着AKF X轴,用复制的方式扩展多个备库,提升读SQL的性能,可见,AKF多个轴完全可以搭配着协同使用。
|
||||
|
||||
拆分功能是需要重构代码的,它的实施成本比沿X轴简单复制扩展要高得多。在上图中,通常关系数据库的客户端SDK已经支持读写分离,所以实施成本由中间件承担了,这对我们理解Y轴的实施代价意义不大,所以我们再来看从业务上拆分功能的例子。
|
||||
|
||||
当这个博客平台访问量越来越大时,一台主库是无法扛住所有写流量的。因此,基于业务特性拆分功能,就是必须要做的工作。比如,把用户的个人信息、身份验证等功能拆分出一个子系统,再把文章、留言发布等功能拆分到另一个子系统,由无状态的业务层代码分开调用,并通过事务组合在一起,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/af/3bba7bc19965bb9b01c058e67a6471af.png" alt="">
|
||||
|
||||
这样,每个后端的子应用更加聚焦于细分的功能,它的数据库规模会变小,也更容易优化性能。比如,针对用户登录功能,你可以再次基于Y轴将身份验证功能拆分,用Redis等服务搭建一个基于LRU算法淘汰的缓存系统,快速验证用户身份。
|
||||
|
||||
然而,沿Y轴做功能拆分,实施成本非常高,需要重构代码并做大量测试工作,上线部署也很复杂。比如上例中要对数据模型做拆分(如同一个库中的表拆分到多个库中,或者表中的字段拆到多张表中),设计组件之间的API交互协议,重构无状态应用进程中的代码,为了完成升级还要做数据迁移,等等。
|
||||
|
||||
解决数据增长引发的性能下降问题,除了成本较高的AKF Y轴扩展方式外,沿Z轴扩展系统也很有效,它的实施成本更低一些,下面我们具体看一下。
|
||||
|
||||
## 如何基于AKF Z轴扩展系统?
|
||||
|
||||
不同于站在服务角度扩展系统的X轴和Y轴,AKF Z轴则从用户维度拆分系统,它不仅可以提升数据持续增长降低的性能,还能基于用户的地理位置获得额外收益。
|
||||
|
||||
仍然以上面虚拟的博客平台为例,当注册用户数量上亿后,无论你如何基于Y轴的功能去拆分表(即“垂直”地拆分表中的字段),都无法使得关系数据库单个表的行数在千万级以下,这样表字段的B树索引非常庞大,难以完全放在内存中,最后大量的磁盘IO操作会拖慢SQL语句的执行。
|
||||
|
||||
这个时候,关系数据库最常用的分库分表操作就登场了,它正是AKF沿Z轴拆分系统的实践。比如已经含有上亿行数据的User用户信息表,可以分成10个库,每个库再分成10张表,利用固定的哈希函数,就可以把每个用户的数据映射到某个库的某张表中。这样,单张表的数据量就可以降低到1百万行左右,如果每个库部署在不同的服务器上(具体的部署方式视访问吞吐量以及服务器的配置而定),它们处理的数据量减少了很多,却可以独占服务器的硬件资源,性能自然就有了提升。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/83/dc9e29827c26f89ff3459b5c99313583.png" alt="">
|
||||
|
||||
分库分表是关系数据库中解决数据增长压力的最有效办法,但分库分表同时也导致跨表的查询语句复杂许多,而跨库的事务几乎难以实现,因此这种扩展的代价非常高。当然,如果你使用的是类似MySQL这些成熟的关系数据库,整个生态中会有厂商提供相应的中间件层,使用它们可以降低Z轴扩展的代价。
|
||||
|
||||
再比如,最开始我们采用X轴复制扩展的服务,它们的负载均衡策略很简单,只需要选择负载最小的上游服务器即可,比如RoundRobin或者最小连接算法都可以达到目的。但若上游服务器通过Y轴扩展,开启了缓存功能,那么考虑到缓存的命中率,就必须改用Z轴扩展的方式,基于用户信息做哈希规则下的新路由,尽量将同一个用户的请求命中相同的上游服务器,才能充分提高缓存命中率。
|
||||
|
||||
Z轴扩展还有一个好处,就是可以充分利用IDC与用户间的网速差,选择更快的IDC为用户提供高性能服务。网络是基于光速传播的,当IDC跨城市、国家甚至大洲时,用户访问不同IDC的网速就会有很大差异。当然,同一地域内不同的网络运营商之间,也会有很大的网速差。
|
||||
|
||||
例如你在全球都有IDC或者公有云服务器时,就可以通过域名为当地用户就近提供服务,这样性能会高很多。事实上,CDN技术就基于IP地址的位置信息,就近为用户提供静态资源的高速访问。
|
||||
|
||||
下图中,我使用了2种Z轴扩展系统的方式。首先是基于客户端的地理位置,选择不同的IDC就近提供服务。其次是将不同的用户分组,比如免费用户组与付费用户组,这样在业务上分离用户群体后,还可以有针对性地提供不同水准的服务。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/35/3b/353d8515d40db25eebee23889a3ecd3b.png" alt="">
|
||||
|
||||
沿AKF Z轴扩展系统可以解决数据增长带来的性能瓶颈,也可以基于数据的空间位置提升系统性能,然而它的实施成本比较高,尤其是在系统宕机、扩容时,一旦路由规则发生变化,会带来很大的数据迁移成本,[第24讲] 我将要介绍的一致性哈希算法,其实就是用来解决这一问题的。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们介绍了如何基于AKF立方体的X、Y、Z三个轴扩展系统提升性能。
|
||||
|
||||
X轴扩展系统时实施成本最低,只需要将程序复制到不同的服务器上运行,再用下游的负载均衡分配流量即可。X轴只能应用在无状态进程上,故无法解决数据增长引入的性能瓶颈。
|
||||
|
||||
Y轴扩展系统时实施成本最高,通常涉及到部分代码的重构,但它通过拆分功能,使系统中的组件分工更细,因此可以解决数据增长带来的性能压力,也可以提升系统的总体效率。比如关系数据库的读写分离、表字段的垂直拆分,或者引入缓存,都属于沿Y轴扩展系统。
|
||||
|
||||
Z轴扩展系统时实施成本也比较高,但它基于用户信息拆分数据后,可以在解决数据增长问题的同时,基于地理位置就近提供服务,进而大幅度降低请求的时延,比如常见的CDN就是这么提升用户体验的。但Z轴扩展系统后,一旦发生路由规则的变动导致数据迁移时,运维成本就会比较高。
|
||||
|
||||
当然,X、Y、Z轴的扩展并不是孤立的,我们可以同时应用这3个维度扩展系统。分布式系统非常复杂,AKF给我们提供了一种自上而下的方法论,让我们能够针对不同场景下的性能瓶颈,以最低的成本提升性能。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后给你留一道思考题,我们在谈到Z轴扩展时(比如关系数据库的分库分表),提到了基于哈希函数来设置路由规则,请结合[[第3讲]](https://time.geekbang.org/column/article/232351) 的内容,谈谈你认为应该如何设计哈希函数,才能使它满足符合Z轴扩展的预期?期待你的总结。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
103
极客时间专栏/系统性能调优必知必会/分布式系统优化/22 | NWR算法:如何修改读写模型以提升性能?.md
Normal file
103
极客时间专栏/系统性能调优必知必会/分布式系统优化/22 | NWR算法:如何修改读写模型以提升性能?.md
Normal file
@@ -0,0 +1,103 @@
|
||||
<audio id="audio" title="22 | NWR算法:如何修改读写模型以提升性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/da/d5/da638bf0ab1924f7014b1259334f43d5.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
前两讲我们介绍数据库的扩展时,写请求仍然在操作中心化的Master单点,这在很多业务场景下都是不可接受的。这一讲我将介绍对于无单点的去中心化系统非常有用的NWR算法,它可以灵活地平衡一致性与性能。
|
||||
|
||||
最初我们仅在单机上部署数据库,一旦性能到达瓶颈,我们可以基于AKF Y轴将读写分离,这样多个Slave从库将读操作分流后,写操作就可以独享Master主库的全部性能。然而主库作为中心化的单点,一旦宕机,未及时同步到从库的数据就有可能丢失。而且,这一架构下,主库的故障还会导致整个系统瘫痪。
|
||||
|
||||
去中心化系统中没有“Master主库”这一概念,数据存放在多个Replication冗余节点上,且这些节点间地位均等,所以没有单点问题。为了保持强一致性,系统可以要求修改数据时,必须同时写入所有冗余节点,才能向客户端返回成功。但这样系统的可用性一定很成问题,毕竟大规模分布式系统中,出现故障是常态,写入全部节点的操作根本无法容错,任何1个节点宕机都会造成写操作失败。而且,同步节点过多也会导致写操作性能低下。
|
||||
|
||||
NWR算法提供了一个很棒的读写模型,可以解决上述问题。这里的“NWR”,是指在去中心化系统中将1份数据存放在N个节点上,每次操作时,写W个节点、读R个节点,只要调整W、R与N的关系,就能动态地平衡一致性与性能。NWR在NoSQL数据库中有很广泛的应用,比如Amazon的Dynamo和开源的Cassandra,这些数据库往往跨越多个IDC数据中心,包含成千上万个物理机节点,适用于海量数据的存储与处理。
|
||||
|
||||
这一讲,我们将介绍NWR算法的原理,包括它是怎样调整读写模型来提升性能的,以及Cassandra数据库是如何使用NWR算法的。
|
||||
|
||||
## 从鸽巢原理到NWR算法
|
||||
|
||||
NWR算法是由鸽巢原理得来的:如果10只鸽子放入9个鸽巢,那么有1个鸽巢内至少有2只鸽子,这就是鸽巢原理,如下图所示:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/83/17/835a454f1ecb8d6edb5a1c2059082d17.jpg" alt="" title="图片来源:https://zh.wikipedia.org/wiki/%E9%B4%BF%E5%B7%A2%E5%8E%9F%E7%90%86">](https://zh.wikipedia.org/wiki/%E9%B4%BF%E5%B7%A2%E5%8E%9F%E7%90%86)
|
||||
|
||||
你可以用反证法证明它。鸽巢原理虽然简单,但它有许多很有用的推论。比如[[第3课]](https://time.geekbang.org/column/article/232351) 介绍了很多解决哈希表冲突的方案,那么,哈希表有没有可能完全不出现冲突呢?**鸽巢原理告诉我们,只要哈希函数输入主键的值范围大于输出索引,出现冲突的概率就一定大于0;只要存放元素的数量超过哈希桶的数量,就必然会发生冲突。**
|
||||
|
||||
基于鸽巢原理,David K. Gifford在1979年首次提出了[Quorum](https://en.wikipedia.org/wiki/Quorum_(distributed_computing)) 算法(参见《[Weighted Voting for Replicated Data](https://dl.acm.org/doi/epdf/10.1145/800215.806583)》论文),解决去中心化系统冗余数据的一致性问题。而Quorum算法提出,如果冗余数据存放在N个节点上,且每次写操作成功写入W个节点(其他N - W个节点将异步地同步数据),而读操作则从R个节点中选择并读出正确的数据,只要确保W + R > N,同1条数据的读、写操作就不能并发执行,这样客户端就总能读到最新写入的数据。特别是当W > N/2时,同1条数据的修改必然是顺序执行的。这样,分布式系统就具备了强一致性,这也是NWR算法的由来。
|
||||
|
||||
比如,若N为3,那么设置W和R为2时,在保障系统强一致性的同时,还允许3个节点中1个节点宕机后,系统仍然可以提供读、写服务,这样的系统具备了很高的可用性。当然,R和W的数值并不需要一致,如何调整它们,取决于读、写请求数量的比例。比如当N为5时,如果系统读多写少时,可以将W设为4,而R设为2,这样读操作的性能会更好。
|
||||
|
||||
NWR算法最早应用在Amazon推出的[Dynamo](https://en.wikipedia.org/wiki/Dynamo_(storage_system)) 数据库中,你可以参见2007年Amazon发表的[《Dynamo: Amazon’s Highly Available Key-value Store》](https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf)论文。2008年Dynamo的作者Avinash Lakshman跳槽到FaceBook,开发了Dynamo的开源版数据库[Cassandra](https://zh.wikipedia.org/wiki/Cassandra),它是目前最流行的NoSQL数据库之一,在Apple、Netflix、360等公司得到了广泛的应用。想必你对NWR算法的很多细节并不清楚,那么接下来我们以Cassandra为例,看看NWR是如何应用在实际工程中的。
|
||||
|
||||
## Cassandra数据库是如何使用NWR算法的?
|
||||
|
||||
1个Cassandra分布式系统可以由多个IDC数据中心、数万个服务器节点构成,这些节点间使用RPC框架通信,由于Cassandra推出时gRPC(参见[[第18课]](https://time.geekbang.org/column/article/247812))还没有诞生,因此它使用的是性能相对较低的Thrift RPC框架(Thrift的优点是支持的开发语言更多)。同时,Cassandra虽然使用宽列存储模型(每行最多可以包含[20亿列](https://docs.datastax.com/en/cql-oss/3.x/cql/cql_reference/refLimits.html)数据),但**数据的分布是基于行Key进行的**,它和Dynamo一样使用了一致性哈希算法,将Key对应的数据存储在多个节点中。关于一致性哈希算法,我们会在 [第24课] 再详细介绍。
|
||||
|
||||
Cassandra对客户端提供一种类SQL的[CQL](https://cassandra.apache.org/doc/latest/cql/index.html) 语言,你可以使用下面这行CQL语句设定数据存储的冗余节点个数,也就是NWR算法中的N(也称为Replication Factor):
|
||||
|
||||
```
|
||||
CREATE KEYSPACE excalibur
|
||||
WITH REPLICATION = {'class' : 'NetworkTopologyStrategy', 'dc1' : 3};
|
||||
|
||||
```
|
||||
|
||||
上面这行CQL语句设置了每行数据在数据中心DC1中存储3份冗余,即N = 3,接下来我们通过下面的CQL语句,将读R、写W的节点数都设置为1:
|
||||
|
||||
```
|
||||
cqlsh> CONSISTENCY ONE
|
||||
Consistency level set to ONE.
|
||||
cqlsh> CONSISTENCY
|
||||
Current consistency level is ONE.
|
||||
|
||||
```
|
||||
|
||||
**此时,Cassandra的性能最高,但达成最终一致性的耗时最长,丢数据风险也最大。**如果业务上对丢失少量数据不太在意,可以采用这种模型。此时修改数据时,客户端会并发地向3个存储节点写入数据,但只要1个节点返回成功,Cassandra就会向客户端返回写入成功,如下图所示:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/74/1d/742a430b92bb3b235294805b7073991d.png" alt="" title="该图片及以下图片来源:https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/dml">](https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/dml)
|
||||
|
||||
上图中的系统由12个主机节点构成,由于数据采用一致性哈希算法分片,故构成了一个节点环。其中,本次写入的数据被分布到1、3、6这3个节点中存储。客户端可以随机连接到系统中的任何一个节点访问Cassandra,此时该节点被称为Coordinator Node,由它根据NWR的值来选择一致性模型,访问存储节点。
|
||||
|
||||
再来看读取数据的流程。下图中,作为Coordinator Node的节点10首先试图读取节点1中的数据,但发现节点1已经宕机,于是改选节点6并获取到数据,由于R = 1于是立刻向客户端返回成功。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/d6/5038a63ce8a5cd23fcb6ba2e14b59cd6.jpg" alt="">
|
||||
|
||||
如果我们将R、W都设置成2,这就满足了R + W > N(3)的场景,此时系统具备了强一致性。客户端读写数据时,必须有2个节点返回,才算操作成功。比如下图中读取数据时,只有接收到节点1、节点6的返回,操作才算成功。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/0f/8dc00f0a82676cb54d21880e7b60c20f.jpg" alt="">
|
||||
|
||||
上图中的蓝色线叫做Read repair,如果节点3上的数据不一致,那么本次读操作可以将它修复为正确的数据。说完正常场景,我们再来看当一个节点出现异常时,NWR是如何保持强一致性的。
|
||||
|
||||
下图中,客户端1在第2步,同时向3个存储节点写入了数据,由于节点1、3返回成功,所以写入操作实际已经完成了,但是节点6由于网络故障,却一直没有收到Coordinator Node发来的写入操作。在强一致性的约束下,客户端2在第5步发起的读请求,必须能获取到第2步写入的数据。然而,客户端2连接的Coordinator Node与客户端1不同,它选择了节点3和节点6,这两个节点上的数据并不一致。**根据不同的timestamp时间戳,Coordinator Node发现节点3上的数据才是最后写入的数据,因此选择其上的数据返回客户端。这也叫Last-Write-Win策略。**
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/4b/fa/4bc3308298395b7a57d9d540a79aa7fa.jpg" alt="" title="图片来源:https://blog.scottlogic.com/2017/10/06/cassandra-eventual-consistency.html">](https://blog.scottlogic.com/2017/10/06/cassandra-eventual-consistency.html)
|
||||
|
||||
Cassandra提供了一个简单的方法,用于设置读写节点数量都过半,满足强一致性的要求,如下所示:
|
||||
|
||||
```
|
||||
cqlsh> CONSISTENCY QUORUM
|
||||
Consistency level set to QUORUM.
|
||||
cqlsh> CONSISTENCY
|
||||
Current consistency level is QUORUM.
|
||||
|
||||
```
|
||||
|
||||
最后我们再来看看多数据中心的部署方式。下图中2个数据中心各设置N = 3,其中R、W则采用QUORUM一致性模型。当客户端发起写请求到达节点10这个Coordinator Node后,它选择本IDC Alpha的1、3、6节点存储数据,其中节点3、6返回成功后,IDC Alpha便更新成功。同时找到另一IDC Beta的节点11存储数据,并由节点11将数据同步给节点4和节点8。其中,只要节点4返回成功,IDC Beta也就成功更新了数据,此时Coordinator Node会向客户端返回写入成功。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/00/5fe2fa80bb20e04d25c41ed5986c0c00.jpg" alt="">
|
||||
|
||||
读取数据时,这2个IDC内必须由4个存储节点返回数据,才满足QUORUM一致性的要求。下图中,Coordinator Node获取到了IDC Alpha中节点1、3、6的返回,以及IDC Beta中节点11的返回,就可以基于timestamp时间戳选择最新的数据返回客户端。而且Coordinator Node会并发地发起Read repair,试图修复IDC Beta中可能存在不一致的节点4和8。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/19/59564438445fb26d2e8993a50a23df19.jpg" alt="">
|
||||
|
||||
Cassandra还有许多一致性模型,比如LOCAL_QUORUM只要求本地IDC内有多数节点响应即可,而EACH_QUORUM则要求每个IDC内都必须有多数节点返回成功(注意,这与上图中IDC Alpha中有3个节点返回,而IDC Beta则只有1个节点返回的QUORUM是不同的)。你可以从[这个页面](https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/dml/dmlConfigConsistency.html)找到Cassandra支持的所有一致性模型,但无论如何变化,都只是在引入数据中心、机架等概念后,局部性地调节NWR而已。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们介绍了鸽巢原理,以及由此推导出的NWR算法,并以流行的NoSQL数据库Cassandra为例,介绍了NWR在分布式系统中的实践。
|
||||
|
||||
当鸽子的数量超过了鸽巢后,就要注定某一个鸽巢内一定含有两只以上的鸽子,同样的道理,只要读、写操作涉及的节点超过半数,就注定读写操作总包含一个含有正确数据的节点。NWR算法将这一原理一般化为:只要读节点数R + 写节点数W > 存储节点数N,特别是W > N/2时,就能使去中心的分布式系统获得强一致性。
|
||||
|
||||
支持上万节点的Cassandra数据库,就使用了NWR算法来保持一致性。当然,Cassandra支持多种一致性模型,当你需要更强劲的性能时,你可以令R + W < N,当业务变化导致需要增强系统的一致性时,你可以实时地修改R、W。Cassandra也支持跨数据中心部署,此时的一致性模型更为复杂,但仍然将NWR算法作为实现基础。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后给你留一道讨论题。你还知道哪些有状态服务使用了NWR算法吗?它与NWR在Cassandra中的应用有何不同?欢迎你在留言区中分享,也期待你能从大家的留言中总结出更一般化的规律。
|
||||
|
||||
感谢你的阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
150
极客时间专栏/系统性能调优必知必会/分布式系统优化/23 | 负载均衡:选择Nginx还是OpenResty?.md
Normal file
150
极客时间专栏/系统性能调优必知必会/分布式系统优化/23 | 负载均衡:选择Nginx还是OpenResty?.md
Normal file
@@ -0,0 +1,150 @@
|
||||
<audio id="audio" title="23 | 负载均衡:选择Nginx还是OpenResty?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dc/60/dce2221dc30eeaabba4852a18b7f5960.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
在[[第21讲]](https://time.geekbang.org/column/article/252741) 介绍AKF立方体时,我们讲过只有在下游添加负载均衡后,才能沿着X、Y、Z三个轴提升性能。这一讲,我们将介绍最流行的负载均衡Nginx、OpenResty,看看它们是如何支持AKF扩展体系的。
|
||||
|
||||
负载均衡通过将流量分发给新增的服务器,提升了系统的性能。因此,我们对负载均衡最基本的要求,就是它的吞吐量要远大于上游的应用服务器,否则扩展能力会极为有限。因此,目前性能最好的Nginx,以及在Nginx之上构建的OpenResty,通常是第一选择。
|
||||
|
||||
系统接入层的负载均衡,常通过Waf防火墙承担着网络安全职责,系统内部的负载均衡则通过权限、流量控制等功能承担着API网关的职责,CDN等边缘节点上的负载均衡还会承担边缘计算的任务。如果负载均衡不具备高度开放的设计,或者推出时间短、社区不活跃,**我们就无法像搭积木一样,从整个生态中低成本地构建出符合需求的负载均衡。**
|
||||
|
||||
很幸运的是,Nginx完全符合上述要求,它性能一流而且非常稳定。从2004年诞生之初,Nginx的模块化设计就未改变过,这样16年来累积下的各种Nginx模块都可以复用。它的[2-clause BSD-like license](https://opensource.org/licenses/BSD-2-Clause) 源码许可协议极其开放,即使修改源码后仍然可作商业用途,因此Nginx之上延伸出了TEngine、OpenResty、Kong等生态,这大大扩展了Nginx的能力边界。
|
||||
|
||||
接下来,我们就以Nginx以及建立了Lua语言生态的OpenResty为例,看看负载均衡是怎样扩展系统的,以及Nginx和同源的OpenResty有何不同。
|
||||
|
||||
## 负载均衡是如何扩展系统提升性能的?
|
||||
|
||||
通过AKF立方体X轴扩展系统时,负载均衡只需要能够透传协议,并选择负载最低的上游应用作为流量分发对象即可。这样,三层(网络层)、四层(传输层)负载均衡都可用于扩展系统,甚至在单个局域网内你还可以使用二层(数据链路层)负载均衡。其中,分发流量的路由算法既可以使用RoundRobin轮转算法,也可以基于TCP连接或者UDP Session使用最少连接数算法,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/yd/a614d25af06a6439874a12d7748afyyd.jpg" alt="">
|
||||
|
||||
然而,基于AKF Y轴扩展系统时,负载均衡必须根据功能来分发请求,也就是说它必须解析完应用层协议,才能明白这是什么请求。因此,如LVS这样工作在三层和四层的负载均衡就无法满足需求了,我们需要Nginx这样的七层(应用层)负载均衡,它能够从请求中获取到描述功能的关键信息,并以此为依据路由请求。比如当HTTP请求中的URL描述功能时,Nginx就可以用location匹配URL,再基于location来路由请求,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/73/198619be633e7db39e6c8c817078b673.jpg" alt="">
|
||||
|
||||
基于AKF Z轴扩展时,如果只是使用了网络报文中的源IP地址,那么三层、四层负载均衡都能胜任。然而如果需要帐号、访问对象等用户信息扩展系统,仍然只能使用七层负载均衡从请求中获得。比如,Nginx可以通过$变量获取到URL参数或者HEADER头部的值,再以此作为路由算法的输入参数。
|
||||
|
||||
因此,**七层负载均衡是分布式系统提升性能的必备工具。**除了基于各种路由策略分发流量,提高性能及可用性(如宕机迁移)外,负载均衡还需要完成上、下游协议间的适配、转换。例如考虑到信息安全,跑在公网上的外部协议常基于TLS/SSL协议,而在效率优先的企业内网中,一般不会使用大幅降低性能的TLS协议,因此负载均衡需要拥有卸载或者装载TLS层的能力。
|
||||
|
||||
再比如,下游客户端多样且难以保持一致(比如IE6这个古董浏览器仍然存在于当下的互联网中),因此常使用HTTP协议与服务器通讯,而上游组件则依据开发团队或者系统架构的特点,会选择CGI、uWSGI、gRPC等协议,这样负载均衡还得拥有转换各种协议的功能。Nginx可以通过反向代理模块,轻松适配各类协议,如下所示(通过stream模块,Nginx也支持四层负载均衡):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ey/93/eyy0dfeeb4783de585789f1c5b768393.jpg" alt="">
|
||||
|
||||
从性能角度,Nginx支持C10M级别的并发连接,其原因你可以参考本专栏第1、2部分的内容。从功能角度,良好的模块化设计,使得Nginx可以完成各类协议的适配,不只包括第3部分课程介绍过的通用协议,甚至支持Redis、MySQL等专有协议。因此,Nginx是目前最好用的负载均衡。
|
||||
|
||||
## Nginx上为什么可以执行Lua代码?
|
||||
|
||||
OpenResty也非常流行,其实它就是Nginx,只是通过扩展的C模块支持在Nginx中嵌入Lua语言,这样Lua模块构建出的生态就可以与C模块协作使用,大幅度提升了开发效率。我们先来看下OpenResty与Nginx之间的关系。
|
||||
|
||||
OpenResty源代码由**官方Nginx、第三方C模块、Lua语言模块以及一些工具脚本**构成。编译Nginx时,OpenResty将自己的第三方C模块按照Nginx的规则添加到可执行文件中,包括ngx_http_lua_module和ngx_stream_lua_module这两个C模块,它们允许Lua语言运行在Nginx进程中,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/8e/a73f1f14bec13dddd497aeb4a5393b8e.jpg" alt="">
|
||||
|
||||
**Lua模块既能够享受Nginx的高性能,又通过“协程”**(参见[[第5讲]](https://time.geekbang.org/column/article/233629))**、Lua脚本语言提高了开发效率**,这也是OpenResty最大的优点。我们先来看看Lua语言是怎么嵌入到Nginx中的。
|
||||
|
||||
**Nginx在进程启动、处理请求时提供了许多钩子函数,允许第三方C模块将其代码放在这些钩子函数中执行。同时,Nginx还允许C模块自行解析nginx.conf中出现的配置项。**这种架构允许OpenResty将Lua代码写进nginx.conf文件,再基于[LuaJIT](https://luajit.org/) 即时编译到Nginx中执行。
|
||||
|
||||
ngx_http_lua_module模块也正是通过OpenResty提供的以下11个指令嵌入Lua代码的:
|
||||
|
||||
- 在Nginx启动时嵌入Lua代码,包括master进程启动时执行的**init_by_lua**指令,以及每个worker进程启动时执行的**init_worker_by_lua**指令。
|
||||
- 在重写URL、访问权限控制等预处理阶段嵌入Lua代码,包括解析TLS协议后的**ssl_certificate_by_lua**指令(基于openssl的回调函数实现),设置动态变量的**set_by_lua**指令,重写URL阶段的**rewrite_by_lua**指令,以及控制访问权限的**access_by_lua**指令。
|
||||
- 生成HTTP响应时嵌入Lua代码,包括直接生成响应的**content_by_lua**指令,连接上游服务前的**balancer_by_lua**指令,处理响应头部的**header_filter_by_lua**指令,以及处理响应包体的**body_filter_by_lua**指令。
|
||||
- 记录access.log日志时嵌入Lua代码,通过**log_by_lua**指令实现。
|
||||
|
||||
如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/97/c3/9747cc2830cb65513ea0f4e5603b9fc3.jpg" alt="">
|
||||
|
||||
ngx_stream_lua_module模块与之类似,这里不再赘述。
|
||||
|
||||
当然,如果Lua代码只是可以在Nginx进程中执行,它是无法处理用户请求的。我们还需要让Lua代码与Nginx中的C代码互相调用,去获取、改变HTTP请求、响应的内容。因此,ngx_http_lua_module和ngx_stream_lua_module这两个模块通过[FFI](https://luajit.org/ext_ffi.html) 技术,将C函数通过Ngx库中的Lua API,暴露给纯Lua代码,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/58/b30b937e7866b9e588ce3e0427b1b758.jpg" alt="">
|
||||
|
||||
这样,通过nginx.conf文件中的11个指令,以及FFI技术提供的SDK,Lua代码才真正可以处理请求,Lua生态从这里开始延伸,因此,OpenResty上还提供了进一步提高开发效率的Lua模块库(参见/usr/local/openresty/lualib/目录)。关于FFI技术及OpenResty提供的HTTP SDK,你可以参考 [《Nginx核心知识100讲》中的第147-154课](https://time.geekbang.org/course/intro/100020301)。
|
||||
|
||||
## Nginx与OpenResty的差别在哪里?
|
||||
|
||||
清楚了OpenResty与Nginx间的相同之处,我们再来看,二者默认编译出的Nginx可执行程序有何不同之处。
|
||||
|
||||
首先看版本差异。当你在[官网](http://nginx.org/en/download.html)下载Nginx时,会发现有3类版本:Mainline、Stable和Legacy。其中,Mainline是单号版本,**它是含有最新功能的主线版本,迭代速度最快。Stable是mainline版本稳定运行一段时间后,将单号大版本转换为双号的稳定版本**,比如1.18.0就是由1.17.10转换而来。Legacy则是曾经的稳定版本,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/59/63yy17990a31578a4e3ba00af7b67859.jpg" alt="">
|
||||
|
||||
你可以通过源代码中的CHANGES文件,通过4种不同类型的变更查看版本间的差异,包括:
|
||||
|
||||
- 表示新功能的**Feature**,比如下图中HTTP服务新增的auth_delay指令。
|
||||
- 表示已修复问题的**Bugfix**。
|
||||
- 表示已知特性变更的**Change**,比如Nginx曾经允许HTTP请求头部中出现多个Host头部,但在1.17.9这个Change之后,这类HTTP请求将作为非法请求处理。
|
||||
- 表示安全升级的**Security**,比如1.15.6版本就修复了CVE-2018-16843等3个安全问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/7e/c366e0f928dfc149da5a4df8cb15c27e.jpg" alt="">
|
||||
|
||||
当你安装好了OpenResty或者Nginx后,你可以通过nginx -v命令查看它们的版本。你会发现,**2014年以后发布的OpenResty都是运行在单号Mainline版本上的:**
|
||||
|
||||
```
|
||||
# /usr/local/nginx/sbin/nginx -v
|
||||
nginx version: nginx/1.18.0
|
||||
# /usr/local/openresty/nginx/sbin/nginx -v
|
||||
nginx version: openresty/1.15.8.3
|
||||
|
||||
```
|
||||
|
||||
通常我们会选择稳定的Stable版本,OpenResty为什么会选择单号的Mainline版本呢?这是因为,**Nginx与OpenResty的版本发布频率完全不同**,2012年后Nginx每年大约发布10多个版本,如下图所示(versions统计了每年发布的版本数,图中其他3条拆线统计了每年feature、bugfix、change的变更次数):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bd/68/bd39d1fd2abbcf1787a34aeb008fa368.jpg" alt="">
|
||||
|
||||
OpenResty每年的版本更新频率是个位数,特别是从2018年到现在,OpenResty只发布了4个版本。所以**为了使用尽量运行在最新版本上,OpenResty选择了Mainline单号版本。**
|
||||
|
||||
你可能会想,OpenResty并没有修改Nginx的源代码,为什么不能由用户在官方Nginx上自行添加C模块,实现OpenResty的安装呢?这源于部分OpenResty C模块,没有按照Nginx架构指定的顺序添加到模块列表中,而且它们的编译环境也过于复杂。因此,OpenResty放弃了Nginx官方的configure文件,用户需要使用OpenResty改造过的configure脚本编译Nginx。
|
||||
|
||||
再来看模块间的差异。如果你留意OpenResty与Nginx间二进制文件的体积,会发现使用默认配置时,**OpenResty的可执行文件大了5倍,**如下所示:
|
||||
|
||||
```
|
||||
# ls -s --block-size=1 /usr/local/openresty/nginx/sbin/nginx
|
||||
16437248 /usr/local/openresty/nginx/sbin/nginx
|
||||
# ls -s --block-size=1 /usr/local/nginx/sbin/nginx
|
||||
3851568 /usr/local/openresty/nginx/sbin/nginx
|
||||
|
||||
```
|
||||
|
||||
这由2个原因所致。
|
||||
|
||||
首先,官方Nginx提供的四层负载均衡功能(由18个STREAM模块实现)、TLS协议处理功能,默认都是不添加到Nginx中的,而OpenResty的configure脚本将其改为了默认模块。当然,如果你在编译官方Nginx时,加入以下选项:
|
||||
|
||||
```
|
||||
./configure --with-stream --with-stream_ssl_module --with-stream_ssl_preread_module --with-http_ssl_module
|
||||
|
||||
```
|
||||
|
||||
那么从官方模块上,Nginx就与OpenResty完全一致了,此时再观察二进制文件的体积,会发现它翻了一倍:
|
||||
|
||||
```
|
||||
# ls -s --block-size=1 /usr/local/openresty/nginx/sbin/nginx
|
||||
6999144 /usr/local/openresty/nginx/sbin/nginx
|
||||
|
||||
```
|
||||
|
||||
其次,OpenResty添加了近20个第三方C模块,除了前文介绍过支持Lua语言的2个模块外,还有支持Redis、Memcached、MySQL等服务的模块。这些模块编译时,还需要链接依赖的软件库,因此它们又将Nginx可执行文件的体积增加了1倍多。
|
||||
|
||||
除版本、模块外,OpenResty与Nginx间还有一些小的差异,比如Nginx使用了GCC编译器的-O1优化参数,而OpenResty则使用了-O2优化参数。再比如,官方Nginx最新版本的Makefile支持upgrade参数,简化了热升级操作。当然,这些小改动并不重要,只要修改configure脚本就能做到。
|
||||
|
||||
我们到底该如何在二者中选择呢?我认为,如果不使用Lua语言,那么我建议使用Nginx。官方Nginx的Stable版本更稳定,可执行文件的体积也更小。**如果你需要使用OpenResty、TEngine中的部分C模块,可以通过–add-module选项将其加入到官方Nginx中。**
|
||||
|
||||
如果所有C模块都无法满足业务需求,你就应该选择OpenResty。注意,Lua语言给你带来极大灵活性的同时,也会引入许多不确定性。比如,如果你调用了会导致进程休眠的Lua阻塞函数(比如封装了系统调用的原生Lua库,或者第三方服务提供的同步SDK),将会导致Nginx正在处理数万并发请求的C模块同时进入休眠,从而让服务的性能大幅度下降。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲,我们介绍了负载均衡的工作方式,以及Nginx、OpenResty这两个最流行的负载均衡之间的异同。
|
||||
|
||||
任何负载均衡都能从AKF X轴水平扩展系统,但只有能够解析应用层协议,获取到请求的功能、用户身份、访问对象等信息,才能够沿AKF Y轴、Z轴全方位扩展系统。因此,七层负载均衡是分布式系统提升性能的利器。
|
||||
|
||||
Nginx的开放式架构允许第三方模块通过10多个钩子函数,在不同的生命周期中处理请求。同时,还允许C模块自行解析nginx.conf配置文件。这样,OpenResty就通过2个C模块,将Lua代码用LuaJIT编译到Nginx中执行,并通过FFI技术将C函数暴露给Lua代码。这就是OpenResty允许Lua语言与C模块协同处理请求的原因。
|
||||
|
||||
OpenResty虽然就是Nginx,但由于版本发布频率低于官方Nginx,因此使用了单号的Mainline版本以获得Nginx的最新特性。由于OpenResty默认加入了四层负载均衡和TLS协议处理功能,还新增了近20个第三方C模块,这造成它编译出的Nginx体积大了5倍。如果无须使用Lua语言就能够满足业务需求,我推荐你使用Nginx。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一道思考题。Nginx、OpenResty都有着丰富、活跃的生态,这也是我们选择开源软件的一个前提。这节课我们提到开放的设计是形成生态的一个必要因素,然而这二者都有许多没有形成生态的竞争对手,你认为开源软件构建出庞大的生态,还需要哪些充分条件呢?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你对负载均衡有了更深的了解,搞清楚了OpenResty与Nginx间的差别,也欢迎把它分享给你的朋友。
|
||||
92
极客时间专栏/系统性能调优必知必会/分布式系统优化/24 | 一致性哈希:如何高效地均衡负载?.md
Normal file
92
极客时间专栏/系统性能调优必知必会/分布式系统优化/24 | 一致性哈希:如何高效地均衡负载?.md
Normal file
@@ -0,0 +1,92 @@
|
||||
<audio id="audio" title="24 | 一致性哈希:如何高效地均衡负载?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/52/26/5273de634444c7426365df786d67ef26.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
还记得我们在[[第22讲]](https://time.geekbang.org/column/article/254600) 谈到的Cassandra数据库吗?它将服务器节点组成一个环来存储数据,所使用的就是一致性哈希算法。那这一讲,我们就来看看一致性哈希算法是怎样工作的。
|
||||
|
||||
使用哈希算法扩展系统时,最大的问题在于代表哈希桶的服务器节点数发生变化时,哈希函数就改变了,数据与节点间的映射关系自然发生了变化,结果大量数据就得在服务器间迁移。特别是含有多份冗余数据的系统,迁移工作量更是会成倍提高。
|
||||
|
||||
同时,为了在整体上更加充分地使用IT资源,我们必须解决分布式系统扩展时可能出现的两个问题:数据分布不均衡和访问量不均衡。比如,对于包含10个服务器节点、持久化1亿条用户数据的有状态服务,如果采用关键字模10(key%10)的哈希算法作为路由策略,就很难保证每个节点处理1千万条数据,那些可能还未达到一半设计容量的节点会浪费大量磁盘空间。
|
||||
|
||||
即使节点间存储的数据非常均匀,但这些数据间的活跃程度也不相同,存放热点数据较多的节点访问量非常大,很容易率先达到CPU瓶颈,在许多主机节点还很空闲时,我们就得扩容系统。
|
||||
|
||||
特别是我们很难保证集群内的所有节点都是同构的,如果哈希算法不能区别对待不同配置的服务器,也会抬高IT成本。
|
||||
|
||||
一致性哈希算法可以解决上述问题,它在许多流行的开源软件上都有很广泛的应用。这一讲我们将介绍一致性哈希算法的工作原理,以及如何通过虚拟节点提升算法的均衡性。
|
||||
|
||||
## 如何减少扩容、缩容时迁移的数据量?
|
||||
|
||||
在主机硬件达到性能瓶颈后,有状态服务可以沿AKF立方体Z轴(参见[[第21讲]](https://time.geekbang.org/column/article/252741)),基于哈希算法扩展为分布式系统。下图系统中拥有5个节点,哈希算法将每条数据的关键字模5得出的数字作为哈希桶序号,从而将数据映射到节点上(如果关键字是字符串或者其他结构化数据,可以先通过其他哈希算法转换为整数,再进行模运算):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/d7/9bd05076f931e8fe4b871cd88942abd7.jpg" alt="">
|
||||
|
||||
这个方案实现简单,运算速度也快,但它最大的问题是在系统扩容或者缩容时,必须迁移改变了映射关系的数据。然而,取模哈希函数中基数的变化,往往会导致绝大部分映射关系改变,比如上例中的5个关键字,在下图中集群节点数(即基数)从5降为4时,原映射关系全部失效,这5条数据都得迁移到其他节点:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/58/d2/58c7eyye48db0d85c5eb3563aa04d4d2.jpg" alt="">
|
||||
|
||||
1997年发布的《[Consistent Hashing and Random Trees](https://www.akamai.com/us/en/multimedia/documents/technical-publication/consistent-hashing-and-random-trees-distributed-caching-protocols-for-relieving-hot-spots-on-the-world-wide-web-technical-publication.pdf)》论文提出了[一致性哈希](https://zh.wikipedia.org/wiki/%E4%B8%80%E8%87%B4%E5%93%88%E5%B8%8C)算法,可以大幅度减少数据迁移量。一致性哈希算法是通过以下2个步骤来建立数据与主机节点间映射关系的:
|
||||
|
||||
- 首先,将关键字经由通用的哈希函数映射为32位整型哈希值。这些哈希值会形成1个环,最大的数字 ${2^{32}}$ 相当于0。
|
||||
- 其次,设集群节点数为N,将哈希环由小至大分成N段,每个主机节点处理哈希值落在该段内的数据。比如下图中,当节点数N等于3且均匀地分段时,节点0处理哈希值在 [0, $\frac{1}{3} * {2^{32}}$] 范围内的关键字,节点1处理 [$\frac{1}{3} * {2^{32}}$, $\frac{2}{3} * {2^{32}}$] 范围内的关键字,而节点2则处理的范围是 [$\frac{2}{3} * {2^{32}}$, ${2^{32}}$]:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/91/d7864bfc037382a3f391e3f2d0492b91.jpg" alt="">
|
||||
|
||||
**当然,在生产环境中主机节点很可能是异构的,所以我们要给高规格的服务器节点赋予更高的权重。一致性哈希算法改变节点的权重非常简单,只需要给每个节点分配更大的弧长即可。**例如,如果上图中的节点0拥有更高的硬件配置,那么可以将原本均匀分布的3个节点调整为2:1:1的权重,这样节点0处理的哈希值范围调整为 [0, ${2^{31}}$],节点1的处理范围调整为 [${2^{31}}$, ${3} * {2^{30}}$],节点2的处理范围调整为 [${3} * {2^{30}}$, ${2^{32}}$],如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/0d/0c4dac0ea1c9484e639a9830d508350d.jpg" alt="">
|
||||
|
||||
而扩容、缩容时,虽然节点数发生了变化,但只要小幅度调整环上各节点的位置,就不会导致大量数据的迁移。比如下图中我们将3个节点的集群扩容为4个节点,只需要将节点0上一半的数据迁移至节点3即可,其他节点不受影响:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/e7/e0c34abb43bfb8c7680683fa48f3e7e7.jpg" alt="">
|
||||
|
||||
接下来我们从成本上分析下一致性哈希算法的优劣。假设总数据条数为M,而节点个数为N,先来看映射函数的时间复杂度。传统的哈希算法以N为基数执行取模运算,时间复杂度为O(1)(参见[[第3讲]](https://time.geekbang.org/column/article/232351));一致性哈希算法需要将关键字先转换为32位整型(这1步的时间复杂度也是O(1)),再根据哈希环中各节点的处理范围,找到所属的节点。**由于所有节点是有序排列的,所以采用二分法,可以在O(logN)时间复杂度内,完成关键字到节点位置的映射。**
|
||||
|
||||
再来评估下数据的迁移规模。节点变化会导致传统哈希算法的映射结果不可控,最坏情况下所有数据都需要迁移,所以它的数据迁移规模是O(M);对于一致性哈希算法,我们可以通过调整节点位置,任意设定迁移规模。**在环中各节点均匀分布的情况下,数据迁移规模是O(M/N)。**
|
||||
|
||||
因此,一致性哈希算法的缺点是将映射函数的时间复杂度从O(1)提高到了O(logN),它的优点是将数据迁移规模从O(M)降低至O(M/N)。**由于数据条数M远大于主机节点数N,而且数据迁移的成本很大,所以一致性哈希算法更划算,它的适用场景也更广!**
|
||||
|
||||
## 如何通过虚拟节点提高均衡度?
|
||||
|
||||
一致性哈希算法虽然降低了数据的迁移量,但却遗留了两个问题没有解决。
|
||||
|
||||
首先,如果映射后哈希环中的数字分布不均匀,就会导致各节点处理的数据不均衡,从而降低了系统的运行效率与性能。在无法找出分布规律时,我们也无法通过调整环中节点的权重,平衡各节点处理的数据量。
|
||||
|
||||
其次,容灾与扩容时,哈希环上的相邻节点容易受到过大影响。比如下图中,当节点0宕机后,根据一致性哈希算法的规则,其上数据应该全部迁移到相邻的节点1上,这样,节点1的数据量、访问量都会迅速增加1倍,一旦新增的压力超过了节点1的处理能力上限,就会导致节点1崩溃,进而形成雪崩式的连锁反应:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/bc/2a20eb528c335345c6ca8422e1011bbc.jpg" alt="">
|
||||
|
||||
系统扩容时也面临着同样的问题,除非同时调整环中各节点的位置,否则扩容节点也只会减轻相邻节点的负载。
|
||||
|
||||
当数据存在多份冗余时,这两类问题会被进一步放大。
|
||||
|
||||
那如何提高均衡性呢?**在真实的数据节点与哈希环之间引入一个虚拟节点层,就可以解决上述问题。**例如下图中的集群含有4个节点,但我们并不直接将哈希环分为4份,而是将它均匀地分为32份并赋予32个虚拟节点,因此每个虚拟节点会处理 ${2^{27}}$ 个哈希值,再将32个虚拟节点通过某个哈希函数(比如CRC32)映射到4个真实节点上(比如图中8个绿色虚拟节点皆由同色的主机节点0处理):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/c3/3c68ea2ccccd94000a39927590b0d0c3.jpg" alt="">
|
||||
|
||||
这样,如果图中绿色的节点0宕机,按照哈希环上数据的迁移规则,8个绿色虚拟节点上的数据就会沿着顺时针方向,分别迁移至相邻的虚拟节点上,最终会迁移到真实节点1(橙色)、节点2(蓝色)、节点3(水红色)上。所以,宕机节点上的数据会迁移到其他所有节点上。
|
||||
|
||||
扩容时也是一样的,通过虚拟节点环,新增节点可以分担现有全部节点的压力。至于虚拟节点为什么可以让数据的分布更均衡,这是因为在虚拟节点与真实节点间,又增加了一层哈希映射,哈希函数会将原本不均匀的数字进一步打散。上图为了方便你理解,每个真实节点仅包含8个虚拟节点,这样能起到的均衡效果其实很有限。而在实际的工程中,虚拟节点的数量会大很多,比如Nginx的一致性哈希算法,每个权重为1的真实节点就含有[160个](http://nginx.org/en/docs/http/ngx_http_upstream_module.html#hash)虚拟节点。
|
||||
|
||||
当然,有了虚拟节点后,为异构的服务器节点设置权重也更方便。只需要为权重高的真实节点,赋予更多的虚拟节点即可。注意,**虚拟节点增多虽然会提升均衡性,但也会消耗更多的内存与计算力。**
|
||||
|
||||
上面我们仅讨论了数据分布的均衡性,当热点数据导致访问量不均衡时,因为这一新维度的信息还没有反馈在系统中,所以你需要搜集各节点的访问量信息,基于它来动态地调整真实节点的权重,进而从热点数据更多的节点中迁移出部分数据,以此提高均衡性。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们介绍了一致性哈希算法的工作原理。
|
||||
|
||||
传统哈希函数中,主机节点的变化会导致大量数据发生迁移。一致性哈希算法将32位哈希值构成环,并将它分段赋予各节点,这样,扩容、缩容动作就只影响相邻节点,大幅度减少了数据迁移量。一致性哈希算法虽然将数据的迁移量从O(M)降为O(M/N),却也将映射函数的时间复杂度从O(1)提高到O(logN),但由于节点数量N并不会很大,所以一致性哈希算法的性价比还是很高的。
|
||||
|
||||
当哈希值分布不均匀时,数据分布也不会均衡。在哈希环与真实节点间,添加虚拟节点层,可以通过新的哈希函数,分散不均匀的数据。每个真实节点含有的虚拟节点数越多,数据分布便会越均衡,但同时也会消耗更多的内存与计算力。
|
||||
|
||||
虚拟节点带来的最大优点,是宕机时由所有节点共同分担流量缺口,这避免了可能产生的雪崩效应。同时,扩容的新节点也会分流所有节点的压力,这也提升了系统整体资源的利用率。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一道思考题。提升数据分布、访问的平衡性,并不是只有一致性哈希这一个方案。比如,我们将数据与节点的映射关系放在另一个服务中持久化存储,通过反向代理或者客户端SDK,在访问数据节点前,先从元数据服务中获取到数据的映射关系,再访问对应的节点,也是可以的,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/39/fd66edb150de7ec2fd2c0a86b2639539.png" alt="">
|
||||
|
||||
你觉得上述方案与一致性哈希相比,有何优劣?各自适用的场景又是什么?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你掌握了一致性哈希算法这个新工具,并能够用它提升分布式系统的运行效率,也欢迎把今天的内容分享给你的朋友。
|
||||
148
极客时间专栏/系统性能调优必知必会/分布式系统优化/25 | 过期缓存:如何防止缓存被流量打穿?.md
Normal file
148
极客时间专栏/系统性能调优必知必会/分布式系统优化/25 | 过期缓存:如何防止缓存被流量打穿?.md
Normal file
@@ -0,0 +1,148 @@
|
||||
<audio id="audio" title="25 | 过期缓存:如何防止缓存被流量打穿?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/40/86433285f33958625ce602f778ce5b40.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
这一讲我们将对一直零散介绍的缓存做个全面的总结,同时讨论如何解决缓存被流量打穿的场景。
|
||||
|
||||
在分布式系统中,缓存无处不在。比如,浏览器会缓存用户Cookie,CDN会缓存图片,负载均衡会缓存TLS的握手信息,Redis会缓存用户的session,MySQL会缓存select查询出的行数据,HTTP/2会用动态表缓存传输过的HTTP头部,TCP Socket Buffer会缓存TCP报文,Page Cache会缓存磁盘IO,CPU会缓存主存上的数据,等等。
|
||||
|
||||
只要系统间的访问速度有较大差异,缓存就能提升性能。如果你不清楚缓存的存在,两个组件间重合的缓存就会带来不必要的复杂性,同时还增大了数据不一致引发错误的概率。比如,MySQL为避免自身缓存与Page Cache的重合,就使用直接IO绕过了磁盘高速缓存。
|
||||
|
||||
缓存提升性能的幅度,不只取决于存储介质的速度,还取决于缓存命中率。为了提高命中率,缓存会基于时间、空间两个维度更新数据。在时间上可以采用LRU、FIFO等算法淘汰数据,而在空间上则可以预读、合并连续的数据。如果只是简单地选择最流行的缓存管理算法,就很容易忽略业务特性,从而导致缓存性能的下降。
|
||||
|
||||
在分布式系统中,缓存服务会为上游应用挡住许多流量。如果只是简单的基于定时器淘汰缓存,一旦热点数据在缓存中失效,超载的流量会立刻打垮上游应用,导致系统不可用。
|
||||
|
||||
这一讲我会系统地介绍缓存及其数据变更策略,同时会以Nginx为例介绍过期缓存的用法。
|
||||
|
||||
## 缓存是最有效的性能提升工具
|
||||
|
||||
在计算机体系中,各类硬件的访问速度天差地别。比如:
|
||||
|
||||
- CPU访问缓存的耗时在10纳秒左右,访问内存的时延则翻了10倍;
|
||||
- 如果访问SSD固态磁盘,时间还要再翻个1000倍,达到100微秒;
|
||||
- 如果访问机械硬盘,对随机小IO的访问要再翻个100倍,时延接近10毫秒;
|
||||
- 如果跨越网络,访问时延更要受制于主机之间的物理距离。比如杭州到伦敦相距9200公里,ping时延接近200毫秒。当然,网络传输的可靠性低很多,一旦报文丢失,TCP还需要至少1秒钟才能完成报文重传。
|
||||
|
||||
可见,最快的CPU缓存与最慢的网络传输,有1亿倍的速度差距!一旦高速、低速硬件直接互相访问,前者就会被拖慢运行速度。因此,**我们会使用高速的存储介质创建缓冲区,通过预处理、批处理以及缓冲数据的反复命中,提升系统的整体性能。**
|
||||
|
||||
不只是硬件层面,软件设计对访问速度的影响更大。比如,对关系数据库的非索引列做条件查询,时间复杂度是O(N),而对Memcached做Key/Value查询,时间复杂度则是O(1),所以在海量数据下,两者的性能差距远高于硬件。因此,RabbitMQ、Kafka这样的消息服务也会充当高速、低速应用间的缓存。
|
||||
|
||||
如果两个实体之间的访问时延差距过大,还可以通过多级缓存,逐级降低访问速度差,提升整体性能。比如[[第1讲]](https://time.geekbang.org/column/article/230194) 我们介绍过CPU三级缓存,每级缓存越靠近CPU速度越快,容量也越小,以此缓解CPU频率与主存的速度差,提升CPU的运行效率。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/y9/986779b16684c3a250ded39066ea5yy9.png" alt="">
|
||||
|
||||
再比如下图的Web场景中,浏览器的本地缓存、操作系统内核中的TCP缓冲区(参见[[第11讲]](https://time.geekbang.org/column/article/239176))、负载均衡中的TLS握手缓存、应用服务中的HTTP响应缓存、MySQL中的查询缓存等,每一级缓存都缓解了上下游间不均衡的访问速度,通过缩短访问路径降低了请求时延,通过异步访问、批量处理提升了系统效率。当然,缓存使用了简单的Key/Value结构,因此可以用哈希表、查找树等容器做索引,这也提升了访问速度。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/96/81/96f9fcdc192de9282830f8f7a1b75a81.png" alt="">
|
||||
|
||||
从系统底层到业务高层,缓存都大有用武之地。比如在Django这个Python Web Server中,既可以使用视图缓存,将动态的HTTP响应缓存下来:
|
||||
|
||||
```
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
@cache_page(60 * 15)
|
||||
def my_view(request):
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
也可以使用[django-cachealot](https://django-cachalot.readthedocs.io/en/latest/) 这样的中间件,将所有SQL查询结果缓存起来:
|
||||
|
||||
```
|
||||
INSTALLED_APPS = [
|
||||
...
|
||||
'cachalot',
|
||||
...
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
还可以在更细的粒度上,使用Cache API中的get、set等函数,将较为耗时的运算结果存放在缓存中:
|
||||
|
||||
```
|
||||
cache.set('online_user_count', counts, 3600)
|
||||
user_count = cache.get('online_user_count')
|
||||
|
||||
```
|
||||
|
||||
这些缓存的应用场景大相径庭,但数据的更新方式却很相似,下面我们来看看缓存是基于哪些原理来更新数据的。
|
||||
|
||||
## 缓存数据的更新方式
|
||||
|
||||
缓存的存储容量往往小于原始数据集,这有许多原因,比如:
|
||||
|
||||
- 缓存使用了速度更快的存储介质,而这类硬件的单位容量更昂贵,因此从经济原因上只能选择更小的存储容量;
|
||||
- 负载均衡可以将上游服务的动态响应转换为静态缓存,从时间维度上看,上游响应是无限的,这样负载均衡的缓存容量就一定会不足;
|
||||
- 即使桌面主机的磁盘容量达到了TB级,但浏览器要对用户访问的所有站点做缓存,就不可能缓存一个站点上的全部资源,在一对多的空间维度下,缓存一样是稀缺资源。
|
||||
|
||||
因此,我们必须保证在有限的缓存空间内,只存放会被多次访问的热点数据,通过提高命中率来提升系统性能。要完成这个目标,必须精心设计向缓存中添加哪些数据,缓存溢出时淘汰出哪些冷数据。我们先来看前者。
|
||||
|
||||
通常,缓存数据的添加或者更新,都是由用户请求触发的,这往往可以带来更高的命中率。比如,当读请求完成后,将读出的内容放入缓存,基于时间局部性原理,它有很高的概率被后续的读请求命中。[[第15讲]](https://time.geekbang.org/column/article/242667) 介绍过的HTTP缓存就采用了这种机制。
|
||||
|
||||
对于磁盘操作,还可以基于空间局部性原理,采用预读算法添加缓存数据(参考[[第4讲]](https://time.geekbang.org/column/article/232676) 介绍的PageCache)。比如当我们统计出连续两次读IO的操作范围也是连续的,就可以判断这是一个顺序读IO,如果这个读IO获取32KB的数据,就可以在这次磁盘中,多读出128KB的数据放在缓存,这会带来2个收益:
|
||||
|
||||
- 首先,通过减少定位时间提高了磁盘工作效率。机械磁盘容量大价格低,它的顺序读写速度由磁盘旋转速度与存储密度决定,通常可以达到100MB/s左右。然而,由于机械转速难以提高(服务器磁盘的转速也只有10000转/s),磁头定位与旋转延迟大约消耗了8毫秒,因此对于绝大部分时间花在磁头定位上的随机小IO(比如4KB),读写吞吐量只有几MB。
|
||||
- 其次,当后续的读请求命中提前读入缓存的数据时,请求时延会大幅度降低,这提升了用户体验。
|
||||
|
||||
而且,并不是只有单机进程才能使用预读算法。比如公有云中的云磁盘,之所以可以实时地挂载到任意虚拟机上,就是因为它实际存放在类似HDFS这样的分布式文件系统中。因此,云服务会在宿主物理机的内存中缓存虚拟机发出的读写IO,由于网络传输的成本更高,所以预读效果也更好。
|
||||
|
||||
写请求也可以更新缓存,你可以参考[[第20讲]](https://time.geekbang.org/column/article/251062) 我们介绍过write through和write back方式。其中,write back采用异步调用回写数据,能通过批量处理提升性能。比如Linux在合并IO的同时,也会像电梯运行一样,每次使磁头仅向一个方向旋转写入数据,提升机械磁盘的工作效率,因此得名为电梯调度算法。
|
||||
|
||||
说完数据的添加,我们再来看2种最常见的缓存淘汰算法。
|
||||
|
||||
首先来看[FIFO](https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics))(First In, First Out)先入先出淘汰算法。[[第16讲]](https://time.geekbang.org/column/article/245966) 介绍的HTTP/2动态表,会将HTTP/2连接上首次出现的HTTP头部,缓存在客户端、服务器的内存中。由于它们基于相同的规则生成,所以拥有相同的动态表序号。这样,传输1-2个字节的表序号,要比传输几十个字节的头部划算得多。当内存容量超过SETTINGS_HEADER_TABLE_SIZE阈值时,会基于FIFO算法将最早缓存的HTTP头部淘汰出动态表。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/7b/fa/7b4ed2f6e8e0e0379a89a921b587a7fa.jpg" alt="" title="图片参见wiki:https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)">](https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics))
|
||||
|
||||
再比如[[第14讲]](https://time.geekbang.org/column/article/241632) 介绍的TLS握手很耗时,所以我们可以将密钥缓存在客户端、服务器中,等再次建立连接时,通过session ID迅速恢复TLS会话。由于内存有限,服务器必须及时淘汰过期的密钥,其中,Nginx也是采用FIFO队列淘汰TLS缓存的。
|
||||
|
||||
其次,LRU(Less Recently Used)也是最常用的淘汰算法,比如Redis服务就通过它来淘汰数据,OpenResty在进程间共享数据的shared_dict在达到共享内存最大值后,也会通过LRU算法淘汰数据。LRU通常使用双向队列实现(时间复杂度为O(1)),队首是最近访问的元素,队尾就是最少访问、即将淘汰的元素。当访问了队列中某个元素时,可以将其移动到队首。当缓存溢出需要淘汰元素时,直接删除队尾元素,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/80/21f4cfa8c5a92db8eaf358901a7f8780.png" alt="">
|
||||
|
||||
以上我只谈了缓存容量到达上限后的淘汰策略,为了避免缓存与源数据不一致,在传输成本高昂的分布式系统中,通常会基于过期时间来淘汰缓存。比如HTTP响应中的Cache-Control、Expires或者Last-Modified头部,都会用来设置定时器,响应过期后会被淘汰出缓存。然而,一旦热点数据被淘汰出缓存,那么来自用户的流量就会穿透缓存到达应用服务。由于缓存服务性能远大于应用服务,过大的流量很可能会将应用压垮。因此,过期缓存并不能简单地淘汰,下面我们以Nginx为例,看看如何利用过期缓存提升系统的可用性。
|
||||
|
||||
## Nginx是如何防止流量打穿缓存的?
|
||||
|
||||
当热点缓存淘汰后,大量的并发请求会同时回源上游应用,其实这是不必要的。比如下图中Nginx的合并回源功能开启后,Nginx会将多个并发请求合并为1条回源请求,并锁住所有的客户端请求,直到回源请求返回后,才会更新缓存,同时向所有客户端返回响应。由于Nginx可以支持C10M级别的并发连接,因此可以很轻松地锁住这些并发请求,降低应用服务的负载。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/2d/3ec9f8f4251af844770d24c756a6432d.png" alt="">
|
||||
|
||||
启用合并回源功能很简单,只需要在nginx.conf中添加下面这条指令即可:
|
||||
|
||||
```
|
||||
proxy_cache_lock on;
|
||||
|
||||
```
|
||||
|
||||
当1个请求回源更新时,其余请求将会默认等待,如果5秒(可由proxy_cache_lock_timeout修改)后缓存依旧未完成更新,这些请求也会回源,但它们的响应不会用于更新缓存。同时,第1个回源请求也有时间限制,如果到达5秒(可由proxy_cache_lock_age修改)后未获得响应,就会放行其他并发请求回源更新缓存。
|
||||
|
||||
如果Nginx上的缓存已经过期(未超过proxy_cache_path中inactive时间窗口的过期缓存,并不会被删除),且上游服务此时已不可用,那有没有办法可以通过Nginx提供降级服务呢?所谓“服务降级”,是指部分服务出现故障后,通过有策略地放弃一些可用性,来保障核心服务的运行,这也是[[第20讲]](https://time.geekbang.org/column/article/251062) BASE理论中Basically Available的实践。如果Nginx上持有着过期的缓存,那就可以通过牺牲一致性,向用户返回过期缓存,以保障基本的可用性。比如下图中,Nginx会直接将过期缓存返回给客户端,同时也会一直试图更新缓存。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/e5/d086aa5b326c21ce7609a2b7b98f86e5.png" alt="">
|
||||
|
||||
开启过期缓存功能也很简单,添加下面2行指令即可:
|
||||
|
||||
```
|
||||
proxy_cache_use_stale updating;
|
||||
proxy_cache_background_update on;
|
||||
|
||||
```
|
||||
|
||||
当然,上面两条Nginx指令只是开启了最基本的功能,如果你想进一步了解它们的用法,可以观看[《Nginx核心知识100讲》第102课](https://time.geekbang.org/course/detail/100020301-76629)。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们系统地总结了缓存的工作原理,以及Nginx解决缓存穿透问题的方案。
|
||||
|
||||
当组件间的访问速度差距很大时,直接访问会降低整体性能,在二者之间添加更快的缓存是常用的解决方案。根据时间局部性原理,将请求结果放入缓存,会有很大概率被再次命中,而根据空间局部性原理,可以将相邻的内容预取至缓存中,这样既能通过批处理提升效率,也能降低后续请求的时延。
|
||||
|
||||
由于缓存容量小于原始数据集,因此需要将命中概率较低的数据及时淘汰出去。其中最常用的淘汰算法是FIFO与LRU,它们执行的时间复杂度都是O(1),效率很高。
|
||||
|
||||
由于缓存服务的性能远大于上游应用,一旦大流量穿透失效的缓存到达上游后,就可能压垮应用。Nginx作为HTTP缓存使用时,可以打开合并回源功能,减轻上游压力。在上游应用宕机后,还可以使用过期缓存为用户提供降级服务。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一道讨论题。缓存并不总是在提高性能,回想一下,在你的实践中,有哪些情况是增加了缓存服务,但并没有提高系统性能的?原因又是什么?欢迎你在留言区与大家一起分享。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你加深了对缓存的理解,也欢迎把它分享给你的朋友。
|
||||
92
极客时间专栏/系统性能调优必知必会/分布式系统优化/26 | 应用层多播:如何快速地分发内容?.md
Normal file
92
极客时间专栏/系统性能调优必知必会/分布式系统优化/26 | 应用层多播:如何快速地分发内容?.md
Normal file
@@ -0,0 +1,92 @@
|
||||
<audio id="audio" title="26 | 应用层多播:如何快速地分发内容?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/37/3feb9595eea01aa4e439c2eec0828a37.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
[[第7讲]](https://time.geekbang.org/column/article/235302) 我们曾介绍了网络层的IP协议是如何支持多播的,这节课我们再来从应用层看看如何实现多播功能。
|
||||
|
||||
当你的分布式集群只有十多个节点时,每次发布版本时,尽可以从发布服务器,将新版本的安装包通过ftp、scp、wget等工具分发到各个节点中。可是,一旦集群规模达到成千上万个节点时,再这么做就会带来很大的问题,文件分发的时长高达几个小时,甚至会打挂文件源终止分发流程。在微服务环境中这点尤为明显, 毕竟每个Docker镜象的体积动辄就在数百兆字节以上。
|
||||
|
||||
虽然网络层的IP协议允许通过路由器、交换机实现高效的多播,但IP层很难实现文件的可靠传输,而且跨越多个局域网时路由器等网络设备对IP多播的支持也不好。此时,通过应用层的接力传播,就能通过多播思想大幅提升系统的传输效率,解决上述问题。
|
||||
|
||||
除了分发文件场景外,应用层多播协议也常用于完全去中心化的分布式系统,特别是在管理成千上万个节点的状态时非常有用。比如Gossip就是这样一个多播协议,[[第22讲]](https://time.geekbang.org/column/article/254600) 介绍过的Cassandra数据库使用它来同步节点间的状态,比特币通过它传输账本数据,Redis集群也使用它同步Redis进程间的状态。
|
||||
|
||||
那么这节课我们就重点介绍应用层中的多播协议,并以阿里的蜻蜓、Cassandra中的Gossip协议为例,看看它们的工作原理。
|
||||
|
||||
## 认识应用层的多播协议
|
||||
|
||||
之所以需要应用层的多播协议,是因为网络层的IP多播功能(参见[[第7讲]](https://time.geekbang.org/column/article/235302) 的IP广播与组播)有以下4个方面的问题:
|
||||
|
||||
- 从功能上看,IP多播缺失了质量控制、可靠性传输等特性,无法满足绝大部分场景中的要求;
|
||||
- 从管理上看,监控跨网络的多播报文并不容易,多播地址的管理也很复杂,而且多播很容易造成网络洪峰及安全问题;
|
||||
- 从市场上看,单播报文在终端上的计费很成熟,相反,运营商对多播报文的计费要困难许多,相对缺乏推进动力;
|
||||
- 从产业协作上看,IP多播必须由各设备厂商的路由器、交换机配合,由于网络层是由内核实现的,所以还要同步升级操作系统。
|
||||
|
||||
这些因素都使得网络层的多播功能难以推广,因此,各类基于IP单播功能的应用层多播协议应运而生。可能有些同学对于单播、多播这些网络知识还不熟悉,下图我将传统单播、网络层多播、应用层多播放在一起对比着看,你可以很容易看到它们的区别。
|
||||
|
||||
这里仍然以版本发布场景为例,主机1是发布节点,它需要将文件分发到其余3台主机上,其中传统单播协议的玩法,是在主机2、3、4上分别向主机1发起下载文件命令,因此主机1上有3条TCP下载链路,图中我用3种不同的颜色表示。很明显,此时主机1的下行流量一定会成为瓶颈,而且随着集群规模的扩大,网络链路中的路由器也会很快达到流量瓶颈。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/34/7a/3457fac0f6811d21357188c48fe5867a.png" alt="">
|
||||
|
||||
再来看效率最高的网络层多播,主机1可以仅通过1次报文传输,将文件分发到所有主机上。在整个流程中,仅由路由器将网络报文扩散到相邻的主机、路由器上,没有任何多余的动作。如同上述所说,网络层多播的使用有很多困难,这里列出它是为了方便与应用层多播作对比。
|
||||
|
||||
在上图的应用层多播中,文件传输分为两个阶段。首先,主机2、3直接从主机1中下载文件(参见绿色与红色线)。其次,当主机3完成下载后,主机4再从主机3上下载文件(参见蓝色线)。可见,相对于IP多播,**应用层多播有以下3个缺点:**
|
||||
|
||||
首先网络效率下降了不少,这由2个原因所致:
|
||||
|
||||
- 数据在网络中有冗余,比如主机1将数据重复发送了2次(参见红色、绿色线),主机3既接收了一次数据,也发送了一次数据;
|
||||
- 虽然图中主机4从同一局域网中的主机3下载数据,但在复杂的互联网环境中,应用层很难掌握完整的路由器组网信息,主机4一旦跨网络从主机2上下载数据,这就增加了路由器A及网络链路的负载,效率进一步下降。
|
||||
|
||||
其次,由于传输路径变长了,文件传输的总完成时间也变长了。比如,主机4的总传输路径是:主机1 -> 路由器A -> 路由器B -> 主机3 -> 路由器B -> 主机4,既多出了主机3、路由器B这两个传输环节,而且进入主机3后,协议栈的处理深度也增加了。
|
||||
|
||||
最后,单次传输路径中引入了功能复杂的主机,相比仅由网络设备参与的IP多播,可靠性、稳定性也降低了。
|
||||
|
||||
说完缺点,我们再来看应用层多播的优点。
|
||||
|
||||
- 首先,它回避了IP多播的问题,无须改变现有组网环境,也不需要管理组播IP地址,立刻就可以应用在当下的生产环境中;
|
||||
- 其次,在数以万计的大规模集群下,单一发布源很容易被流量打爆,进而导致分发流程停止,应用层多播可以避免这一问题;
|
||||
- 再次,通过应用层节点的接力分发,整个传输带宽被大幅度提高了,分发速度有了数量级上的飞跃;
|
||||
- 最后,如果分发集群跨越不同传输成本的网络(比如多个区域IDC构成的集群),在应用层也很容易控制分发策略,进而减少高成本网络的数据传输量,提升经济性。
|
||||
|
||||
所以,综合来说,**集群规模越大,应用层多播的优势也越大。**实际上十多年前,我们在使用BT、迅雷下载时,就已经接触到应用层多播协议了,接下来我们结合2个服务器端的案例,看看多播协议的实现与应用。
|
||||
|
||||
## 应用层多播协议是如何工作的?
|
||||
|
||||
其实,应用层多播主要是指一种[P2P(Peer to Peer)](https://zh.wikipedia.org/wiki/%E5%B0%8D%E7%AD%89%E7%B6%B2%E8%B7%AF)网络传输思想,任何基于IP单播的通讯协议都可以拿来使用。比如针对于在分布式集群中分发软件安装包的场景,完全可以使用HTTP协议实现应用层的分发,阿里巴巴开源的[Dragonfly蜻蜓](https://github.com/DarLiner/Dragonfly)就是这么做的。
|
||||
|
||||
蜻蜓拥有1个中心化的集群服务节点:SuperNode,其中既可以直接保存着源文件,也可以通过HTTP缓存加速第三方文件源(比如Docker仓库)。集群中的每个节点都要启动dfget进程(替代了传统的wget),它就像我们平时使用的迅雷,在下载文件的同时,也会将自己下载完成的文件传输给其他节点。其中,通过HTTP的Range文件分段下载规范,dfget就可以实现断点续传、多线程下载等功能,如下图所示:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/2f/00/2fbba7f194bd3bcabded582467056700.png" alt="" title="图片及下图来源:https://github.com/DarLiner/Dragonfly/blob/master/docs/zh/architecture.md">](https://github.com/DarLiner/Dragonfly/blob/master/docs/zh/architecture.md)
|
||||
|
||||
各个dfget程序间通过SuperNode协调传输关系,这样绝大部分dfget程序并不需要从SuperNode节点下载文件。比如上图中的节点C,通过HTTP Range协议从节点A中下载了文件的第1块,同时并发地从节点B中下载了文件的第2块,最后再把这些Block块拼接为完整的文件。
|
||||
|
||||
这里你可能会想,这不就是一个P2P下载工具么?是的,但你站在集群运维的角度,这就是基于应用层多播协议的文件分发工具。当每个节点部署dfget服务后,新版本安装包发布时,就可以由SuperNode节点推送,经由各个dfget进程以多播的形式分发下去,此时性能会获得大幅度的提升。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/36/82/36de22b49038a6db2b8bc7ce953e5c82.png" alt="">
|
||||
|
||||
上图是传统的wget单播与蜻蜓多播分发文件的性能对比图。我们可以看到,传统方式下,**分发客户端越多(Y轴)总分发时长(X轴)就越大,特别是1200个以上的并发节点下载文件时,会直接将文件源打爆。**而采用应用层多播方式后,下载时长要低得多,而且伴随着节点数的增加,下载时长也不会增长。
|
||||
|
||||
蜻蜓虽然传输效率很高,但SuperNode却是保存着全局信息的中心节点,一旦宕机就会造成系统不可用。我们再来看去中心化的[Gossip流言协议](https://en.wikipedia.org/wiki/Gossip_protocol)是如何实现应用层多播的。
|
||||
|
||||
Gossip协议也叫epidemic传染病协议,工作原理如下图所示。在这个分布式网络中,并没有任何中心化节点,但只要第1个种子节点被感染为红色后,每个节点只需要感染其相邻的、有限的几个节点,最终就能快速感染网络中的所有节点(即仅保证最终一致性)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fa/b5/fa710dc6de9aa9238fee647ffdb69eb5.gif" alt="">
|
||||
|
||||
当然,所谓的“感染”就是数据的传输,这一算法由1987年发布的《[Epidemic algorithms for replicated database maintenance](http://bitsavers.trailing-edge.com/pdf/xerox/parc/techReports/CSL-89-1_Epidemic_Algorithms_for_Replicated_Database_Maintenance.pdf)》论文提出,同时证明了算法的收敛概率。Cassandra数据库、Fabric区块链、Consul系统等许多去中心化的分布式系统,都使用Gossip协议管理集群中的节点状态。以Cassandra为例,每秒钟每个节点都会随机选择1到3个相邻节点,通过默认的7000端口传输包含节点状态的心跳信息,这样集群就可以快速发现宕机或者新增的节点。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/79/fe/7918df00c24e6e78122d5a70bd6bd2fe.png" alt="" title="图片来源:https://www.linkedin.com/pulse/gossip-protocol-inside-apache-cassandra-soham-saha">](https://www.linkedin.com/pulse/gossip-protocol-inside-apache-cassandra-soham-saha)
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们介绍了应用层的多播协议。
|
||||
|
||||
网络层的IP多播功能有限,对网络环境也有过多的要求,所以很难通过多播协议提升传输效率。基于IP单播协议(如TCP或者UDP),在应用代码层面实现分布式节点间的接力转发,就可以实现应用层的多播功能。
|
||||
|
||||
在分布式集群的文件分发场景中,阿里开源的[Dragonfly蜻蜓](https://github.com/DarLiner/Dragonfly)可以将发布节点上的源文件,通过HTTP协议推送到集群中的每个节点上,其中每个节点在应用层都参与了多播流量分发的实现。当节点数到达千、万级时,蜻蜓仍然能保持较低的分发时延,避免发布节点被下行流量打爆。
|
||||
|
||||
在完全去中心化的分布式集群中,每个节点都没有准确的全局信息,此时可以使用Gossip流言协议,通过仅向有限的相邻节点发送消息,完成整个集群的数据同步,实现最终一致性。因此,Gossip协议常用于大规模分布集群中的节点状态同步。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一道讨论题。在5G完成设备层的组网后,类似华为NewIP这样的基础协议层也在做相应的重构,其中[Multicast VPN协议](https://support.huawei.com/enterprise/en/doc/EDOC1000173015/e0de8568/overview-of-rosen-mvpn)就将现有IPv4无法在公网中推广的多播功能,在VPN逻辑链路层实现了。你对未来多播协议的发展又是如何看的?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你了解到应用层的多播协议,而通过它可以大幅度提升分布式集群的网络传输效率的话,也欢迎你把今天的内容分享给你的朋友。
|
||||
85
极客时间专栏/系统性能调优必知必会/分布式系统优化/27 | 消息队列:如何基于异步消息提升性能?.md
Normal file
85
极客时间专栏/系统性能调优必知必会/分布式系统优化/27 | 消息队列:如何基于异步消息提升性能?.md
Normal file
@@ -0,0 +1,85 @@
|
||||
<audio id="audio" title="27 | 消息队列:如何基于异步消息提升性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/6e/27bfeeac9216d1b7fe0cda975446256e.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
在前26讲中我们介绍了许多异步实现机制,这节课我们来看看如何通过[消息队列](https://zh.wikipedia.org/wiki/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97)提升分布式系统的性能。
|
||||
|
||||
异步通讯是最常用的性能提升方式,比如gRPC提供的异步API,或者基于write-back模式向缓存写入数据时,系统性能都可以提高。然而,对于复杂的大规模分布式系统,这些分散、孤立的异步实现机制,无法解决以下问题:
|
||||
|
||||
- 组件间耦合在一起,不只迭代变更时更为困难,而且当它们之间的性能有差异时,吞吐量较低的组件就会成为系统瓶颈;
|
||||
- 当业务在时间上具有明显的峰谷访问差异时,实现**削峰填谷**需要一定的开发成本;
|
||||
- 实现BASE理论中的Basically Available并不容易;
|
||||
- 每个组件都要自行维护负载均衡组件,以此提供可伸缩性;
|
||||
- 每个组件的请求格式、日志都不尽相同,因此系统总体的监控成本相对较高;
|
||||
- 批量处理请求、异步化都可以提升性能,但每个组件独立实现这些基础功能付出的成本并非完全必要。
|
||||
|
||||
想必你肯定听过Kafka、RabbitMQ、RocketMQ这些流行的消息队列吧?通过消息队列实现组件间的异步交互方式,上述问题就会迎刃而解。这一讲我们就来看看如何在分布式系统中使用消息队列,以及高可用性又是如何保证的。
|
||||
|
||||
## 消息队列解决了哪些问题?
|
||||
|
||||
当进程中需要交互的两个模块性能差距很大时,我们会基于FIFO先入先出队列实现生产者消费者模型,通过调整生产者、消费者的数量,实现线程间的负载均衡。而且,生产者仅将任务添加至队列首部就可以返回,这种异步操作释放了它的性能。比如[[第13课]](https://time.geekbang.org/column/article/240656) 中接收心跳包的分发线程性能要比处理心跳包的工作线程性能高得多,两者间就通过单机上的消息队列提高了整体性能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/21/1865eebccb3f65c6b56d526124c8e421.png" alt="">
|
||||
|
||||
把单机中的FIFO队列放大到分布式系统中,就形成了独立的消息队列服务。此时,生产者、消费者的角色从线程变成了网络中的独立服务,生产者可以向消息队列发布多种消息,多个消费者也可以订阅同一种消息,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/7c/9ca376f54a0631b7e83e9fa024e3427c.png" alt="">
|
||||
|
||||
总结一下的话,消息队列就具备了以下7个优点:
|
||||
|
||||
1. 降低了系统的耦合性。比如上图中,组件2发布了一条用户注册成功消息,原本只有负责通知用户注册结果的组件3在处理,如果组件4需要立刻开启新用户的营销工作,只需要同时向消息队列订阅即可;再比如,组件2、组件3、组件4通讯时并不需要统一应用层协议或者RPC接口,所有参与方只需要与消息队列服务的SDK打交道。
|
||||
1. 可伸缩性很容易实现。比如,当组件3的性能不足时,添加订阅消息的新实例,就可以通过水平扩展提升消费能力。反之,也可以扩展组件1,提升消息的生产能力。
|
||||
1. 天然实现“削峰填谷”功能。消息队列服务会将消息持久化存储在磁盘中,在高峰期来不及处理的消息,会在低谷期被消费者服务处理完。通常,消息队列会使用廉价、高容量的机械磁盘存放消息,可以轻松缓存住高峰期超载的全部请求。
|
||||
1. 提高了系统可用性。首先,持久化到磁盘中的消息,在宕机故障时比内存中的请求有更高的可用性;其次,消息队列可以隔离故障,比如,消费者服务宕机后,生产者服务短期内不会受到影响;再次,当总吞吐量超过性能上限时,还可以设置不同的消息优先级,通过服务降级保障系统的基本可用性。
|
||||
1. 消息队列的生产者天然具备异步功能,这降低了生产者的请求处理时延,提升了用户体验。
|
||||
1. [[第21课]](https://time.geekbang.org/column/article/252741) 介绍过,基于AKF Y轴拆分功能可以降低数据规模,而且组件间分工更细也会带来更深入的性能优化。当消息队列作为通讯方式时,这种“事件驱动”的分布式系统很容易通过消息实现服务拆分,成本会低很多。
|
||||
1. 消息队列服务对于各种消息的发布、消费情况都有统计,因此,从消息中就能获得业务的实时运行状态,以极低的成本实现系统的监控。
|
||||
|
||||
正是因为这么多的优点,所以消息队列成为了多数分布式系统必备的基础设施。而且,消息队列自身也拥有很高的性能,比如RabbitMQ单机每秒可以处理10万条消息,而Kafka单机每秒甚至可以处理百万条消息。消息队列的性能为什么如此夸张呢?除了消息队列处理逻辑简单外,还有一个重要原因,就是消息的产生、消费在时间上是连续的,这让消息队列在以下优化点上能获得很高的收益:
|
||||
|
||||
- 首先,在网络通讯中,很容易通过批量处理提高网络效率。比如生产者快速发布消息时,Kafka的客户端SDK会自动聚集完一批消息,再一次性发送给Broker,这样网络报文的有效载荷比会很高。
|
||||
- 其次,在数据写入磁盘的过程中,由于时序性特征,存放消息的文件仅以追加形式变更,这样多数情况下机械硬盘的磁头仅朝一个方向转动,这让磁盘写入速度可以轻松达到100MB/s。
|
||||
- 最后,由于消费者也是按照FIFO规则有序接收消息的,这样消息队列的缓存就可以通过批量预读等优化方式,大幅提高读操作的缓存命中率。
|
||||
|
||||
而且,目前主流消息队列都支持集群架构,因此消息队列自身一般不会是性能瓶颈。
|
||||
|
||||
## 消息队列的服务质量是如何保证的?
|
||||
|
||||
为了提升整个分布式系统的性能,我们在处理消息时,还需要在生产端、消费端以及消息队列的监控上,做到以下3件事:
|
||||
|
||||
- 首先,虽然生产者会异步地发布消息,但毕竟需要接收到消息队列的确认,才构成完整的发布流程。网络传输是相对漫长、不可控的,所以在高性能场景中,生产者应基于多线程或者非阻塞Socket发布消息,以提高并发能力。
|
||||
- 其次,当消费端性能不足需要扩容时,必须同步增加消息队列服务中的队列(在Kafka中叫做分区),才能允许新增的消费节点并行接收消息,提高消息的处理能力。否则,当多个消费者消费同一消息队列时,消息的有序性会导致多个消费节点串行处理消息,无法发挥出它们的全部性能,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/46/bc9b57604b2f15cf6fb95d64af99c546.png" alt="">
|
||||
|
||||
- 最后,如果通过监控发现消息的消费能力小于生产能力,那就必须及时扩容消费端,或者降低消息的发布速度,否则消息就会积压,最终导致系统不可用。
|
||||
|
||||
接下来,我们再来看消息队列的QoS(Quality of Service)是如何保证的,即:消息在传递过程中会不会丢失,以及接收方会不会重复消费消息。在[MQTT协议](https://en.wikipedia.org/wiki/MQTT)中,给消息队列定义了三种QoS级别:
|
||||
|
||||
- at most once,每条消息最多只被传送一次,这意味着消息有可能丢失;
|
||||
- at least once,每条消息至少会传送一次,这意味着消息可能被重复消费;
|
||||
- exactly once,每条消息恰好只传送一次,这是最完美的状态。
|
||||
|
||||
需要at most once约束的场景较罕见,因此目前绝大部分消息队列服务提供的QoS约束都是at least once,它是通过以下3点做到的:
|
||||
|
||||
- 生产端发布消息时,只有消息队列确定写入磁盘后,才会返回成功;
|
||||
- 为防止消息队列服务出现故障后丢消息,我们也需要将数据存放在多个副本节点中。第4部分课程介绍的许多高可用策略,消息队列都会采用,比如Kafka就是使用[[第22课]](https://time.geekbang.org/column/article/254600) 介绍过的NWR算法来选出副本中的Leader节点,再经由它同步数据副本。
|
||||
- 消费端必须在消费完消息(而不是收到消息)后,才能向消息队列服务返回成功。
|
||||
|
||||
这样,消息队列就能以很高的可用性提供at least once级别的QoS。而exactly once是在at least once的基础上,通过[幂等性idempotency](https://en.wikipedia.org/wiki/Idempotence) 实现的。对于一条“幂等性消息”,无论消费1次还是多次,结果都是一样的。因此,Kafka通过消息事务和幂等性约束实现了[exactly once语义](https://kafka.apache.org/documentation/#upgrade_11_exactly_once_semantics),其中,发布消息时Kafka会创建全局唯一的递增ID,这样传输消息时它就能低成本地去除重复的消息,通过幂等性为单队列实现exactly once语义;针对生产者向多个分区发布同一条消息的场景,消息事务通过“要么全部成功要么全部失败”,也实现了exactly once语义。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们介绍了消息队列及其用法。
|
||||
|
||||
消息队列可以解耦分布式系统,其缓存的消息提供了削峰填谷功能,将消息持久化则提高了系统可用性,共享队列则为系统提供了可伸缩性,而且统计消息就可以监控整个系统,因此消息队列已成为当下分布式系统的必备基础设施。
|
||||
|
||||
虽然消息队列自身拥有优秀的性能,但若想提高使用效率,我们就需要确保在生产端实现网络传输上的并发,在消费端扩容时同步增加队列或者分区,并且需要持续监控系统,确保消息的生产能力小于消费能力,防止消息积压。
|
||||
|
||||
消息队列的Qos提供三种语义,其中at most once很少使用,而主流的at least once由消息持久化时的冗余,以及生产端、消息端使用消息的方式共同保障。Kafka通过幂等性、事务消息这两个特性,在at least once的基础上提供了exactly once语义。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一道讨论题。你在实践中使用过消息队列吗?它主要帮你解决了哪些问题?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有所帮助,也欢迎把今天的内容分享给你的朋友。
|
||||
89
极客时间专栏/系统性能调优必知必会/分布式系统优化/28 | MapReduce:如何通过集群实现离线计算?.md
Normal file
89
极客时间专栏/系统性能调优必知必会/分布式系统优化/28 | MapReduce:如何通过集群实现离线计算?.md
Normal file
@@ -0,0 +1,89 @@
|
||||
<audio id="audio" title="28 | MapReduce:如何通过集群实现离线计算?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1f/e9/1f52881d9daaa1a3aac4ffda0fbae5e9.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
接下来的2节课我将介绍如何通过分布式集群优化计算任务。这一讲我们首先来看对于有边界静态数据的离线计算,下一讲再来看对无边界数据流的实时计算。
|
||||
|
||||
对大量数据做计算时,我们通常会采用分而治之的策略提升计算速度。比如单机上基于递归、分治思想实现的快速排序、堆排序,时间复杂度只有O(N*logN),这比在原始数据集上工作的插入排序、冒泡排序要快得多(O(N<sup>2</sup>))。然而,当单机磁盘容量无法存放全部数据,或者受限于CPU频率、核心数量,单机的计算时间远大于可接受范围时,我们就需要在分布式集群上使用分治策略。
|
||||
|
||||
比如,大规模集群每天产生的日志量是以TB为单位计算的,这种日志分析任务单台服务器的处理能力是远远不够的。我们需要将计算任务分解成单机可以完成的小任务,由分布式集群并行处理后,再从中间结果中归并得到最终的运算结果。这一过程由Google抽象为[MapReduce](https://zh.wikipedia.org/wiki/MapReduce) 模式,实现在Hadoop等分布式系统中。
|
||||
|
||||
虽然MapReduce已经有十多个年头的历史了,但它仍是分布式计算的基石,这种编程思想在新出现的各种技术中都有广泛的应用。比如当在单机上使用TensorFlow完成一轮深度学习的时间过久,或者单颗GPU显存无法存放完整的神经网络模型时,就可以通过Map思想把数据或者模型分解给多个TensorFlow实例,并行计算后再根据Reduce思想合并得到最终结果。再比如知识图谱也是通过MapReduce思想并行完成图计算任务的。
|
||||
|
||||
接下来我们就具体看看如何在分布式集群中实现离线计算,以及MapReduce是怎样提供SQL语言接口的。
|
||||
|
||||
## 分而治之:如何实现集群中的批量计算?
|
||||
|
||||
分而治之的思想在分布式系统中广为使用,比如[[第21讲]](https://time.geekbang.org/column/article/252741) 介绍过的AKF立方体Z轴扩展,就是基于用户的请求,缩小集群中单个节点待处理的数据量,比如下图中当关系数据库中单表行数达到千万行以上时,此时不得不存放在磁盘中的索引将会严重降低SQL语句的查询速度。而执行分库分表后,由应用或者中间层的代理分解查询语句,待多个不足百万行的表快速返回查询结果后,再归并为最终的结果集。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/81/712a0a73b71090abcaa7ac552f402181.png" alt="">
|
||||
|
||||
与上述的IO类任务不同,并非所有的计算任务都可以基于分治策略,分解为可以并发执行的子任务。比如[[第14讲]](https://time.geekbang.org/column/article/241632) 介绍过的基于[CBC分组模式](https://zh.wikipedia.org/zh-hans/%E5%88%86%E7%BB%84%E5%AF%86%E7%A0%81%E5%B7%A5%E4%BD%9C%E6%A8%A1%E5%BC%8F)的AES加密算法就无法分解执行,如下图所示,每16个字节的块在加密时,都依赖前1个块的加密结果,这样的计算过程既无法利用多核CPU,也无法基于MapReduce思想放在多主机上并发执行。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/2b/3b/2b8bca7a74eb5f98125098e271d0973b.jpg" alt="" title="图片源自:https://zh.wikipedia.org/zh-hans/%E5%88%86%E7%BB%84%E5%AF%86%E7%A0%81%E5%B7%A5%E4%BD%9C%E6%A8%A1%E5%BC%8F">](https://zh.wikipedia.org/zh-hans/%E5%88%86%E7%BB%84%E5%AF%86%E7%A0%81%E5%B7%A5%E4%BD%9C%E6%A8%A1%E5%BC%8F)
|
||||
|
||||
我们再来看可以使用MapReduce的计算任务,其中最经典的例子是排序(Google在构建倒排索引时要为大量网页排序)。当使用插入排序(不熟悉插入排序的同学,可以想象自己拿了一手乱牌,然后在手中一张张重新插入将其整理有序)在整个数据集上操作时,计算的时间复杂度是O(N<sup>2</sup>),但快排、堆排序、归并排序等算法的时间复杂度只有O(N*logN),这就是通过分治策略,缩小子问题数据规模实现的。
|
||||
|
||||
比如下图是在8个数字上使用归并排序算法进行排序的流程。我们将数组递归地进行3(log8)轮对半拆分后,每个子数组就只有2个元素。对2个元素排序只需要进行1次比较就能完成。接着,再将有序的子数组不断地合并,就可以得到完整的有序数组。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/71/8e9f75013bcb26ae2befec6ff8739971.png" alt="">
|
||||
|
||||
其中,将两个含有N/2个元素的有序子数组(比如1、3、7、19和4、8、11、25),合并为一个有序数组时只需要做N/2到N-1次比较(图中只做了5次比较),速度非常快。因此,比较次数乘以迭代轮数就可以得出时间复杂度为O(N*logN)。
|
||||
|
||||
同样的道理引申到分布式系统中,就成为了MapReduce模式。其中,原始数据集要通过SPLIT步骤拆分到分布式系统中的多个节点中,而每个节点并发执行用户预定义的MAP函数,最后将MAP运算出的结果通过用户预定义的REDUCE函数,归并为最终的结果。比如上例中我们可以将8个元素拆分到2个节点中并行计算,其中每个节点究竟是继续采用归并排序,还是使用其他排序算法,这由预定义的MAP函数决定。当MAP函数生成有序的子数组后,REDUCE函数再将它们归并为完整的有序数组,具体如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/72/15/72bb89540bae52a46e69a5d802680715.png" alt="">
|
||||
|
||||
当面对TB、PB级别的数据时,MapReduce思想就成了唯一的解决方案。当然,在实际软件工程中实现MapReduce的框架要比上面的示意图复杂许多,毕竟在大规模分布式系统中,故障每时每刻都会发生,如何分发数据、调度节点执行MAP映射、监控计算节点等,都需要精心的设计。特别是,当单个节点的磁盘无法存放下全部数据时,常常使用类似HDFS的分布式文件系统存放数据,所以MapReduce框架往往还需要对接这样的系统来获取数据,具体如下图所示:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/4f/39/4f3182c6334ec0c7b67e69b6ded2e839.png" alt="" title="图片来源:http://a4academics.com/tutorials/83-hadoop/840-map-reduce-architecture">](http://a4academics.com/tutorials/83-hadoop/840-map-reduce-architecture)
|
||||
|
||||
而且,生产环境中的任务远比整数排序复杂得多,所以写对Map、Reduce函数并不容易。另一方面,大部分数据分析任务又是高度相似的,所以我们没有必要总是直接编写Map、Reduce函数,实现发布式系统的离线计算。由于SQL语言支持聚合分析、表关联,还内置了许多统计函数,很适合用来做数据分析,它的学习成本又非常低,所以大部分MapReduce框架都提供了类SQL语言的接口,可以替代自行编写Map、Reduce函数。接下来我们看看,SQL语言统计数据时,Map、Reduce函数是怎样工作的。
|
||||
|
||||
## SQL是如何简化MapReduce模式的?
|
||||
|
||||
我们以最常见的Web日志分析为例,观察用SQL语言做统计时,MapReduce流程是怎样执行的。举个例子,Nginx的access.log访问日志是这样的(基于默认的combined格式):
|
||||
|
||||
```
|
||||
127.0.0.1 - - [18/Jul/2020:10:16:15 +0800] "GET /login?userid=101 HTTP/1.1" 200 56 "-" "curl/7.29.0"
|
||||
|
||||
```
|
||||
|
||||
你可以通过正则表达式取出客户端IP地址、用户名、HTTP响应码,这样就可以生成结构化的数据表格:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/e7/e9fcf8e7529f973b1679af93333b4ee7.jpg" alt="">
|
||||
|
||||
如果我们想按照客户端IP、HTTP响应码聚合统计访问次数,基于通用的SQL规则,就可以写出下面这行SQL语句:
|
||||
|
||||
```
|
||||
select ClientIp, StatusCode, count(*) from access_log group by ClientIp, StatusCode
|
||||
|
||||
```
|
||||
|
||||
而建立在MapReduce之上的框架(比如Hive)会将它翻译成如下图所示的MapReduce流程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4c/f9/4cb7443e0f9cdf2ba77fbbe230487ff9.png" alt="">
|
||||
|
||||
其中,我们假定5行数据被拆分到2个节点中执行Map函数,其中它们分别基于2行、3行这样小规模的数据集,生成了正确的聚合统计结果。接着,在Shuffle步骤基于key关键字排序后,再交由Reduce函数归并出正确的结果。
|
||||
|
||||
除了这个例子中的count函数,像max(求最大值)、min(求最小值)、distinct(去重)、sum(求和)、avg(求平均数)、median(求中位数)、stddev(求标准差)等函数,都很容易分解为子任务并发执行,最后归并出最终结果。
|
||||
|
||||
当多个数据集之间需要做交叉统计时,SQL中的join功能(包括内连接、左外连接、右外连接、全连接四种模式)也很容易做关联查询。此时,我们可以在并行计算的Map函数中,把where条件中的关联字段作为key关键字,经由Reduce阶段实现结果的关联。
|
||||
|
||||
由于MapReduce操作的数据集非常庞大,还需要经由网络调度多台服务器才能完成计算,因此任务的执行时延至少在分钟级,所以通常不会服务于用户的实时请求,而只是作为离线的异步任务将运算结果写入数据库。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们介绍了在集群中使用分治算法统计大规模数据的MapReduce模式。
|
||||
|
||||
当数据量很大,或者计算时间过长时,如果计算过程可以被分解为并发执行的子任务,就可以基于MapReduce思想,利用分布式集群的计算力完成任务。其中,用户可以预定义在节点中并发执行的Map函数,以及将Map输出的列表合并为最终结果的Reduce函数。
|
||||
|
||||
虽然MapReduce将并行计算抽象为统一的模型,但开发Map、Reduce函数的成本还是太高了,于是针对高频场景,许多MapReduce之上的框架提供了类SQL语言接口,通过group by的聚合、join连接以及各种统计函数,我们就可以利用整个集群完成数据分析。
|
||||
|
||||
MapReduce模式针对的是静态数据,也叫有边界数据,它更多用于业务的事前或者事后处理流程中,而做事中处理时必须面对实时、不断增长的无边界数据流,此时MapReduce就无能为力了。下一讲我们将介绍处理无边界数据的流式计算框架。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一道思考题。你遇到过哪些计算任务是无法使用MapReduce模式完成的?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你有所收获,也欢迎你把今天的内容分享给你的朋友。
|
||||
91
极客时间专栏/系统性能调优必知必会/分布式系统优化/29 | 流式计算:如何通过集群实现实时计算?.md
Normal file
91
极客时间专栏/系统性能调优必知必会/分布式系统优化/29 | 流式计算:如何通过集群实现实时计算?.md
Normal file
@@ -0,0 +1,91 @@
|
||||
<audio id="audio" title="29 | 流式计算:如何通过集群实现实时计算?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5f/88/5f8498f16a0c7f1286c952770e7ea788.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上节课我们介绍了在有边界的存量数据上进行的MapReduce离线计算,这节课我们来看看对于无边界数据,怎样实时地完成流式计算。
|
||||
|
||||
对于不再变化的存量数据,可以通过分而治之的MapReduce技术将数据划分到多台主机上并行计算,由于待处理的数据量很大,我们只能获得分钟级以上的时延。当面对持续实时产生动态数据的场景时,业务上通常需要在秒级时延中及时地拿到运算结果。
|
||||
|
||||
比如,商家为了拉新促活,会为特定的用户群体(比如新用户或者不活跃用户)推出优惠活动,为了防止“羊毛党”通过大量主机并行地**“薅羊毛”**,系统要能实时地聚合分析所有优惠券的使用者特点,再基于业务规则及时地封掉“羊毛党”帐号或者IP地址,才可以控制住风险范围,提高营销活动的收益。那对于整个系统持续生成的大量订单数据,怎样才能提供秒级的聚合分析结果呢?
|
||||
|
||||
最初的流式计算方案,是在时间维度上定期地将数据分片,再基于MapReduce思想在空间维度的多台主机上实现并行计算,这样也能获得实时计算结果。然而,对每片数据执行批量计算,想要在秒级甚至毫秒级拿到计算结果并不容易。当网络不稳定时,数据会因为报文延误而乱序,简单的基于时序分片会导致计算结果失真。当数据之间具有明显的业务关系时,固定的时间窗口更是难以得到预期的分析结果。
|
||||
|
||||
接下来我们就深入学习一下流式计算的工作原理,以及流式计算常用的数据分片窗口。
|
||||
|
||||
## 流式计算是如何实现的?
|
||||
|
||||
在数据库、HDFS等分布式系统中存放的静态数据,由于拥有清晰的边界,所以被称为InBound Data有边界数据。然而,线上运行中的互联网产品生命周期并不确定,它产生的数据有明确的开始,却没有截止时间点。对于这样有始无终的实时数据流,我们把它称为OutBound Data无边界数据,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/a0/bfe991c2d38eb5c2f3f2d271d7fbd7a0.jpg" alt="">
|
||||
|
||||
从业务需求上看,有边界数据与无边界数据的计算目的是完全不同的。比如对于分布式监控系统,我们需要基于IP地址、用户帐号、请求类型等许多特征进行定时的聚合统计,例如获取每分钟内所有请求处理时延的平均值、中位数、最大值等,监控系统性能。此时,可以根据请求执行结果的产生时间对数据进行分片计算。比如,下表中有7条监控数据,需要求出每分钟请求时延的平均值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/36/f124f93afffdb388660d4236418ff236.jpg" alt="">
|
||||
|
||||
如果我们按照分钟整点对数据进行分片,就可以在02:00时对蓝色的消息1、2求出窗口内的平均时延192毫秒,并立刻返回结果。之后当接收完红色的消息3、4、5后,在第2分钟结束时再对3个数字求出平均值。以此类推。
|
||||
|
||||
**这种设计思想就是基于固定时间窗口的批处理解决方案。**当然,并不是一定要等到时间窗口结束时,才对这一批次的所有数据统一计算。我们完全可以在每个消息到来时,就计算出中间状态,当所在的时间窗口结束时,再将中间状态转换为最终结果。仍然以上表为例,我们可以在每个监控事件到达时,计算出请求时延和以及当前窗口内的事件个数,这样,在窗口结束时我们只需要将时延和除以事件个数,就能得到平均值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/y5/7b5015d8221b32150c4bd1bfcd17byy5.jpg" alt="">
|
||||
|
||||
因此,中间状态可以更均衡地使用计算资源,提高流式计算的整体性能。我们既可以把中间状态放在内存中,也可以把它持久化到本地磁盘中获取更高的可用性,为了方便计算节点的调度,我们通常还会将备份状态存放至远端的数据库。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/e6/56/e6518cbc0f727a9f5fde40cdccbd2f56.png" alt="" title="图片来源:https://flink.apache.org/">](https://flink.apache.org/)
|
||||
|
||||
当然,流式计算最主要的性能提升思路,还是基于MapReduce思想,将同一窗口的数据从空间维度中分发到不同的计算节点进行并行的Map计算,再将Map映射出的结果Reduce为最终结果。由于流式计算天然是基于消息事件驱动的,因此它往往直接从Kafka等消息队列中获取输入数据,如同[[第27讲]](https://time.geekbang.org/column/article/261094) 的介绍,消息队列很容易协助流式计算实现数据拆分。
|
||||
|
||||
到这里,我们已经看到了实现流式计算的基本思路,其中基于固定时间窗口的数据划分方式还有很大的改进空间,目前它还无法解决较为复杂的有状态计算。所谓有状态计算,是指在时间窗口内,不同的消息之间会互相作用并影响最终的计算结果,比如求平均值就是这样一个例子,每个新到达的数据都会影响中间状态值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/da/ce5ef3b3fe17b5032b906a1f1658a6da.jpg" alt="">
|
||||
|
||||
相反,无状态计算处理到达的数据时,并不涉及窗口内的其他数据,处理流程要简单的多。例如,当监控到请求时延超过3秒时,就产生一条告警。此时,只需要单独地判断每个消息中的时延数据,就能够得到计算结果。
|
||||
|
||||
在真实的业务场景中,有状态计算还要更复杂。比如,对两个不同的数据源(可以理解为数据库中的表)做join连接时,采用内连接、外连接这两种不同的连接方式,就会影响到我们的时间窗口长度。再比如,当不同的事件具有逻辑关系时,窗口长度则应该由业务规则确定,不同的请求可能拥有不等的窗口大小。接下来,我们再来看看流式计算中的几种常见窗口。
|
||||
|
||||
## 如何通过窗口确定待计算的数据?
|
||||
|
||||
首先来看滑动窗口,它是从固定窗口衍生出的一种窗口。我们继续延续求每分钟平均值的例子,当业务上需要更平滑的曲线时,可以通过每20秒求最近1分钟请求时延的平均值实现,这就是滑动窗口,其中窗口长度则是1分钟,但每次计算完并不会淘汰窗口中的全部数据,而只是将步长向后移动20秒,即只淘汰最早20秒中的数据。当窗口长度与步长一致时,滑动窗口就退化成了固定窗口。
|
||||
|
||||
当然,我们还可以把窗口的计量单位从时间改为事件个数,此时可以称为计数窗口。仍然延续上面的例子,固定计数窗口可以改为求每100个访问记录的平均时延,滑动计数窗口可以改为每10条记录中求最近100个记录的平均时延。由于消息本身是有时序的,所以这些都可以称为时间驱动的窗口。事实上,还有另外一种事件驱动的窗口与此完全不同,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/57/75dd62dd3a90c027a4e8ae95389dea57.jpg" alt="">
|
||||
|
||||
固定窗口、滑动窗口并不会解析业务字段,区别对待图中不同的Key关键字,这就很难解决以下这类场景:当需要统计用户在一个店铺内浏览的商品数量时,就需要针对用户的店铺停留时长来设计动态的窗口大小。毕竟不同的用户在不同的店铺内停留时长不可能相同,此时,动态的窗口大小可以通过事件来驱动,我们称为会话窗口。
|
||||
|
||||
事实上,我们还面临着信息统计准确性上的问题。在基于时间驱动的窗口中,这里的时间其实是事件到达流式系统时产生的系统处理时间,而不是事件发生的时间。仍然以访问日志为例,每条日志都有明确的请求访问时间,但在分布式系统传输时,由于网络波动的传输时延,以及各主机节点应用层的处理时延,这些事件到达流式计算框架的顺序已经发生了变化。如果仍然以固定的时间窗口来处理,就会得到错误的统计结果。
|
||||
|
||||
为了避免乱序事件扰乱统计结果,我们可以使用水位线Watermark减少乱序概率。比如下图中,消息队列中的数字表示事件时间,其中事件7先于事件3、5到达了流式计算系统:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dd/56/dda5bbfa757d04d41b26d5a97b4bca56.jpg" alt="">
|
||||
|
||||
如果设置了水位4,窗口就不再以事件顺序严格划分,而是通过水位上的时间来划分窗口,这样事件7就会放在第2个窗口中处理:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bd/ce/bdcc6f9a03f5572882c0f59e5e8db2ce.jpg" alt="">
|
||||
|
||||
当然,并不是有了水位线,第1个窗口就会无限制的等下去。在经历一个时间段后,第1个窗口会认定窗口关闭(这未必准确),它会处理3、1、3、2这4个事件。基于业务规则,下一个水位被设置为9:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7f/46/7fc2a8daa079d547a75bb257f08cd346.jpg" alt="">
|
||||
|
||||
这样第2个窗口会处理6、5、7事件,而事件9就放在了第3个窗口中处理:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/0b/c032e4d98cfc51f29aeb4da6f4ec370b.jpg" alt="">
|
||||
|
||||
以此类推。根据业务特性和经验值,找到最大乱序时间差,再基于此设置合适的水位线,就能减轻乱序事件的影响。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们介绍了流式计算的实现原理,以及常用的几种分片窗口。
|
||||
|
||||
对于无边界的实时数据流,我们可以在时间维度上将其切分到不同的窗口中,再将每个窗口内的数据从空间维度上分发到不同的节点并行计算,在窗口结束时汇总结果,这就实现了流式计算。Apache [Flink](https://zh.wikipedia.org/wiki/Apache_Flink)、[Spark](https://en.wikipedia.org/wiki/Apache_Spark)、[Storm](https://en.wikipedia.org/wiki/Apache_Storm) 等开源产品都是这样的流式计算框架。
|
||||
|
||||
通过不同的窗口划分规则,可以实现不同的计算目的,包括以时间驱动的固定窗口、滑动窗口和计数窗口,以及以事件驱动的会话窗口。为了避免乱序事件的影响,还可以通过携带超时时间的Watermark水位,基于事件发生时间更精准地划分窗口。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一道讨论题。你知道Lambda架构吗?它通过分开部署的MapReduce、流式计算系统,分别完成离线计算与实时流计算,如下图所示:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/bc/0f/bc3760eaf9e5789c2459fdb4e03ea00f.png" alt="" title="图片来源:https://www.oreilly.com/radar/questioning-the-lambda-architecture/">](https://www.oreilly.com/radar/questioning-the-lambda-architecture/)
|
||||
|
||||
这套系统的IT成本很高,因此大家致力于使用一套系统同时解决这两个问题。你认为这种解决方案是如何实现的?你又是如何看待流式计算发展方向的?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你有所收获,也欢迎你把今天的内容分享给你的朋友。
|
||||
153
极客时间专栏/系统性能调优必知必会/分布式系统优化/30 | 如何权衡关系数据库与NoSQL数据库?.md
Normal file
153
极客时间专栏/系统性能调优必知必会/分布式系统优化/30 | 如何权衡关系数据库与NoSQL数据库?.md
Normal file
@@ -0,0 +1,153 @@
|
||||
<audio id="audio" title="30 | 如何权衡关系数据库与NoSQL数据库?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/d8/c0beca9aab73685fc6efce7d396c7fd8.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
到了第4部分课程的最后一讲,我们来结合前面介绍过的知识点,看看面对NoSQL、关系数据库时该如何选择。
|
||||
|
||||
在分布式系统中,我们会同时使用多种数据库。比如,你可能会在Redis中存放用户Session会话,将业务数据拆解为由行、列构成的二维表存储在MySQL中,将需要全文检索的数据放在ElasticSearch中,将知识图谱放在Neo4j图数据库中,将数据量、访问量很大的数据放在Cassandra列式数据库或者MongoDB文档型数据库中,等等。
|
||||
|
||||
选择数据库时,我们的依据可能是访问速度,比如基于哈希表的Redis查询复杂度只有O(1),也可能从事务的支持程度上选择了关系数据库,甚至从应用层的开发效率上还给它添加了Hibernate等ORM框架,也可能从处理数据的体量上选择了NoSQL数据库。可是,除了各种实现层面上的差异外,各类NoSQL与关系数据库之间,有没有最本质的区别?在实际工程中,我们可否从此入手确定大方向,再从细微处选择不同的实现?
|
||||
|
||||
在我看来,答案就在于“关系”这两个字,这也是我权衡数据库时最先考虑的前提。接下来我们就沿着关系数据库的特性,看看NoSQL数据库究竟做了哪些改变,我们又该如何选择它们。
|
||||
|
||||
## 关系数据库的优点
|
||||
|
||||
关系数据库对业务层开发效率的提升有很大帮助。下面我们先基于一个简单的例子,看看关系数据库有何优点。疫情期间新增了一批能够测量体温的考勤机,通过关系数据库我们新建了用户、考勤机、考勤记录三张表,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/25/60870a7eebd59fbcfb87298c3ed31025.jpg" alt="">
|
||||
|
||||
在关系数据库中,表中的每行数据由多个从属于列的单一值(比如数字、字符串)构成。虽然表中可以存放任意行数据,但列却是预先定义且不变的,因此我们很容易通过行、列交汇处的单一值进行关联操作,进而完成各类业务目的不同的查询。比如,业务开发者可以通过下面这行SQL语句,找到体温超过37度的员工,上报其姓名、测量时间以及所在地理位置:
|
||||
|
||||
```
|
||||
select user.name, record.time, machine.location from user, record, machine where user.id = record.user_id and machine.id = record.machine_id and record.temporature > 37;
|
||||
|
||||
```
|
||||
|
||||
运营人员则可以通过下面这行SQL语句,找出各类考勤机的使用频率:
|
||||
|
||||
```
|
||||
select count(*), machine.id from machine, record where machine.id = record.machine_id group py machine.id;
|
||||
|
||||
```
|
||||
|
||||
因此,关系数据库**可以通过预定义的关系,由数据库自身完成复杂的逻辑计算,为不同的场景提供数据服务。**由于不同的数据间具有了关系,关系数据库还提供了“[Transaction事务](https://en.wikipedia.org/wiki/Database_transaction)”,用于保证相关数据间的一致性,这大大释放了应用开发者的生产力。所谓“事务”,会同时具有ACID4个特性:
|
||||
|
||||
**Atomicity原子性**,指多个SQL语句组成了一个逻辑单位,执行时要么全部成功,要么全部失败。
|
||||
|
||||
**Consistency一致性**,指数据库只能从一个一致性状态转换到另一个一致性状态。即使数据库发生了重启,仍然得维持一致性。
|
||||
|
||||
**Isolation隔离性**,由于数据库可以支持多个连接并发操作,因此并发的事务间必须互相隔离才有意义。SQL标准定义了以下4种隔离级别:
|
||||
|
||||
- READ UNCOMMITTED未提交读,它表示在事务A还未提交时,并发执行的事务B已经可以看到事务A改变的数据。这种隔离级别会带来很多问题,因此很少使用。
|
||||
- READ COMMITTED提交读,它表示当事务A未提交时,事务B看不到事务A改变的任何数据,这是PostgreSQL数据库的默认隔离级别。
|
||||
- REPEATABLE READ可重复读,指在READ COMMITTED的基础上解决了脏读问题。所谓脏读,是指在一个事务内,多次读取到同一数据时,结果可能不一致。这是MySQL数据库的默认隔离级别。
|
||||
- SERIALIZABLE可串行化,它通过对每一行数据加锁,使得所有事务串行执行,虽然隔离性最好,但也大大降低了数据库的并发性,所以很少使用。
|
||||
|
||||
**Durability持久性**,指一旦事务提交,事务所做的修改必须永久性地保存到数据库中。
|
||||
|
||||
可见,事务的ACID特性简化了本应由应用层完成的流程!这也是关系数据库与NoSQL数据库之间最大的差别。除事务外,关系数据库还在以下4点上降低了应用层的开发成本:
|
||||
|
||||
- 无论是商业版的Oracle,还是开源的MySQL、PostgreSQL,**只要是关系数据库就拥有同样的数据模型,因此它们可以通过**[SQL](https://zh.wikipedia.org/wiki/SQL)** 语言为应用层提供标准化、几乎没有差异的访问接口;**
|
||||
- 生产级的数据库对持久化都有良好的支持,全面的冷备、热备方案提供了很高的可用性;
|
||||
- 通过索引、缓存等特性,当行数在亿级以下时,关系数据库的性能并不低;
|
||||
- 关系数据库支持还不错的并发度,一般可以服务于上千个并发连接。
|
||||
|
||||
所以应用层会将许多计算任务放在关系数据库中,在此基础上还诞生了MVC等将数据层从业务中剥离、以关系数据库为中心的架构。
|
||||
|
||||
## 关系数据库的问题
|
||||
|
||||
虽然基于单一值的关系映射提供了事务等许多功能,但同时也引入了3个问题。
|
||||
|
||||
首先,内存中的数据结构非常多样,难以直接映射到行列交汇处的单一值上。不过,**这个问题可以通过**[ORM(Object-relational mapping)](https://en.wikipedia.org/wiki/Object-relational_mapping)**框架解决。**比如,Python中的Django ORM框架,可以将上述3张表映射为内存中的3个类:
|
||||
|
||||
```
|
||||
from django.db import models
|
||||
|
||||
class User(models.Model):
|
||||
name = models.CharField(max_length=20)
|
||||
|
||||
class Machine(models.Model):
|
||||
location = models.CharField(max_length=100)
|
||||
|
||||
class Record(models.Model):
|
||||
time = models.DateTimeField()
|
||||
temporature = models.FloatField()
|
||||
user = models.ForeignKey(User)
|
||||
machine= models.ForeignKey(Machine)
|
||||
|
||||
```
|
||||
|
||||
ORM框架会为每张表生成id字段,而Record表将User和Machine表中的id字段作为外键(ForeignKey)互相关联在一起。于是,这3个类就映射了数据库中的那3张表,而内存中的对象(即类的实例)则映射为每张表中的一行数据。在ORM框架下,找到体温大于37度员工的那串长SQL,可以转化为OOP中的函数调用,如下所示:
|
||||
|
||||
```
|
||||
#gte表示大于等于
|
||||
records = Record.objects.filter(temporature__gte = 37)
|
||||
for r in records:
|
||||
print(r.user.name, r.machine.location, r.time)
|
||||
|
||||
```
|
||||
|
||||
相比起SQL语句,映射后的OO编程要简单许多。
|
||||
|
||||
其次,为了实现关系映射,每张表中的字段都得预先定义好,一旦在产品迭代过程中数据模型发生了变化,便需要同步完成以下3件事:
|
||||
|
||||
- 修改表结构;
|
||||
- 修改应用层操作数据的代码;
|
||||
- 根据新的规则转换、迁移已有数据。
|
||||
|
||||
在ORM中我们可以把这3步放在一个migration迁移脚本中完成。当然,如果数据迁移成本高、时间长,可以设计更复杂的灰度迁移方案。
|
||||
|
||||
**最后是关系数据库固有的可伸缩性问题,这是各类NoSQL数据库不断诞生的主要原因。**在[[第21讲]](https://time.geekbang.org/column/article/252741) 中,我们介绍过沿AKF X轴扩展的复制型主从结构,然而单点主库无法解决数据持续增长引入的性能问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/16/0cf179648bf05bf38fb192c7ca797916.png" alt="">
|
||||
|
||||
沿AKF Z轴扩展数据库虽然能够降低数据规模,但分库分表后,单一值关系引申出的ACID事务不能基于高时延、会抖动的网络传输强行实现,否则会导致性能大幅下降,这样的可用性是分布式系统无法接受的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/44/f4/44c738618f8e947372969be96c525cf4.png" alt="">
|
||||
|
||||
因此,在单机上设计出的关系数据库,难以处理PB级的大数据。而NoSQL数据库放弃了单一值数据模型,非常适合部署在成千上万个节点的分布式环境中。
|
||||
|
||||
## NoSQL数据库是如何解决上述问题的?
|
||||
|
||||
虽然所有的NoSQL数据库都无法实现标准的SQL语言接口,但NoSQL绝不是“No SQL:拒绝SQL语言”的意思。当然,NoSQL也不是“Not Only SQL:不只是SQL语言”的意思,否则Oracle也能算NoSQL数据库了。实际上,没有必要纠结NoSQL的字面含义,NoSQL数据库只是放弃了与分布式环境相悖的ACID事务,提供了另一种聚合数据模型,从而拥有可伸缩性的非关系数据库。
|
||||
|
||||
NoSQL数据库可以分为以下4类:
|
||||
|
||||
[Key/Value数据库](https://en.wikipedia.org/wiki/Key%E2%80%93value_database),通常基于哈希表实现(参见[[第3讲]](https://time.geekbang.org/column/article/232351)),性能非常好。其中Value的类型通常由应用层代码决定,当然,Redis这样的Key/Value数据库还可以将Value定义为列表、哈希等复合结构。
|
||||
|
||||
[文档型数据库](https://en.wikipedia.org/wiki/Document-oriented_database),在Key/Value数据库中,由于没有预定义的值结构,所以只能针对Key执行查询,这大大限制了使用场景。**文档型数据库将Value扩展为XML、JSON(比如MongoDB)等数据结构,于是允许使用者在文档型数据库的内部解析复合型的Value结构,再通过其中的单一值进行查询,这就兼具了部分关系数据库的功能。**
|
||||
|
||||
[列式数据库](https://en.wikipedia.org/wiki/Column-oriented_DBMS),比如[[第22讲]](https://time.geekbang.org/column/article/254600) 介绍过的Cassandra。列式数据库基于Key来映射行,再通过列名进行二级映射,同时它基于列来安排存储的拓扑结构,这样当仅读写大量行中某个列时,操作的数据节点、磁盘非常集中,磁盘IO、网络IO都会少很多。列式数据库的应用场景非常有针对性,比如博客文章标签的行数很多,但在做数据分析时往往只读取标签列,这就很适合使用列式数据库。再比如,**通过倒排索引实现了全文检索的ElasticSearch,就适合使用列式存储存放Doc Values,这样做排序、聚合时非常高效。**
|
||||
|
||||
[图数据库](https://en.wikipedia.org/wiki/Graph_database),在社交关系、知识图谱等场景中,携带各种属性的边可以表示节点间的关系,由于节点的关系数量多,而且非常容易变化,所以关系数据库的实现成本很高,而图数据库既没有固定的数据模型,遍历关系的速度也非常快,很适合处理这类问题。当然,我们日常见到的主要是前3类NoSQL数据库。
|
||||
|
||||
相对于关系数据库,NoSQL在性能和易用性上都有明显的优点。
|
||||
|
||||
首先我们来看可用性及性能,这是NoSQL数据库快速发展的核心原因:
|
||||
|
||||
- NoSQL数据库的可伸缩性都非常好。虽然许多文档型、列式数据库都提供了类SQL语言接口,但这只是为了降低用户的学习成本,它们对跨节点事务的支持极其有限。因此,这些NoSQL数据库可以放开手脚,基于Key/Value模型沿AKF Z轴将系统扩展到上万个节点。
|
||||
- 在数据基于Key分片后,很容易通过[[第28讲]](https://time.geekbang.org/column/article/264122) 介绍过的MapReduce思想,提高系统的计算能力。比如,MongoDB很自然的就在查询接口中,提供了[MapReduce](https://docs.mongodb.com/manual/core/map-reduce/) 函数。
|
||||
- 通过冗余备份,NoSQL可以提供优秀的容灾能力。比如,Redis、Cassandra等数据库,都可以基于[[第22讲]](https://time.geekbang.org/column/article/254600) 介绍过的NWR算法,灵活地调整CAP权重。
|
||||
- 如果每个Key中Value存放的复合数据已经能满足全部业务需求,那么NoSQL的单机查询速度也会优于关系数据库。
|
||||
|
||||
其次再来看易用性,这主要体现在我们可以低成本地变更Value结构。虽然NoSQL数据库支持复合型Value结构,但并不限定结构类型。比如,文档型数据库中,同一个表中的两行数据,其值可以是完全不同的JSON结构;同样的,列式数据库中两行数据也可以拥有不同的列数。因此,当数据结构改变时,只需要修改应用层操作数据的代码,并不需要像关系数据库那样同时修改表结构以及迁移数据。
|
||||
|
||||
那么,到底该如何选择关系数据库与NoSQL数据库呢?其实,沿着“单一值关系”这一线索,我们已经找到了各自适用的场景。
|
||||
|
||||
如果多个业务数据间互相关联,我们需要从多个不同的角度分析、计算,并保持住相关数据的一致性,那么关系数据库最为适合。一旦数据行数到了亿级别以上,就需要放弃单一值结构,将单行数据聚合为复合结构,放在可以自由伸缩的NoSQL数据库中。此时,我们无法寄希望于NoSQL数据库提供ACID事务,只能基于二段式提交等算法在应用层代码中自行实现事务。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们介绍了关系数据库与NoSQL数据库各自的特点及其适用场景。
|
||||
|
||||
关系数据库通过行、列交汇处的单一值,实现了多种数据间的关联。通过统一的SQL接口,用户可以在数据库中实现复杂的计算任务。为了维持关联数据间的一致性,关系数据库提供了拥有ACID特性的事务,提升了应用层的开发效率。
|
||||
|
||||
虽然单一值无法映射内存中的复合数据结构,但通过ORM框架,关系数据库可以将表映射为面向对象编程中的类,将每行数据映射为对象,继续降低开发成本。然而,关系数据库是为单机设计的,一旦将事务延伸到分布式系统中,执行成本就会高到影响基本的可用性。因此,关系数据库的可伸缩性是很差的。
|
||||
|
||||
NoSQL数据库基于Key/Value数据模型,可以提供几乎无限的可伸缩性。同时,将Value值进一步设计为复合结构后,既可以增加查询方式的多样性,也可以通过MapReduce提升系统的计算能力。实际上,关系数据库与每一类NoSQL数据库都有明显的优缺点,我们可以从数据模型、访问方式、数据容量上观察它们,结合具体的应用场景权衡取舍。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一道讨论题。你在选择NoSQL与关系数据库时是如何考虑的?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你有所收获,也欢迎你把今天的内容分享给身边的朋友。
|
||||
113
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐3 | 大厂面试到底在考些什么?.md
Normal file
113
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐3 | 大厂面试到底在考些什么?.md
Normal file
@@ -0,0 +1,113 @@
|
||||
<audio id="audio" title="加餐3 | 大厂面试到底在考些什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/25/f4/25898f60f11ee83113ec3959db806df4.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。这节课我们换换脑子,聊一个相对轻松点的话题——大厂面试。
|
||||
|
||||
2004年我毕业于西安交通大学计算机科学与技术专业,此后16年来既在华为、腾讯、思科、阿里巴巴这样的大厂工作过,也在两家几十人的创业公司工作过,在这种对比下,我对大厂面试的考核点很有心得体会。
|
||||
|
||||
作为候选人我拿到过很多大厂offer,作为面试官也考核过数百位同学的技术水平,因此,今天这节课我会兼顾面试官与候选人的视角,分享如何拿下一线大厂的技术面试。
|
||||
|
||||
## 大厂面试到底在考些什么?
|
||||
|
||||
相信绝大多数同学都经历过技术面试,你肯定发现,小厂与大厂的面试题差距很大,其中,**大厂特别关注程序性能**,为什么呢?在我看来有这样3个原因:
|
||||
|
||||
首先,大厂产品的用户基数大,任何微小的性能提升都会被庞大的用户数放大。因此,员工具备性能优先的思维,有利于提升产品竞争力。
|
||||
|
||||
其次,大厂经常会重新造轮子,不管原因是什么,造轮子都需要深厚的底层知识,而性能是其中的核心要素。而且,愿意花时间去掌握底层知识的候选人,学习动力更强,潜力也会更好。
|
||||
|
||||
最后,大厂待遇好,成长空间大,是典型的稀缺资源,大家都打破了头往里挤。在这样优中选优的情况下,有区分度的性能题就是最好的面试题,通过快速筛选不同档次的候选人,可以节约招聘成本。
|
||||
|
||||
那么,对于候选人来说,到底怎样才能答好性能面试题呢?首先,背网上流传的大厂面试题,绝对不是个好主意,这是因为大厂的面试题并不是固定的,往往都是考官自备的面试题,这与每位考官的个人经历有关,所以你押中面试题的概率非常低。
|
||||
|
||||
而且,面试并不是为了找出最优秀的那位候选人(这样的候选人手里往往拿着许多优厚的offer,签下他并不容易),而是将大量的候选人分出层次,再按照团队的业务发展、技术方向、薪资规划来发放offer。这样的话,面试题就必须是开放的,在各个层次上都有考核点,有内涵更有外延,任何一个点考官都可以展开了聊上个把小时。你背的知识点,未必是考官感兴趣会展开了问的点。
|
||||
|
||||
所以,大厂面试考核的是技能、潜力,而不是知识,面试前的刷题不是为了背答案,而是通过练习来提升技能!
|
||||
|
||||
下面我就以1道算法题为例,带你看看大厂面试中都在考哪些技能点。
|
||||
|
||||
## 举例:1道算法题可以考核多少知识点?
|
||||
|
||||
题目:请用你熟悉的一门编程语言实现Fibnacci函数。
|
||||
|
||||
Fibnacci是中学代数提过的一个函数,在自然界中广泛存在,美学中的黄金分割点也与它相关。可能有些同学还不熟悉Fibnacci函数,它的定义如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fc/03/fc992b73d1ac9c00d2a377a746f46803.png" alt="">
|
||||
|
||||
比如Fib(6)=Fib(5)+Fib(4)=5+3=8。
|
||||
|
||||
我个人非常喜欢用这道面试题,因为它有很好的区分度,至少能考核候选人6个方面的能力。
|
||||
|
||||
首先它像所有编码题一样,可以判断候选人是否至少熟练使用一门编程语言,特别是在不依赖编辑器错误提示的情况下,能不能在白板上手写出高质量的代码。这通常是大厂的基本要求。
|
||||
|
||||
其次,Fibnacci函数很显然非常适合用递归函数实现,大多数候选人都可以写出递归函数,比如:
|
||||
|
||||
```
|
||||
Fib(n) {
|
||||
if (n <= 0) return 0;
|
||||
if (n == 1) return 1;
|
||||
return Fib(n-1)+Fib(n-2);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到递归函数其实并不难,然而面试官会很自然地追问2个问题,这才是考核点:
|
||||
|
||||
首先,递归函数在系统层面有什么问题?
|
||||
|
||||
这其实是在考你是否知道栈溢出问题。每调用一次函数,需要将函数参数、返回值压栈,而操作系统为每个线程分配的栈空间是有限的,比如Linux通常只有8MB。因此,当数字n过大时,很容易导致StackOverFlow错误。
|
||||
|
||||
其次,这段递归代码的时间复杂度是多少?
|
||||
|
||||
如果你还没有时间复杂度的概念,请再次阅读[[第3讲]](https://time.geekbang.org/column/article/232351)。《算法导论》中的递归树很适合用于猜测算法的时间复杂度,下图是Fibnacci(6)展开的计算量,可见,递归树中所有节点的数量,就是递归函数的时间复杂度:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/83/ca/83dd19f2399619474e6abb224714beca.png" alt="" title="图片来源:https://medium.com/launch-school/recursive-fibonnaci-method-explained-d82215c5498e">](https://medium.com/launch-school/recursive-fibonnaci-method-explained-d82215c5498e)
|
||||
|
||||
如果你仔细数一数,会发现随着n的增大,节点数大致接近2<sup>n</sup> 个,因此其时间复杂度为O(2<sup>n</sup>)。当然,树中的节点数与Fibnacci数列相关,大致为O(1.618<sup>n</sup>)个(书中还介绍了2种求解递归函数时间复杂度的方法:代入法和主定理,请参考书中第4章)。
|
||||
|
||||
最后,这道题还可以考察候选人能否运用逆向思维,通过一个循环实现递归函数的效果。递归法的时间复杂度之所以达到了O(2<sup>n</sup>),是因为做了大量的重复运算。比如求Fibnacci(6)时,Fibnacci(2)重复执行了4次。如果对n从小向大做递推运算,重复使用已经计算完成的数字,就能大大减少计算量,如下所示:
|
||||
|
||||
```
|
||||
Fib(n) {
|
||||
if (n <= 0) return 0;
|
||||
if (n == 1) return 1;
|
||||
prev = 0;
|
||||
cur = 1;
|
||||
for (i = 2; i <= n; i = i + 1) {
|
||||
next = cur + prev;
|
||||
prev = cur;
|
||||
cur = next;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,只通过1次循环,就能以O(n)的时间复杂度完成Fibnacci数列的计算。这与动态规划的实现思路是一致的。注意,使用递推法时如果不小心,会采用数组存放计算出的每个结果,这样空间复杂度就从O(1)到了O(n),这也是一个考核点。
|
||||
|
||||
当然,我们还可以通过公式直接计算出数列的值:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/f5/a3a4f74473a79053d8b6d32acb2f80f5.png" alt="">
|
||||
|
||||
但纯粹背下这个公式并没有意义,因为你写出来后,会有2个问题等着你:
|
||||
|
||||
首先,如何使用高等数学推导出这个公式?如果你能够答出来,那么证明你的数学功底很好,在数据挖掘、人工智能人才短缺的当下,这对你在大厂内部的职业发展很有好处!
|
||||
|
||||
其次,上面这个公式有大量的浮点运算,在数学中数字可以是无限长的,但在计算机工程体系中,任何类型都有最大长度(比如浮点类型通常是64个比特位),所以对于根号5这样的无理数,小数点后的数字会出现四舍五入而不精确,而且当n非常大时,有限的内存还会导致数据溢出。因此上述的公式法并不能直接使用,如果你能回答出适合计算机使用的矩阵解法(请参考[wiki](https://en.wikipedia.org/wiki/Fibonacci_number),这里不再列出矩阵法的细节),那就更完美了。
|
||||
|
||||
可见,这么一道简单的题目,就可以考察递归编码能力、递推解法、公式解法、矩阵解法、时间复杂度的推算、计算机浮点运算特性等许多知识点。而且,随着你回答时涉及到更多的知识点,面试官会基于自己的经验进一步延伸提问。所以我不推荐你押题,把基础技能掌握好才是最有效的面试备战法。
|
||||
|
||||
这道题目只是入门的算法题,如果你应聘的是Google、头条之类非常重视算法的公司,那么你还必须掌握动态规划、贪心算法、图算法等高级算法等等。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们只是详细讲解了算法题,其实从系统工程、网络协议上也可以从性能优化这个方向快速区分出候选人的能力水平。比如:
|
||||
|
||||
- 我在开课直播时提出的关于并发的问题,多进程和多线程、协程实现的并发编程,各自的优势和劣势是什么(你可以参考[[第5讲]](https://time.geekbang.org/column/article/233629))?
|
||||
- 或者TCP连接的close_wait状态出现时应当如何解决(你可以参考[[第10讲]](https://time.geekbang.org/column/article/238388))?
|
||||
|
||||
这些都在考察你如何通过操作系统,协调使用系统资源的能力。
|
||||
|
||||
另外,除了硬核的知识技能外,你也不要忽略软技能,这也能在面试中加分。比如,任何大厂都非常强调团队协作,如果员工遇到难题时,只会闷头冥想,这样的时间成本太高,既有可能延迟项目进度,也不利于充分发挥大厂高手如林、资源丰富的优势。所以,如果你在面试中,表现出善于沟通、乐于求助的特性,都是加分项。
|
||||
|
||||
以上就是我对大厂面试的一些沉淀和思考,不知道你有没有感同身受呢?如果你在面试中也遇到过一些特别开放、有区分度的面试题,或者作为面试官你有哪些喜欢用的面试题,欢迎分享出来,我们一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课有所收获,也欢迎把它分享给你的朋友。
|
||||
198
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐4|百万并发下Nginx的优化之道.md
Normal file
198
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐4|百万并发下Nginx的优化之道.md
Normal file
@@ -0,0 +1,198 @@
|
||||
<audio id="audio" title="加餐4|百万并发下Nginx的优化之道" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f9/05/f9059e9d4546b7cc008c871345767805.mp3"></audio>
|
||||
|
||||
你好,我是专栏编辑冬青。今天的课程有点特别,作为一期加餐,我为你带来了陶辉老师在GOPS 2018 · 上海站的分享,以文字讲解+ PPT的形式向你呈现。今天的内容主要集中在Nginx的性能方面,希望能给你带来一些系统化的思考,帮助你更有效地去做Nginx。
|
||||
|
||||
## 优化方法论
|
||||
|
||||
今天的分享重点会看这样两个问题:
|
||||
|
||||
- 第一,如何有效使用每个连接分配的内存,以此实现高并发。
|
||||
- 第二,在高并发的同时,怎样提高QPS。
|
||||
|
||||
当然,实现这两个目标,既可以从单机中的应用、框架、内核优化入手,也可以使用类似F5这样的硬件设备,或者通过DNS等方案实现分布式集群。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1a/24/1a69ba079c318c227c9ccff842714424.jpg" alt="">
|
||||
|
||||
而Nginx最大的限制是网络,所以将网卡升级到万兆,比如10G或者40G吞吐量就会有很大提升。作为静态资源、缓存服务时,磁盘也是重点关注对象,比如固态硬盘的IOPS或者BPS,要比不超过1万转每秒的机械磁盘高出许多。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/2c/4aecd5772e4d164dc414d1f473440f2c.jpg" alt="">
|
||||
|
||||
这里我们重点看下CPU,如果由操作系统切换进程实现并发,代价太大,毕竟每次都有5微秒左右的切换成本。Nginx将其改到进程内部,由epoll切换ngx_connection_t连接的处理,成本会非常低。OpenResty切换Lua协程,也是基于同样的方式。这样,CPU的计算力会更多地用在业务处理上。
|
||||
|
||||
从整体上看,只有充分、高效地使用各类IT资源,才能减少RTT时延、提升并发连接。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/24/9d4721babd048bed55968c4f8bbeaf24.jpg" alt="">
|
||||
|
||||
## 请求的“一生”
|
||||
|
||||
只有熟悉Nginx处理HTTP请求的流程,优化时才能做到有的放矢。
|
||||
|
||||
首先,我们要搞清楚Nginx的模块架构。Nginx是一个极其开放的生态,它允许第三方编写的C模块与框架协作,共同处理1个HTTP请求。比如,所有的请求处理模块会构成一个链表,以PipeAndFilter这种架构依次处理请求。再比如,生成HTTP响应后,所有过滤模块也会依次加工。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8b/4d/8bb5620111efd7086b3fa89b1b7a3d4d.jpg" alt="">
|
||||
|
||||
### 1. 请求到来
|
||||
|
||||
试想一下,当用户请求到来时,服务器到底会做哪些事呢?首先,操作系统内核会将完成三次握手的连接socket,放入1个ACCEPT队列(如果打开了reuseport,内核会选择某个worker进程对应的队列),某个Nginx Worker进程事件模块中的代码,需要调用accept函数取出socket。
|
||||
|
||||
建立好连接并分配ngx_connection_t对象后,Nginx会为它分配1个内存池,它的默认大小是512字节(可以由connection_pool_size指令修改),只有这个连接关闭的时候才会去释放。
|
||||
|
||||
接下来Nginx会为这个连接添加一个默认60秒(client_header_timeout指令可以配置)的定时器,其中,需要将内核的socket读缓冲区里的TCP报文,拷贝到用户态内存中。所以,此时会将连接内存池扩展到1KB(client_header_buffer_size指令可以配置)来拷贝消息内容,如果在这段时间之内没有接收完请求,则返回失败并关闭连接。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/63/171329643c8f003yy47bcd0d1b5f5963.jpg" alt="">
|
||||
|
||||
### 2. 处理请求
|
||||
|
||||
当接收完HTTP请求行和HEADER后,就清楚了这是一个什么样的请求,此时会再分配另一个默认为4KB(request_pool_size指令可以修改,这里请你思考为什么这个请求内存池比连接内存池的初始字节数多了8倍?)的内存池。
|
||||
|
||||
Nginx会通过协议状态机解析接收到的字符流,如果1KB内存还没有接收到完整的HTTP头部,就会再从请求内存池上分配出32KB,继续接收字符流。其中,这32KB默认是分成4次分配,每次分配8KB(可以通过large_client_header_buffers指令修改),这样可以避免为少量的请求浪费过大的内存。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/64/f8b2e2c3734188c4f00e8002f0966964.jpg" alt="">
|
||||
|
||||
接下来,各类HTTP处理模块登场。当然,它们并不是简单构成1个链表,而是通过11个阶段构成了一个二维链表。其中,第1维长度是与Web业务场景相关的11个阶段,第2维的长度与每个阶段中注册的HTTP模块有关。
|
||||
|
||||
这11个阶段不用刻意死记,你只要掌握3个关键词,就能够轻松地把他们分解开。首先是5个阶段的预处理,包括post_read,以及与rewrite重写URL相关的3个阶段,以及URL与location相匹配的find_config阶段。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a0/86/a048b12f5f79fee43856ecf449387786.jpg" alt="">
|
||||
|
||||
其次是访问控制,包括限流限速的preaccess阶段、控制IP访问范围的access阶段和做完访问控制后的post_access阶段。
|
||||
|
||||
最后则是内容处理,比如执行镜象分流的precontent阶段、生成响应的content阶段、记录处理结果的log阶段。
|
||||
|
||||
每个阶段中的HTTP模块,会在configure脚本执行时就构成链表,顺序地处理HTTP请求。其中,HTTP框架允许某个模块跳过其后链接的本阶段模块,直接进入下一个阶段的第1个模块。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/fb/0ea57bd24be1fdae15f860b926cc25fb.jpg" alt="">
|
||||
|
||||
content阶段会生成HTTP响应。当然,其他阶段也有可能生成HTTP响应返回给客户端,它们通常都是非200的错误响应。接下来,会由HTTP过滤模块加工这些响应的内容,并由write_filter过滤模块最终发送到网络中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/39/f4fc5bc3ef64498ac6882a902f927539.jpg" alt="">
|
||||
|
||||
### 3. 请求的反向代理
|
||||
|
||||
Nginx由于性能高,常用来做分布式集群的负载均衡服务。由于Nginx下游通常是公网,网络带宽小、延迟大、抖动大,而上游的企业内网则带宽大、延迟小、非常稳定,因此Nginx需要区别对待这两端的网络,以求尽可能地减轻上游应用的负载。
|
||||
|
||||
比如,当你配置proxy_request_buffering on指令(默认就是打开的)后,Nginx会先试图将完整的HTTP BODY接收完,当内存不够(默认是16KB,你可以通过client_body_buffer_size指令修改)时还会保存到磁盘中。这样,在公网上漫长的接收BODY流程中,上游应用都不会有任何流量压力。
|
||||
|
||||
接收完请求后,会向上游应用建立连接。当然,Nginx也会通过定时器来保护自己,比如建立连接的最长超时时间是60秒(可以通过proxy_connect_timeout指令修改)。
|
||||
|
||||
当上游生成HTTP响应后,考虑到不同的网络特点,如果你打开了proxy_buffering on(该功能也是默认打开的)功能,Nginx会优先将内网传来的上游响应接收完毕(包括存储到磁盘上),这样就可以关闭与上游之间的TCP连接,减轻上游应用的并发压力。最后再通过缓慢的公网将响应发送给客户端。当然,针对下游客户端与上游应用,还可以通过proxy_limit_rate与limit_rate指令限制传输速度。如果设置proxy_buffering off,Nginx会从上游接收到一点响应,就立刻往下游发一些。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/90/9c3d5be8ecc6b287a0cb4fc09ab0c690.jpg" alt="">
|
||||
|
||||
### 4. 返回响应
|
||||
|
||||
当生成HTTP响应后,会由注册为HTTP响应的模块依次加工响应。同样,这些模块的顺序也是由configure脚本决定的。由于HTTP响应分为HEADER(包括响应行和头部两部分)、BODY,所以每个过滤模块也可以决定是仅处理HEADER,还是同时处理HEADER和BODY。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/e8/2506dfed0c4792a7a1be390c1c7979e8.jpg" alt="">
|
||||
|
||||
因此,OpenResty中会提供有header_filter_by_lua和body_filter_by_lua这两个指令。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c4/81/c495fb95fed3b010a3fcdd26afd08c81.jpg" alt="">
|
||||
|
||||
## 应用层优化
|
||||
|
||||
### 1. 协议
|
||||
|
||||
应用层协议的优化可以带来非常大的收益。比如HTTP/1 HEADER的编码方式低效,REST架构又放大了这一点,改为HTTP/2协议后就大有改善。Nginx对HTTP/2有良好的支持,包括上游、下游,以及基于HTTP/2的gRPC协议。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/91/eebe2bcd1349d51ee1d3cb60a238a391.jpg" alt="">
|
||||
|
||||
### 2. 压缩
|
||||
|
||||
对于无损压缩,信息熵越大,压缩效果就越好。对于文本文件的压缩来说,Google的Brotli就比Gzip效果好,你可以通过[https://github.com/google/ngx_brotli](https://github.com/google/ngx_brotli) 模块,让Nginx支持Brotli压缩算法。
|
||||
|
||||
对于静态图片通常会采用有损压缩,这里不同压缩算法的效果差距更大。目前Webp的压缩效果要比jpeg好不少。对于音频、视频则可以基于关键帧,做动态增量压缩。当然,只要是在Nginx中做实时压缩,就会大幅降低性能。除了每次压缩对CPU的消耗外,也不能使用sendfile零拷贝技术,因为从磁盘中读出资源后,copy_filter过滤模块必须将其拷贝到内存中做压缩,这增加了上下文切换的次数。更好的做法是提前在磁盘中压缩好,然后通过add_header等指令在响应头部中告诉客户端该如何解压。
|
||||
|
||||
### 3. 提高内存使用率
|
||||
|
||||
只在需要时分配恰当的内存,可以提高内存效率。所以下图中Nginx提供的这些内存相关的指令,需要我们根据业务场景谨慎配置。当然,Nginx的内存池已经将内存碎片、小内存分配次数过多等问题解决了。必要时,通过TcMalloc可以进一步提升Nginx申请系统内存的效率。
|
||||
|
||||
同样,提升CPU缓存命中率,也可以提升内存的读取速度。基于cpu cache line来设置哈希表的桶大小,就可以提高多核CPU下的缓存命中率。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/a7/aa7727a2dbf6a3a22c2bf933327308a7.jpg" alt="">
|
||||
|
||||
### 4. 限速
|
||||
|
||||
作为负载均衡,Nginx可以通过各类模块提供丰富的限速功能。比如limit_conn可以限制并发连接,而limit_req可以基于leacky bucket漏斗原理限速。对于向客户端发送HTTP响应,可以通过limit_rate指令限速,而对于HTTP上游应用,可以使用proxy_limit_rate限制发送响应的速度,对于TCP上游应用,则可以分别使用proxy_upload_rate和proxy_download_rate指令限制上行、下行速度。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/af/3e7dbd21efc06b6721ea7b0c08cd95af.jpg" alt="">
|
||||
|
||||
### 5. Worker间负载均衡
|
||||
|
||||
当Worker进程通过epoll_wait的读事件获取新连接时,就由内核挑选1个Worker进程处理新连接。早期Linux内核的挑选算法很糟糕,特别是1个新连接建立完成时,内核会唤醒所有阻塞在epoll_wait函数上的Worker进程,然而,只有1个Worker进程,可以通过accept函数获取到新连接,其他进程获取失败后重新休眠,这就是曾经广为人知的“惊群”现象。同时,这也很容易造成Worker进程间负载不均衡,由于每个Worker进程绑定1个CPU核心,当部分Worker进程中的并发TCP连接过少时,意味着CPU的计算力被闲置了,所以这也降低了系统的吞吐量。
|
||||
|
||||
Nginx早期解决这一问题,是通过应用层accept_mutex锁完成的,在1.11.3版本前它是默认开启的:accept_mutex on;
|
||||
|
||||
其中负载均衡功能,是在连接数达到worker_connections的八分之七后,进行次数限制实现的。
|
||||
|
||||
我们还可以通过accept_mutex_delay配置控制负载均衡的执行频率,它的默认值是500毫秒,也就是最多500毫秒后,并发连接数较少的Worker进程会尝试处理新连接:accept_mutex_delay 500ms;
|
||||
|
||||
当然,在1.11.3版本后,Nginx默认关闭了accept_mutex锁,这是因为操作系统提供了reuseport(Linux3.9版本后才提供这一功能)这个更好的解决方案。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/7a/5f5f833b51f322ae963bde06c7f66f7a.jpg" alt="">
|
||||
|
||||
图中,横轴中的default项开启了accept_mutex锁。我们可以看到,使用reuseport后,QPS吞吐量有了3倍的提高,同时处理时延有明显的下降,特别是时延的波动(蓝色的标准差线)有大幅度的下降。
|
||||
|
||||
### 6. 超时
|
||||
|
||||
Nginx通过红黑树高效地管理着定时器,这里既有面对TCP报文层面的配置指令,比如面对下游的send_timeout指令,也有面对UDP报文层面的配置指令,比如proxy_responses,还有面对业务层面的配置指令,比如面对下游HTTP协议的client_header_timeout。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fy/f7/fyyb03d85d4b8312873b476888a1a0f7.jpg" alt="">
|
||||
|
||||
### 7. 缓存
|
||||
|
||||
只要想提升性能必须要在缓存上下工夫。Nginx对于七层负载均衡,提供各种HTTP缓存,比如http_proxy模块、uwsgi_proxy模块、fastcgi_proxy模块、scgi_proxy模块等等。由于Nginx中可以通过变量来命名日志文件,因此Nginx很有可能会并行打开上百个文件,此时通过open_file_cache,Nginx可以将文件句柄、统计信息等写入缓存中,提升性能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/15/452d0ecf0fcd822c69e1df859fdeb115.jpg" alt="">
|
||||
|
||||
### 8. 减少磁盘IO
|
||||
|
||||
Nginx虽然读写磁盘远没有数据库等服务要多,但由于它对性能的极致追求,仍然提供了许多优化策略。比如为方便统计和定位错误,每条HTTP请求的执行结果都会写入access.log日志文件。为了减少access.log日志对写磁盘造成的压力,Nginx提供了批量写入、实时压缩后写入等功能,甚至你可以在另一个服务器上搭建rsyslog服务,然后配置Nginx通过UDP协议,将access.log日志文件从网络写入到 rsyslog中,这完全移除了日志磁盘IO。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/da/d55cb817bb727a097ffc4dfe018539da.jpg" alt="">
|
||||
|
||||
## 系统优化
|
||||
|
||||
最后,我们来看看针对操作系统内核的优化。
|
||||
|
||||
首先是为由内核实现的OSI网络层(IP协议)、传输层(TCP与UDP协议)修改影响并发性的配置。毕竟操作系统并不知道自己会作为高并发服务,所以很多配置都需要进一步调整。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/15/d58dc3275745603b7525f690479d6615.jpg" alt="">
|
||||
|
||||
其次,优化CPU缓存的亲和性,对于Numa架构的服务器,如果Nginx只使用一半以下的CPU核心,那么就让Worker进程只绑定一颗CPU上的核心。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/c2/8f073d7222yy8e823ce1a7c16b945fc2.jpg" alt="">
|
||||
|
||||
再次,调整默认的TCP网络选项,更快速地发现错误、重试、释放资源。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/32/d3/326cf5a1cb8a8522b89eb19e7ca357d3.jpg" alt="">
|
||||
|
||||
还可以减少TCP报文的往返次数。比如FastOpen技术可以减少三次握手中1个RTT的时延,而增大初始拥塞窗口可以更快地达到带宽峰值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6f/09/6f0de237bd54cf2edf8cdcfb606c8c09.jpg" alt="">
|
||||
|
||||
还可以提高硬件资源的利用效率,比如当你在listen指令后加入defer选项后,就使用了TCP_DEFER_ACCEPT功能,这样epoll_wait并不会返回仅完成三次握手的连接,只有连接上接收到的TCP数据报文后,它才会返回socket,这样Worker进程就将原本2次切换就降为1次了,虽然会牺牲一些即时性,但提高了CPU的效率。
|
||||
|
||||
Linux为TCP内存提供了动态调整功能,这样高负载下我们更强调并发性,而低负载下则可以更强调高传输速度。
|
||||
|
||||
我们还可以将小报文合并后批量发送,通过减少IP与TCP头部的占比,提高网络效率。在nginx.conf文件中打开tcp_nopush、tcp_nodelay功能后,都可以实现这些目的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/04/ac69ce7af1abbf4df4bc0b42f288d304.jpg" alt="">
|
||||
|
||||
为了防止处理系统层网络栈的CPU过载,还可以通过多队列网卡,将负载分担到多个CPU中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/82/6bacf6f3cyy3ffd730c6eb2f664fe682.jpg" alt="">
|
||||
|
||||
为了提高内存、带宽的利用率,我们必须更精确地计算出BDP,也就是通过带宽与ping时延算出的带宽时延积,决定socket读写缓冲区(影响滑动窗口大小)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/7d/1389fd8fea84cef63e6438de1e18587d.jpg" alt="">
|
||||
|
||||
Nginx上多使用小于256KB的小内存,而且我们通常会按照CPU核数开启Worker进程,这样一种场景下,TCMalloc的性能要远高于Linux默认的PTMalloc2内存池。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/a8/56267e1yye31a7af808c8793c894b0a8.jpg" alt="">
|
||||
|
||||
作为Web服务器,Nginx必须重写URL以应对网址变化,或者应用的维护,这需要正则表达式的支持。做复杂的URL或者域名匹配时,也会用到正则表达式。优秀的正则表达式库,可以提供更好的执行性能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/f0/556b3359b43f2a641ef2bc4a13a334f0.jpg" alt="">
|
||||
|
||||
以上就是今天的加餐分享,有任何问题欢迎在留言区中提出。
|
||||
142
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐5 | 如何理解分布式系统?.md
Normal file
142
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐5 | 如何理解分布式系统?.md
Normal file
@@ -0,0 +1,142 @@
|
||||
<audio id="audio" title="加餐5 | 如何理解分布式系统?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/89/41/8919966c3d3e5f51394646efb5bcf241.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。课程到现在也已经接近尾声,我看到有的同学已经开始在掉队了。所以今天这讲,我准备来回答大家的一些高频问题。
|
||||
|
||||
咱们目前正在学习的这一模块叫“分布式系统优化”,我给你讲了监控、CAP、负载均衡、一致性哈希,说实话,这些知识都不简单,你如果觉得有点难,那也别气馁,因为它确实得多琢磨,我自己一开始学习的时候也是这样。
|
||||
|
||||
不过,我发现,在这个模块中,很多同学似乎对分布式有什么误解,有的人说分布式就是多台机器,有的人说分布式就是微服务,总之,大家各有自己的理解。于是,我就想着给你写篇加餐,来系统聊聊这个话题。
|
||||
|
||||
不过,在查资料的过程中,我发现InfoQ上已经有一篇文章很好地回答了这个问题。于是,经过编辑冬青的努力,我们找到了作者张帆,申请到了那篇文章的授权,在这里交付给你。
|
||||
|
||||
如果现在让你阐述一下什么是“分布式系统”,你脑子里第一下跳出来的是什么?我想,此时可以用苏东坡先生的一句诗,来形象地描述大家对分布式系统的认识:
|
||||
|
||||
>
|
||||
横看成岭侧成峰,远近高低各不同。
|
||||
|
||||
|
||||
## “分布式系统”等于 SOA、ESB、微服务这些东西吗?
|
||||
|
||||
我觉得每个人脑子里一下子涌现出来的肯定是非常具象的东西,就像下面这些:
|
||||
|
||||
>
|
||||
“分布式系统”等于 SOA、ESB、微服务这些东西吗?
|
||||
|
||||
|
||||
如果你一下子想到的是 XX 中心、XX 服务,意味着你把服务化的模式(SOA、ESB、微服务)和分布式系统错误地划上了等号。
|
||||
|
||||
那么,什么是“服务化”呢?服务化就像企业当中将相同岗位的人员划分到同一个部门管理,以此来收敛特定的工作入口,再进行二次分配,以提高人员利用率和劳动成果的复用度。服务化的本质是“分治”,而“分治”的前提是先要拆,然后才谈得上如何治。这时,高内聚、低耦合的思想在拆分过程中起到了一个非常重要的作用,因为这可以尽可能地降低拆分后不同组件间进行协作的复杂度。所以重要的是“怎么拆”,还有如何循序渐进地拆,而这个过程中你究竟是采用了何种服务化模式(比如 SOA、ESB、微服务等)并不是关键。
|
||||
|
||||
为什么说“怎么拆”最重要呢?我来举个例子,企业的组织架构包括三种模型:职能型、项目型、矩阵型。你可以把这里的企业理解为一个“分布式系统”,把后面的 3 种模型理解为这个分布式系统的 3 种形态。作为这个“系统”的所有人,你需要考虑如何拆分它,才能使得各功能组件相互之间可以更好地协作。假设,你要将一个总计 10000 名员工的企业按“职能型”拆分成 20 个部门,得到的结果是每个部门 500 人。
|
||||
|
||||
这时,如果工作是流水线式的上下游关系。一个部门完工了再交给下一个部门。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7c/de/7c2f6a058fc79b87b2a041819e392bde.jpg" alt="">
|
||||
|
||||
那么这时候是高内聚、低耦合的。因为一个工种只与另一个工种产生了关联,并且仅有一次。
|
||||
|
||||
但如果工作需要频繁的由不同职能的人员同时进行,就会导致同一个部门可能与多个部门产生联系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/8c/271961e7dd4ca0e9d626afb52e926d8c.jpg" alt="">
|
||||
|
||||
那么,这时是低内聚、高耦合的。因为一个工种需要和其他多个工种产生关联并且远不止一次。
|
||||
|
||||
可以看到服务化体现了“分治”的效果,这也是分布式系统的核心思想,因此从“分治”这个本质上来看,服务化的确是分布式系统,但分布式系统不仅仅停留在那些服务化的模式上。
|
||||
|
||||
我相信,你在工作中参与开发的任何软件系统,到处都存在着需要拆分的地方,除非它的功能极简到只需要计算一个 1+1。比如,当我们在电商平台点击“提交订单”的时候,会涉及生成订单、扣除积分、扣除库存等等动作。电商系统初期所有的功能可能都在一个系统里面,那么这些操作可以写在一个方法体里吗?我想只要代码能够成功运行,大部分人是不会管你怎么写的。但是如果这时需要增加一个红包功能呢?相信你或多或少遇到过在几百上千行代码中去增改功能的事情,其中的痛苦应该深有体会。
|
||||
|
||||
要解决这个问题就是要做拆分,通过梳理、归类,将不同的紧密相关的部分收敛到一个独立的逻辑体中,这个逻辑体可以是函数、类以及命名空间,等等。所以,从这个角度来说“分治”的问题其实早就存在我们的工作中,就看我们是否有去关注它了。因此,这并不只是我们在进行服务化时才需要考虑的问题。
|
||||
|
||||
那么如何才能做好这个事情,更好的拆分能力正是我们需要掌握的。如果只是因为看到其他人这么拆,我也这么拆,根据“二八原则”,或许“依样画葫芦”可以达到 80% 的契合度,但是往往那剩下的 20% 会是耗费我们 80% 精力的“大麻烦”。要知道,**只有掌握了核心主旨,才能更快地找到最理想的高内聚、低耦合方案。**
|
||||
|
||||
## “分布式系统”是各种中间件吗?
|
||||
|
||||
又或许,听到分布式系统,你想到了某某 MQ 框架、某某 RPC 框架、某某 DAL 框架,把运用中间件和分布式系统错误地划上了等号。
|
||||
|
||||
这里需要你搞清楚的是,中间件起到的是标准化的作用。中间件只是承载这些标准化想法的介质、工具,可以起到引导和约束的效果,以此大大降低系统的复杂度和协作成本。我们来分别看一下:
|
||||
|
||||
- MQ 框架标准化了不同应用程序间非实时异步通信的方式。
|
||||
- RPC 框架标准化了不同应用程序间实时通讯的方式。
|
||||
- DAL(Data Access Layer,数据访问层)框架标准化了应用程序和数据库之间通讯的方式。
|
||||
|
||||
所以,**虽然分布式系统中会运用中间件,但分布式系统却不仅仅停留在用了什么中间件上。**你需要清楚每一类中间件背后是对什么进行了标准化,它的目的是什么,带来了哪些副作用,等等。只有如此,你才能真正识别不同技术框架之间的区别,找到真正适合当前系统的技术框架。
|
||||
|
||||
那么标准是拍脑袋决定的吗?肯定不是,正如前面所说每一次标准化都是有目的的,需要产生价值。比如,大部分中间件都具备这样一个价值:
|
||||
|
||||
>
|
||||
为了在软件系统的迭代过程中,避免将精力过多地花费在某个子功能下众多差异不大的选项中。
|
||||
|
||||
|
||||
在现实中,这点更多时候出现在技术层面的中间件里,比如,数据库访问框架的作用是为了标准化操作不同数据库的差异,使得上层应用程序不用纠结于该怎么与 MySQL 交互或者该怎么与 SQL SERVER 交互。因为与业务相比,技术层面“稳定”多了,所以做标准化更有价值,更能获得长期收益。但“稳定”是相对的,哪怕单纯在业务层面也存在相对稳定的部分。
|
||||
|
||||
比如,你可以想象一下“盛饭”的场景,在大多数情况下其中相对稳定的是什么,不稳定的是什么。想完之后看下面的示例:
|
||||
|
||||
```
|
||||
...
|
||||
基类:人
|
||||
继承基类的子类:男人、女人
|
||||
|
||||
基类:碗
|
||||
继承基类的子类:大碗、小碗、汤碗
|
||||
|
||||
基类:勺子
|
||||
继承基类的子类:铁勺、陶瓷勺、塑料勺
|
||||
|
||||
function 盛饭(参数 人,参数 碗,参数 勺子){
|
||||
do 人拿起碗
|
||||
do 人拿起勺子
|
||||
do 人用勺子舀起饭
|
||||
do 人把勺子放到碗的上方并倒下
|
||||
|
||||
}
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
从这个示例里我们发现,不稳定的部分都已经成为变量了,那么剩下的这个方法体起到的作用和前面提到的中间件是一样的,它标准化了盛饭的过程。所以识别相对稳定的部分是什么,如何把它们提炼出来,并且围绕这些点进行标准化,才是我们需要掌握的能力。而锻炼这个能力和需要这个能力的地方同样并不局限于分布式系统。
|
||||
|
||||
**列举这些现象只是想说,我们在认知一个分布式系统的时候,内在胜于表象,掌握一个扎实的理论基本功更为重要。**而且,这些训练场无处不在。
|
||||
|
||||
## 海市蜃楼般的“分布式系统”
|
||||
|
||||
我相信,自从进入移动时代以来,各种高大上的系统架构图越来越频繁地出现,你的眼前充斥着各种主流、非主流的眼花缭乱的技术框架。你不由得肃然起敬一番,心中呐喊着:“对,这就是我想去的地方,我想参与甚至实现一个这样牛逼的分布式系统,再也不想每天只是增删改查了。”
|
||||
|
||||
得不到的事物总是美好的,但往往我们也会过度地高估它的美好。与此类似,高大上的架构图背后呈现的系统的确也是一个成熟分布式系统的样貌,但我们要清楚一点:罗马不是一日建成的。
|
||||
|
||||
而且,“分布式”这个词只是意味着形态上是散列状的,而“一分为二”和“一分为 N”本质上并没有区别。所以,很多小项目或者大型项目的初期所搭配的基础套餐“单程序 + 单数据库”,同样可以理解为分布式系统,其中遇到的问题很多同样也存在于成熟的分布式系统中。
|
||||
|
||||
想象一下,下面的场景是否在“单程序 + 单数据库”项目中出现过?
|
||||
|
||||
- log 记录执行成功,但是数据库的数据没发生变化;
|
||||
- 进程内的缓存数据更新了,但是数据库更新失败了。
|
||||
|
||||
这里我们停顿 30 秒,思考一下为什么会出现这些问题?
|
||||
|
||||
这里需要我们先思考一下“软件”是什么。 软件的本质是一套代码,而代码只是一段文字,除了提供文字所表述的信息之外,本身无法“动”起来。但是,想让它“动”起来,使其能够完成一件我们指定的事情,前提是需要一个宿主来给予它生命。这个宿主就是计算机,它可以让代码变成一连串可执行的“动作”,然后通过数据这个“燃料”的触发,“动”起来。这个持续的活动过程,又被描述为一个运行中的“进程”。
|
||||
|
||||
那么除了我们开发的系统是软件,数据库也是软件,前者负责运算,后者负责存储运算后的结果(也可称为“状态”),分工协作。
|
||||
|
||||
所以,“单程序 + 单数据库”为什么也是分布式系统这个问题就很明白了。因为我们所编写的程序运行时所在的进程,和程序中使用到的数据库所在的进程,并不是同一个。也因此导致了,让这两个进程(系统)完成各自的部分,而后最终完成一件完整的事,变得不再像由单个个体独自完成这件事那么简单。这就如“两人三足”游戏一样,如何尽可能地让外部看起来像是一个整体、自然地前进。
|
||||
|
||||
**所以,我们可以这么理解,涉及多个进程协作才能提供一个完整功能的系统就是“分布式系统”。**
|
||||
|
||||
那么再回到上面举例的两个场景,我们在思考“单程序 + 单数据库”项目中遇到的这些问题背后的原因和解决它的过程时,与我们在一个成熟的分布式系统中的遭遇是一样的,例如数据一致性。当然,这只是分布式系统核心概念的冰山一角。
|
||||
|
||||
维基百科对“分布式系统”的宏观定义是这样的:
|
||||
|
||||
>
|
||||
分布式系统是一种其组件位于不同的联网计算机上的系统,然后通过互相传递消息来进行通信和协调。为了达到共同的目标,这些组件会相互作用。
|
||||
|
||||
|
||||
我们可以再用大小关系来解释它:把需要进行大量计算的工程数据分割成小块,由多台计算机分别计算,然后将结果统一合并得出数据结论的科学。这本质上就是“分治”。而“单程序 + 单数据库”组合的系统也包含了至少两个进程,“麻雀虽小五脏俱全”,这也是“分布式系统”。
|
||||
|
||||
## 小结
|
||||
|
||||
现在,我们搞清楚了,**看待一个“分布式系统”的时候,内在胜于表象。以及,只要涉及多个进程协作才能提供一个完整功能的系统,就是“分布式系统”。**
|
||||
|
||||
我相信还有很多其他景象出现你的脑海中,但这大多数都是分布式系统的本质产生的“化学反应”,进而形成的结果。如果停留在这些表象上,那么我们最终将无法寻找到“分布式系统”的本质,也就无法得到真正的“道”,更不会真正具备驾驭这些形态各异的“分布式系统”的能力。
|
||||
|
||||
所以,希望你在学习分布式系统的时候,不要因追逐“术”而丢了“道”。没有“道”只有“术”是空壳,最终会走火入魔,学得越多,会越混乱,到处都是矛盾和疑惑。
|
||||
|
||||
以上就是张帆老师的分享,他的观点与本专栏也是不谋而合的。他认为:我们不仅要清楚具体场景下的最佳实践,还要明白为什么这样做,以及该如何去权衡不同方案。我们务必要修炼好自己的内功,形成一套完整的知识体系,完成核心“骨架”的塑造。而在此之后,你自己在课外学习时,就可以去填充“血肉”部分,逐渐丰满自己。未来,大家的区别就在于胖一点和瘦一点,但只要能很好地完成工作,胖瘦又有何影响呢?
|
||||
|
||||
最后,有关“分布式系统优化”你还有什么问题吗?欢迎在留言区中一起讨论。
|
||||
113
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐6|分布式系统的本质是什么?.md
Normal file
113
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐6|分布式系统的本质是什么?.md
Normal file
@@ -0,0 +1,113 @@
|
||||
<audio id="audio" title="加餐6|分布式系统的本质是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f7/bb/f759fec4d4bd781dbd4ed98892d080bb.mp3"></audio>
|
||||
|
||||
你好,我是编辑冬青。上一期加餐我们分享了张帆老师的一篇文章,从总体上聊了聊分布式系统,那作为系列分享,这期加餐我还为你带来了张帆老师的另一篇文章,进一步聊聊分布式系统的本质。这里交付给你,期待能给你带来更多的收获!
|
||||
|
||||
## 分布式系统的价值
|
||||
|
||||
谈到分布式系统的价值,可能就得从 1953 年说起了。在这一年,埃布·格罗希(Herb Grosch)提出了一个他观察得出的规律——Grosch 定律。维基百科中是这样描述的:
|
||||
|
||||
>
|
||||
计算机性能随着成本的平方而增加。如果计算机 A 的成本是计算机 B 的两倍,那么计算机 A 的速度应该是计算机 B 的四倍。
|
||||
|
||||
|
||||
这一论断与当时的大型机技术非常吻合,因而使得许多机构都尽其所能购买最大的单个大型机。其实,这也非常符合惯性思维,简单粗暴。
|
||||
|
||||
然而,1965 年高登·摩尔(Gordon Moore)提出了摩尔定律。经过几年的发展,人们发现摩尔定律的预测是符合现实的。这就意味着,集中式系统的运算能力每隔一段时间才能提升一倍。
|
||||
|
||||
那么,到底要隔多久呢?这个“时间”有很多版本,比如广为流传的 18 个月版本,以及 Gordon Moore 本人坚持的 2 年版本。这里我们不用太过纠结于实际情况到底是哪个“时间”版本,因为这其中隐含的意思更重要,即:**如果你的系统需承载的计算量的增长速度大于摩尔定律的预测,那么在未来的某一个时间点,集中式系统将无法承载你所需的计算量。**
|
||||
|
||||
而这只是一个内在因素,真正推动分布式系统发展的催化剂是“经济”因素。
|
||||
|
||||
人们发现,用廉价机器的集合组成的分布式系统,除了可以获得超过 CPU 发展速度的性能外,花费更低,具有更好的性价比,并且还可以根据需要增加或者减少所需机器的数量。
|
||||
|
||||
所以,我们得到一个新结论:**无论是要以低价格获得普通的性能,还是要以较高的价格获得极高的性能,分布式系统都能够满足。并且受规模效应的影响,系统越大,性价比带来的收益越高。**
|
||||
|
||||
之后,进入到互联网快速发展的时期,我们看到了分布式系统相比集中式系统的另一个更明显的优势:更高的可用性。例如,有 10 个能够承载 10000 流量的相同的节点,如果其中的 2 个挂了,只要实际流量不超过 8000,系统依然能够正常运转。
|
||||
|
||||
而这一切的价值,都是建立在分布式系统的“分治”和“冗余”之上的。从全局角度来看,这其实就是分布式系统的本质。
|
||||
|
||||
## 分治
|
||||
|
||||
分治,字面意思是“分而治之”,和我们的大脑在解决问题时的思考方式是一样的。我们可以将整个过程分为 3 步:分解 -> 治理 -> 归并。而分治思想的表现形式多样,分层、分块都是它的体现。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/75/d71fdd9d2a5ce164e82e210a6b4cda75.jpg" alt="">
|
||||
|
||||
这么做的好处是:问题越小越容易被解决,并且,只要解决了所有子问题,父问题就都可以被解决了。但是,这么做的时候,需要满足一个最重要的条件:**不同分支上的子问题,不能相互依赖,需要各自独立。**因为一旦包含了依赖关系,子问题和父问题之间就失去了可以被“归并”的意义。在软件开发领域,我们把这个概念称为“**耦合度**”和“**内聚度**”,这两个度量概念非常重要。
|
||||
|
||||
耦合度,指的是软件模块之间相互依赖的程度。比如,每次调用方法 A 之后都需要同步调用方法 B,那么此时方法 A 和 B 间的耦合度是高的。
|
||||
|
||||
内聚度,指的是模块内的元素具有的共同点的相似程度。比如,一个类中的多个方法有很多的共同之处,都是做支付相关的处理,那么这个类的内聚度是高的。
|
||||
|
||||
**内聚度通常与耦合度形成对比。低耦合通常与高内聚相关,反之亦然。**
|
||||
|
||||
所以,当你打算进行分治的时候,耦合度和内聚度就是需要考虑的重点。
|
||||
|
||||
下面我们来看个例子,体会一下耦合度和内聚度的含义(图仅用于表达含义,切勿作其他参考)。假设一个电商平台,为了应对更大的访问量,需要拆分一个同时包含商品、促销的系统。如果垂直拆分,是这样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e3/9d/e33bb57df050557a26a845369a01c49d.jpg" alt="">
|
||||
|
||||
而如果水平拆分,则是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/d5/6b9bd45b4feaeb8980918ff63e6746d5.jpg" alt="">
|
||||
|
||||
假如我们面对的场景仅仅是具体的商品详情展示页面,很显然,用水平拆分的效果会更好。因为传统的商品展示必然会同时展示促销,所以,如果用水平拆分,一次请求即可获取所有数据,内聚度非常高,并且此时模块间完全没有耦合。而如果是垂直拆分的话,就需要同时请求 2 个节点的数据并进行组合,因此耦合度更高、内聚度更差。
|
||||
|
||||
但是,这样的假设在真实的电商场景中是不存在的。从全局来看,订单、购物车、商品列表等许多其他场景也需要促销信息。并且这个时候我们发现引入了一些新的主体,诸如订单、购物车、商品分类等等。这个时候,水平拆分带来的好处越来越小,因为这样只解决了多个耦合中的一个,低耦合丧失了。并且随着商品和促销与外界的关联越来越多,必然有些场景仅仅涉及到商品和促销的其中一个,但是处理的时候,我们还需要避免受到另一个的影响。如此,高内聚也丧失了。
|
||||
|
||||
这个时候,反而通过垂直拆分可以获得更优的耦合度和内聚度,如下图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/2f/426f0ca71cb9d9aab8b08cb40cc0ef2f.jpg" alt="">
|
||||
|
||||
最高的耦合关系从原先的 6 降到了 4,并且商品和促销各自的处理相互不受影响。
|
||||
|
||||
所以,你会发现随着业务的变化,耦合度与内聚度也会发生变化。因此,及时地进行梳理和调整,可以避免系统的复杂度快速增长,这样才能最大程度地发挥“分治”带来的好处。
|
||||
|
||||
综上,分治可以简化解题的难度,通过高内聚、低耦合的协作关系达到更好的“性能与经济比”,来承载更大的流量。而“冗余”则带来了系统可以 7*24 小时不间断运作的希望。
|
||||
|
||||
## 冗余
|
||||
|
||||
这里的冗余并不等同于代码的冗余、无意义的重复劳动,而是我们有意去做的、人为增加的重复部分。其目的是容许在一定范围内出现故障,而系统不受影响,如下图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ed/f5/ed8ebdf12cb4bc7bafc150415fb766f5.jpg" alt="">
|
||||
|
||||
此时,我们可以将冗余的节点部署在一个独立的环境中。这个独立的环境,可能是处于同一个局域网内的不同主机,也可能是在不同的局域网,还可能是在不同的机房。很显然,它们能够应对的故障范围是逐步递增的。
|
||||
|
||||
但是,像这种单纯为了备用而做的冗余,最大的弊端是,如果没有出现故障,那么冗余的这部分资源就白白浪费了,不能发挥任何作用。所以,我们才提出了诸如双主多活、读写分离之类的概念,以提高资源利用率。
|
||||
|
||||
当然,除了软件层面,硬件层面的冗余也是同样的道理。比如,磁盘阵列可以容忍几块之内磁盘损坏,而不会影响整体。
|
||||
|
||||
不过也很显然,当故障影响范围大于你冗余的容量时,系统依然会挂。所以,既然你无法预知故障的发生情况,那么做冗余的时候需要平衡的另一端就是成本。相比更多的冗余,追求更好的性价比更合理一些。
|
||||
|
||||
在我们生活中的冗余也到处存在。比如,大部分的飞机和直升机的发动机都是偶数的,汽车中的电子控制系统的冗余机制等。就好比替身与真身的关系,冗余的就是替身。它可以和真身同时活动,也可以代替真身活动。
|
||||
|
||||
分治和冗余讲究的都是分散化,最终形成一个完整的系统还需要将它们“连接”起来。天下没有免费的午餐,获得分布式系统价值的同时,这个“再连接”的过程就是我们相比集中式系统要做的额外工作。
|
||||
|
||||
### 再连接
|
||||
|
||||
如何将拆分后的各个节点再次连接起来,从模式上来说,主要是去中心化与中心化之分。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/ab/47543893306d54588af427e3yyab2aab.jpg" alt="">
|
||||
|
||||
前者完全消除了中心节点故障带来的全盘出错的风险,却带来了更高的节点间协作成本。后者通过中心节点的集中式管理大大降低了协作成本,但是一旦中心节点故障则全盘出错。
|
||||
|
||||
另外,从技术角度来说,如何选择通信协议和序列化机制,也是非常重要的。
|
||||
|
||||
虽然很多通讯协议和序列化机制完全可以承担任何场景的连接责任,但是不同的协议和序列化机制在适合的场景下才能发挥它最大的优势。比如,需要更高性能的场景运用 TCP 协议优于 HTTP 协议;需要更高吞吐量的场景运用 UDP 协议优于 TCP 协议,等等。
|
||||
|
||||
## 小结
|
||||
|
||||
不管系统的规模发展到多大,合理的拆分,加上合适的连接方式,那么至少会是一个运转顺畅、协作舒服的系统,至少能够正常发挥分布式系统应有的价值。
|
||||
|
||||
如今,我们发现分布式系统还可以发挥更多的作用。比如,只要基于一个统一的上层通信协议,其下层的不同节点可以运用不同的技术栈来发挥不同技术各自的优势,比如用 Go 来应对高并发场景,用 Python 来做数据分析等。再比如,提高交付的速度,如下图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/8f/8fb6fe3132223a85dbb1e3a35ce6098f.jpg" alt="">
|
||||
|
||||
通过分配不同的团队、人员同时进行多个模块的开发,虽然总的耗时增加了,但是整体的交付速度加快了。
|
||||
|
||||
事物最本质的东西是恒定的、不变的,可以指引我们的工作方向。分布式系统的本质也是这样。例如,这样的“分治”方案耦合度和内聚度是否最优,这样做“冗余”带来的收益是否成本能够接受。只要持续带着这些思考,我们就好像拿着一杆秤,基于它,我们就可以去衡量各种变量影响,然后作权衡。比如成本、时间、人员、性能、易维护等等。也可以基于它去判断什么样的框架、组件、协议更适合当前的环境。
|
||||
|
||||
需要不断的权衡,也意味着分布式系统的设计工作一定不是一步到位,而是循序渐进的。因为过分为未知的未来做更多的考量,最终可能都会打水漂。所以,建议以多考虑 1~2 步为宜。假如以你所在的团队中对重大技术升级的频率来作为参考的话,做可供 2 个升级周期的设计,花一个升级周期的时间先实现第一阶段,下个阶段可以选择直接实现剩下的部分,也可继续进行 2 个升级周期设计,开启一个循环,持续迭代,并且不断修正方向以更贴近现实的发展,就如下图这样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/65/702d945e44414609a4ba116a3c5be965.jpg" alt="">
|
||||
|
||||
以上就是今天的全部内容。最后,互动一下,在你的工作或者学习中,你觉得分布式系统还具备哪些价值呢?欢迎留言!
|
||||
215
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐7|深入剖析HTTP|3协议.md
Normal file
215
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐7|深入剖析HTTP|3协议.md
Normal file
@@ -0,0 +1,215 @@
|
||||
<audio id="audio" title="加餐7|深入剖析HTTP/3协议" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0b/d7/0b36c01d64c8a2be2dddf6df1b4871d7.mp3"></audio>
|
||||
|
||||
你好,我是陶辉,又见面了。结课并不意味着结束,有好的内容我依然会分享给你。今天这节加餐,整理自今年8月3号我在[Nginx中文社区](https://www.nginx-cn.net/explore)与QCon共同组织的[QCon公开课](https://www.infoq.cn/video/VPK3Zu0xrv6U8727ZSXB?utm_source=in_album&utm_medium=video)中分享的部分内容,主要介绍HTTP/3协议规范、应用场景及实现原理。欢迎一起交流探讨!
|
||||
|
||||
自2017年起,HTTP/3协议已发布了29个Draft,推出在即,Chrome、Nginx等软件都在跟进实现最新的草案。那它带来了哪些变革呢?我们结合HTTP/2协议看一下。
|
||||
|
||||
2015年,HTTP/2协议正式推出后,已经有接近一半的互联网站点在使用它:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/0c/01/0c0277835b0yy731b11d68d44de00601.jpg" alt="" title="图片来自:https://w3techs.com/technologies/details/ce-http2">](https://w3techs.com/technologies/details/ce-http2)
|
||||
|
||||
HTTP/2协议虽然大幅提升了HTTP/1.1的性能,然而,基于TCP实现的HTTP/2遗留下3个问题:
|
||||
|
||||
- 有序字节流引出的**队头阻塞(**[**Head-of-line blocking**](https://en.wikipedia.org/wiki/Head-of-line_blocking)**)**,使得HTTP/2的多路复用能力大打折扣;
|
||||
- **TCP与TLS叠加了握手时延**,建链时长还有1倍的下降空间;
|
||||
- 基于TCP四元组确定一个连接,这种诞生于有线网络的设计,并不适合移动状态下的无线网络,这意味着**IP地址的频繁变动会导致TCP连接、TLS会话反复握手**,成本高昂。
|
||||
|
||||
而HTTP/3协议恰恰是解决了这些问题:
|
||||
|
||||
- HTTP/3基于UDP协议重新定义了连接,在QUIC层实现了无序、并发字节流的传输,解决了队头阻塞问题(包括基于QPACK解决了动态表的队头阻塞);
|
||||
- HTTP/3重新定义了TLS协议加密QUIC头部的方式,既提高了网络攻击成本,又降低了建立连接的速度(仅需1个RTT就可以同时完成建链与密钥协商);
|
||||
- HTTP/3 将Packet、QUIC Frame、HTTP/3 Frame分离,实现了连接迁移功能,降低了5G环境下高速移动设备的连接维护成本。
|
||||
|
||||
接下来我们就会从HTTP/3协议的概念讲起,从连接迁移的实现上学习HTTP/3的报文格式,再围绕着队头阻塞问题来分析多路复用与QPACK动态表的实现。虽然正式的RFC规范还未推出,但最近的草案Change只有微小的变化,所以现在学习HTTP/3正当其时,这将是下一代互联网最重要的基础设施。
|
||||
|
||||
## HTTP/3协议到底是什么?
|
||||
|
||||
就像HTTP/2协议一样,HTTP/3并没有改变HTTP/1的语义。那什么是HTTP语义呢?在我看来,它包括以下3个点:
|
||||
|
||||
- 请求只能由客户端发起,而服务器针对每个请求返回一个响应;
|
||||
- 请求与响应都由Header、Body(可选)组成,其中请求必须含有URL和方法,而响应必须含有响应码;
|
||||
- Header中各Name对应的含义保持不变。
|
||||
|
||||
HTTP/3在保持HTTP/1语义不变的情况下,更改了编码格式,这由2个原因所致:
|
||||
|
||||
首先,是为了减少编码长度。下图中HTTP/1协议的编码使用了ASCII码,用空格、冒号以及 \r\n作为分隔符,编码效率很低。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/91/f8c65f3f1c405f2c87db1db1c421f891.jpg" alt="">
|
||||
|
||||
HTTP/2与HTTP/3采用二进制、静态表、动态表与Huffman算法对HTTP Header编码,不只提供了高压缩率,还加快了发送端编码、接收端解码的速度。
|
||||
|
||||
其次,由于HTTP/1协议不支持多路复用,这样高并发只能通过多开一些TCP连接实现。然而,通过TCP实现高并发有3个弊端:
|
||||
|
||||
- 实现成本高。TCP是由操作系统内核实现的,如果通过多线程实现并发,并发线程数不能太多,否则线程间切换成本会以指数级上升;如果通过异步、非阻塞socket实现并发,开发效率又太低;
|
||||
- 每个TCP连接与TLS会话都叠加了2-3个RTT的建链成本;
|
||||
- TCP连接有一个防止出现拥塞的慢启动流程,它会对每个TCP连接都产生减速效果。
|
||||
|
||||
因此,HTTP/2与HTTP/3都在应用层实现了多路复用功能:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/90/e0/90f7cc7fed1a46b691303559cde3bce0.jpg" alt="" title="图片来自:https://blog.cloudflare.com/http3-the-past-present-and-future/">](https://blog.cloudflare.com/http3-the-past-present-and-future/)
|
||||
|
||||
HTTP/2协议基于TCP有序字节流实现,因此**应用层的多路复用并不能做到无序地并发,在丢包场景下会出现队头阻塞问题**。如下面的动态图片所示,服务器返回的绿色响应由5个TCP报文组成,而黄色响应由4个TCP报文组成,当第2个黄色报文丢失后,即使客户端接收到完整的5个绿色报文,但TCP层不允许应用进程的read函数读取到最后5个报文,并发也成了一纸空谈。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/46/12473f12db5359904526d1878bc3c046.gif" alt="">
|
||||
|
||||
当网络繁忙时,丢包概率会很高,多路复用受到了很大限制。因此,**HTTP/3采用UDP作为传输层协议,重新实现了无序连接,并在此基础上通过有序的QUIC Stream提供了多路复用**,如下图所示:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/35/d6/35c3183d5a210bc4865869d9581c93d6.png" alt="" title="图片来自:https://blog.cloudflare.com/http3-the-past-present-and-future/">](https://blog.cloudflare.com/http3-the-past-present-and-future/)
|
||||
|
||||
最早这一实验性协议由Google推出,并命名为gQUIC,因此,IETF草案中仍然保留了QUIC概念,用来描述HTTP/3协议的传输层和表示层。HTTP/3协议规范由以下5个部分组成:
|
||||
|
||||
- QUIC层由[https://tools.ietf.org/html/draft-ietf-quic-transport-29](https://tools.ietf.org/html/draft-ietf-quic-transport-29) 描述,它定义了连接、报文的可靠传输、有序字节流的实现;
|
||||
- TLS协议会将QUIC层的部分报文头部暴露在明文中,方便代理服务器进行路由。[https://tools.ietf.org/html/draft-ietf-quic-tls-29](https://tools.ietf.org/html/draft-ietf-quic-tls-29) 规范定义了QUIC与TLS的结合方式;
|
||||
- 丢包检测、RTO重传定时器预估等功能由[https://tools.ietf.org/html/draft-ietf-quic-recovery-29](https://tools.ietf.org/html/draft-ietf-quic-recovery-29) 定义,目前拥塞控制使用了类似[TCP New RENO](https://tools.ietf.org/html/rfc6582) 的算法,未来有可能更换为基于带宽检测的算法(例如[BBR](https://github.com/google/bbr));
|
||||
- 基于以上3个规范,[https://tools.ietf.org/html/draft-ietf-quic-http-29](https://tools.ietf.org/html/draft-ietf-quic-http-29H) 定义了HTTP语义的实现,包括服务器推送、请求响应的传输等;
|
||||
- 在HTTP/2中,由HPACK规范定义HTTP头部的压缩算法。由于HPACK动态表的更新具有时序性,无法满足HTTP/3的要求。在HTTP/3中,QPACK定义HTTP头部的编码:[https://tools.ietf.org/html/draft-ietf-quic-qpack-16](https://tools.ietf.org/html/draft-ietf-quic-qpack-16)。注意,以上规范的最新草案都到了29,而QPACK相对简单,它目前更新到16。
|
||||
|
||||
自1991年诞生的HTTP/0.9协议已不再使用,但1996推出的HTTP/1.0、1999年推出的HTTP/1.1、2015年推出的HTTP/2协议仍然共存于互联网中(HTTP/1.0在企业内网中还在广为使用,例如Nginx与上游的默认协议还是1.0版本),即将面世的HTTP/3协议的加入,将会进一步增加协议适配的复杂度。接下来,我们将深入HTTP/3协议的细节。
|
||||
|
||||
## 连接迁移功能是怎样实现的?
|
||||
|
||||
对于当下的HTTP/1和HTTP/2协议,传输请求前需要先完成耗时1个RTT的TCP三次握手、耗时1个RTT的TLS握手(TLS1.3),**由于它们分属内核实现的传输层、openssl库实现的表示层,所以难以合并在一起**,如下图所示:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/79/a2/79a1d38d2e39c93e600f38ae3a9a04a2.jpg" alt="" title="图片来自:https://blog.cloudflare.com/http3-the-past-present-and-future/">](https://blog.cloudflare.com/http3-the-past-present-and-future/)
|
||||
|
||||
在IoT时代,移动设备接入的网络会频繁变动,从而导致设备IP地址改变。**对于通过四元组(源IP、源端口、目的IP、目的端口)定位连接的TCP协议来说,这意味着连接需要断开重连,所以上述2个RTT的建链时延、TCP慢启动都需要重新来过。**而HTTP/3的QUIC层实现了连接迁移功能,允许移动设备更换IP地址后,只要仍保有上下文信息(比如连接ID、TLS密钥等),就可以复用原连接。
|
||||
|
||||
在UDP报文头部与HTTP消息之间,共有3层头部,定义连接且实现了Connection Migration主要是在Packet Header中完成的,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/7c/ab3283383013b707d1420b6b4cb8517c.png" alt="">
|
||||
|
||||
这3层Header实现的功能各不相同:
|
||||
|
||||
- Packet Header实现了可靠的连接。当UDP报文丢失后,通过Packet Header中的Packet Number实现报文重传。连接也是通过其中的Connection ID字段定义的;
|
||||
- QUIC Frame Header在无序的Packet报文中,基于QUIC Stream概念实现了有序的字节流,这允许HTTP消息可以像在TCP连接上一样传输;
|
||||
- HTTP/3 Frame Header定义了HTTP Header、Body的格式,以及服务器推送、QPACK编解码流等功能。
|
||||
|
||||
为了进一步提升网络传输效率,Packet Header又可以细分为两种:
|
||||
|
||||
- Long Packet Header用于首次建立连接;
|
||||
- Short Packet Header用于日常传输数据。
|
||||
|
||||
其中,Long Packet Header的格式如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/b4/5ecc19ba9106179cd3443eefc1d6b8b4.png" alt="">
|
||||
|
||||
建立连接时,连接是由服务器通过Source Connection ID字段分配的,这样,后续传输时,双方只需要固定住Destination Connection ID,就可以在客户端IP地址、端口变化后,绕过UDP四元组(与TCP四元组相同),实现连接迁移功能。下图是Short Packet Header头部的格式,这里就不再需要传输Source Connection ID字段了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/26/f41634797dfafeyy4535c3e94ea5f226.png" alt="">
|
||||
|
||||
上图中的Packet Number是每个报文独一无二的序号,基于它可以实现丢失报文的精准重发。如果你通过抓包观察Packet Header,会发现Packet Number被TLS层加密保护了,这是为了防范各类网络攻击的一种设计。下图给出了Packet Header中被加密保护的字段:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/93/5a77916ce399148d0c2d951df7c26c93.png" alt="">
|
||||
|
||||
其中,显示为E(Encrypt)的字段表示被TLS加密过。当然,Packet Header只是描述了最基本的连接信息,其上的Stream层、HTTP消息也是被加密保护的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/af/9edabebb331bb46c6e2335eda20c68af.png" alt="">
|
||||
|
||||
现在我们已经对HTTP/3协议的格式有了基本的了解,接下来我们通过队头阻塞问题,看看Packet之上的QUIC Frame、HTTP/3 Frame帧格式。
|
||||
|
||||
## Stream多路复用时的队头阻塞是怎样解决的?
|
||||
|
||||
其实,解决队头阻塞的方案,就是允许微观上有序发出的Packet报文,在接收端无序到达后也可以应用于并发请求中。比如上文的动态图中,如果丢失的黄色报文对其后发出的绿色报文不造成影响,队头阻塞问题自然就得到了解决:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/f4/f6dc5b11f8a240b4283dcb8de5b9a0f4.gif" alt="">
|
||||
|
||||
在Packet Header之上的QUIC Frame Header,定义了有序字节流Stream,而且Stream之间可以实现真正的并发。HTTP/3的Stream,借鉴了HTTP/2中的部分概念,所以在讨论QUIC Frame Header格式之前,我们先来看看HTTP/2中的Stream长什么样子:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/ff/4e/ff629c78ac1880939e5eabb85ab53f4e.png" alt="" title="图片来自:https://developers.google.com/web/fundamentals/performance/http2">](https://developers.google.com/web/fundamentals/performance/http2)
|
||||
|
||||
每个Stream就像HTTP/1中的TCP连接,它保证了承载的HEADERS frame(存放HTTP Header)、DATA frame(存放HTTP Body)是有序到达的,多个Stream之间可以并行传输。在HTTP/3中,上图中的HTTP/2 frame会被拆解为两层,我们先来看底层的QUIC Frame。
|
||||
|
||||
一个Packet报文中可以存放多个QUIC Frame,当然所有Frame的长度之和不能大于PMTUD(Path Maximum Transmission Unit Discovery,这是大于1200字节的值),你可以把它与IP路由中的MTU概念对照理解:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/47/3df65a7bb095777f1f8a7fede1a06147.png" alt="">
|
||||
|
||||
每一个Frame都有明确的类型:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/5d/0e27cd850c0f5b854fd3385cab05755d.png" alt="">
|
||||
|
||||
前4个字节的Frame Type字段描述的类型不同,接下来的编码也不相同,下表是各类Frame的16进制Type值:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/85/45b0dfcd5c59a9c8be1c5c9e6f998085.jpg" alt="">
|
||||
|
||||
在上表中,我们只要分析0x08-0x0f这8种STREAM类型的Frame,就能弄明白Stream流的实现原理,自然也就清楚队头阻塞是怎样解决的了。Stream Frame用于传递HTTP消息,它的格式如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/4c/10874d334349349559835yy4d4c92b4c.png" alt="">
|
||||
|
||||
可见,Stream Frame头部的3个字段,完成了多路复用、有序字节流以及报文段层面的二进制分隔功能,包括:
|
||||
|
||||
- Stream ID定义了一个有序字节流。当HTTP Body非常大,需要跨越多个Packet时,只要在每个Stream Frame中含有同样的Stream ID,就可以传输任意长度的消息。多个并发传输的HTTP消息,通过不同的Stream ID加以区别;
|
||||
- 消息序列化后的“有序”特性,是通过Offset字段完成的,它类似于TCP协议中的Sequence序号,用于实现Stream内多个Frame间的累计确认功能;
|
||||
- Length指明了Frame数据的长度。
|
||||
|
||||
你可能会奇怪,为什么会有8种Stream Frame呢?这是因为0x08-0x0f这8种类型其实是由3个二进制位组成,它们实现了以下3标志位的组合:
|
||||
|
||||
- 第1位表示是否含有Offset,当它为0时,表示这是Stream中的起始Frame,这也是上图中Offset是可选字段的原因;
|
||||
- 第2位表示是否含有Length字段;
|
||||
- 第3位Fin,表示这是Stream中最后1个Frame,与HTTP/2协议Frame帧中的FIN标志位相同。
|
||||
|
||||
Stream数据中并不会直接存放HTTP消息,因为HTTP/3还需要实现服务器推送、权重优先级设定、流量控制等功能,所以Stream Data中首先存放了HTTP/3 Frame:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/61/12b117914de00014f90d1yyf12875861.png" alt="">
|
||||
|
||||
其中,Length指明了HTTP消息的长度,而Type字段(请注意,低2位有特殊用途,在QPACK一节中会详细介绍)包含了以下类型:
|
||||
|
||||
- 0x00:DATA帧,用于传输HTTP Body包体;
|
||||
- 0x01:HEADERS帧,通过QPACK 编码,传输HTTP Header头部;
|
||||
- 0x03:CANCEL_PUSH控制帧,用于取消1次服务器推送消息,通常客户端在收到PUSH_PROMISE帧后,通过它告知服务器不需要这次推送;
|
||||
- 0x04:SETTINGS控制帧,设置各类通讯参数;
|
||||
- 0x05:PUSH_PROMISE帧,用于服务器推送HTTP Body前,先将HTTP Header头部发给客户端,流程与HTTP/2相似;
|
||||
- 0x07:GOAWAY控制帧,用于关闭连接(注意,不是关闭Stream);
|
||||
- 0x0d:MAX_PUSH_ID,客户端用来限制服务器推送消息数量的控制帧。
|
||||
|
||||
总结一下,QUIC Stream Frame定义了有序字节流,且多个Stream间的传输没有时序性要求。这样,HTTP消息基于QUIC Stream就实现了真正的多路复用,队头阻塞问题自然就被解决掉了。
|
||||
|
||||
## QPACK编码是如何解决队头阻塞问题的?
|
||||
|
||||
最后,我们再看下HTTP Header头部的编码方式,它需要面对另一种队头阻塞问题。
|
||||
|
||||
与HTTP/2中的HPACK编码方式相似,HTTP/3中的QPACK也采用了静态表、动态表及Huffman编码:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/af/94/af869abf09yy1d6d3b0fa5879e300194.jpg" alt="" title="图片来自:https://www.oreilly.com/content/http2-a-new-excerpt/">](https://www.oreilly.com/content/http2-a-new-excerpt/)
|
||||
|
||||
先来看静态表的变化。在上图中,GET方法映射为数字2,这是通过客户端、服务器协议实现层的硬编码完成的。在HTTP/2中,共有61个静态表项:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/21/8cd69a7baf5a02d84e69fe6946d5ab21.jpg" alt="">
|
||||
|
||||
而在QPACK中,则上升为98个静态表项,比如Nginx上的ngx_http_v3_static_table数组所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/10/0c9c5ab8c342eb6545b00cee8f6b4010.png" alt="">
|
||||
|
||||
你也可以从[这里](https://tools.ietf.org/html/draft-ietf-quic-qpack-14#appendix-A)找到完整的HTTP/3静态表。对于Huffman以及整数的编码,QPACK与HPACK并无多大不同,但动态表编解码方式差距很大。
|
||||
|
||||
所谓动态表,就是将未包含在静态表中的Header项,在其首次出现时加入动态表,这样后续传输时仅用1个数字表示,大大提升了编码效率。因此,动态表是天然具备时序性的,如果首次出现的请求出现了丢包,后续请求解码HPACK头部时,一定会被阻塞!
|
||||
|
||||
QPACK是如何解决队头阻塞问题的呢?事实上,QPACK将动态表的编码、解码独立在单向Stream中传输,仅当单向Stream中的动态表编码成功后,接收端才能解码双向Stream上HTTP消息里的动态表索引。
|
||||
|
||||
这里我们又引入了单向Stream和双向Stream概念,不要头疼,它其实很简单。单向指只有一端可以发送消息,双向则指两端都可以发送消息。还记得上一小节的QUIC Stream Frame头部吗?其中的Stream ID别有玄机,除了标识Stream外,它的低2位还可以表达以下组合:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/0a/ae1dbf30467e8a7684d337701f055c0a.png" alt="">
|
||||
|
||||
因此,当Stream ID是0、4、8、12时,这就是客户端发起的双向Stream(HTTP/3不支持服务器发起双向Stream),它用于传输HTTP请求与响应。单向Stream有很多用途,所以它在数据前又多出一个Stream Type字段:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/52/b07521a11e65a24cd4b93553127bfc52.png" alt="">
|
||||
|
||||
Stream Type有以下取值:
|
||||
|
||||
- 0x00:控制Stream,传递各类Stream控制消息;
|
||||
- 0x01:服务器推送消息;
|
||||
- 0x02:用于编码QPACK动态表,比如面对不属于静态表的HTTP请求头部,客户端可以通过这个Stream发送动态表编码;
|
||||
- 0x03:用于通知编码端QPACK动态表的更新结果。
|
||||
|
||||
由于HTTP/3的Stream之间是乱序传输的,因此,若先发送的编码Stream后到达,双向Stream中的QPACK头部就无法解码,此时传输HTTP消息的双向Stream就会进入Block阻塞状态(两端可以通过控制帧定义阻塞Stream的处理方式)。
|
||||
|
||||
## 小结
|
||||
|
||||
最后对这一讲的内容做个小结。
|
||||
|
||||
基于四元组定义连接并不适用于下一代IoT网络,HTTP/3创造出Connection ID概念实现了连接迁移,通过融合传输层、表示层,既缩短了握手时长,也加密了传输层中的绝大部分字段,提升了网络安全性。
|
||||
|
||||
HTTP/3在Packet层保障了连接的可靠性,在QUIC Frame层实现了有序字节流,在HTTP/3 Frame层实现了HTTP语义,这彻底解开了队头阻塞问题,真正实现了应用层的多路复用。
|
||||
|
||||
QPACK使用独立的单向Stream分别传输动态表编码、解码信息,这样乱序、并发传输HTTP消息的Stream既不会出现队头阻塞,也能基于时序性大幅压缩HTTP Header的体积。
|
||||
|
||||
如果你觉得这一讲对你理解HTTP/3协议有所帮助,也欢迎把它分享给你的朋友。
|
||||
73
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场1 | 李玥:高并发场景下如何优化微服务的性能?.md
Normal file
73
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场1 | 李玥:高并发场景下如何优化微服务的性能?.md
Normal file
@@ -0,0 +1,73 @@
|
||||
<audio id="audio" title="大咖助场1 | 李玥:高并发场景下如何优化微服务的性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/5a/4544de2ab048bede770627380af0065a.mp3"></audio>
|
||||
|
||||
你好,我是李玥。相信这里有部分同学对我是比较熟悉的,我在极客时间开了两门课,分别是[《消息队列高手课》](https://time.geekbang.org/column/intro/212?utm_term=zeusEX4MV&utm_source=geektime&utm_medium=xiangqingye)和[《后端存储实战课》](https://time.geekbang.org/column/intro/100046801)。今天很荣幸受邀来到陶辉老师的专栏做一期分享。
|
||||
|
||||
陶辉老师的这门课程,其中的知识点都是非常“硬核”,因为涉及到计算机操作系统底层的这些运行机制,确实非常抽象。我也看到有些同学在留言区提到,希望能通过一些例子来帮助大家更好地消化一下这些知识。那么这期分享呢,我就来帮陶辉老师做一次科普,帮助同学们把“基础设施优化”这一部分中讲到的一些抽象的概念和方法,用举例子的方式来梳理一遍。总结下的话,就是帮你理清这些问题:
|
||||
|
||||
- 线程到底是如何在CPU中执行的?
|
||||
- 线程上下文切换为什么会影响性能?
|
||||
- 为什么说异步比同步的性能好?
|
||||
- BIO、NIO、AIO到底有什么区别?
|
||||
|
||||
## 为什么线程数越多反而性能越差?
|
||||
|
||||
今天的课程,从一个选择题开始。假设我们有一个服务,服务的业务逻辑和我们每天在做的业务都差不多,根据传入的参数去数据库查询数据,然后执行一些简单的业务逻辑后返回。我们的服务需要能支撑10,000TPS的请求数量,那么数据库的连接池设置成多大合适呢?
|
||||
|
||||
我给你二个选项:
|
||||
|
||||
- A. 32
|
||||
- B. 2048
|
||||
|
||||
我们直接公布答案,选项A是性能更好的选择。连接池的大小直接影响的是,同时请求到数据库服务器的并发数量。那我们直觉的第一印象可能是,并发越多总体性能应该越好才对,事实真的是这样吗?下面我们通过一个例子来探究一下这个问题的答案。
|
||||
|
||||
说有一个工厂,要新上一个车间,车间里面设置了8条流水生产线,每个流水线设置1个工位,那需要安排多少个工人才能达到最佳的效率呢?显然是需要8个工人是吧?工人少了生产线闲置,工人多了也没有工位让他们去工作,工人闲置,8个工人对8条流水线是效率最优解。这里面的车间,就可以类比为一台计算机,工位就是线程,工人就是CPU的核心。通过这个类比,我们就可以得出这样一个结论:**一个8核的CPU,8个线程的情况下效率是最高的。** 这时,每个CPU核心正好对应一个线程。
|
||||
|
||||
这是一个非常理想的情况,它有一个前提就是,流水线上的工人(CPU核心)一直有事情做,没有任何等待。而现实情况下,我们绝大部分的计算程序都做不到像工厂流水线那么高效。我们开发的程序几乎是**请求/响应**的模型,对应到车间的例子,生产模式不太像流水线,更像是来料加工。工人在工位上等着,来了一件原料,工人开始加工,加工完成后,成品被送走,然后再等待下一件,周而复始。对应到计算机程序中,原料就是请求,工人在工位上加工原料的过程,相当于CPU在线程上执行业务逻辑的过程,成品就是响应,或者说是请求的返回值。你可以对照下面这个图来理解上面我们讲的这个例子,以及对应到计算机程序中的概念。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/yy/8c/yy53149254ae8cc325b1bc24e5a6428c.png" alt="">
|
||||
|
||||
来料加工这种情况下,只有8个工位并不能保证8个工人一直满负荷的工作。因为,工人每加工完成一件产品之后,需要等待成品被送出去,下一件原料被送进来,才能开始继续工作。在同一个工位上加工每件产品之间的等待是不可避免的,那怎么才能最大化工人的效率,尽量减少工人等待呢?很简单,增加一些工位就可以了。工人在A工位加工完成一件产品之后,不在A工位等着,马上去另外一个原料已经就绪的B工位继续工作,这样只要工位设置得足够多,就可以保证8个工人一直满负荷工作。
|
||||
|
||||
那同样是8个工人满负荷工作,多工位来料加工这种方式,和上面提到的8条流水线作业的方式,哪种效率更高呢?还是流水线的效率高,是不是?原因是,虽然在这两种方式下,工人们都在满负荷工作,但是,来料加工这种方式下,工人在不同的工位之间切换,也是需要一点点时间的,相比于流水线作业,这部分工时相当于被浪费掉了。
|
||||
|
||||
工人在工位间切换,对应到计算机执行程序的过程,就是CPU在不同的线程之间切换,称为**线程上下文切换**。一次线程上下文切换的时间耗时非常短,大约只有几百个纳秒(ns)。一般来说我们并不需要太关注这个短到不可感知的切换时间,但是,在多线程高并发的场景下,如果没有很好的优化,就有可能出现,CPU在大量线程间频繁地发生切换,累积下来,这个切换时间就很可观了,严重的话就会拖慢服务的总体性能。
|
||||
|
||||
我们再来思考另外一个问题:设置多少个工位最合适呢?工位数量不足时,工人不能满负荷工作,工位数量太多了也不行,工人需要频繁地切换工位,浪费时间。这里面一定存在一个最优工位数,可以让所有工人正好不需要等待且满负荷工作。最优工位数取决于工人的加工速度、等待原料的时长等因素。如果这些参数是确定的,那我们确定这个最佳工位数就不太难了。一般来说,工位的数量设置成工人数量的两三倍就差不多了,如果等待的时间比较长,可能需要五六倍,大致是这样一个数量级。把这个结论对应到计算机系统中就是,**对于一个请求/响应模型的服务,并发线程数设置为CPU核数N倍时性能最佳**,N的大致的经验值范围是[2, 10]。
|
||||
|
||||
有了这个结论,再回过头来看我们课程开始提到的那个数据库连接池问题。数据库服务符合“请求/响应模型”,所以它的并发数量并不是越多越好,根据我们上面得出的结论,大约是CPU核数的几倍时达到最佳性能。这个问题来自于数据库连接池HikariCP的一篇Wiki: [About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing),里面有详细的性能测试数据和计算最佳连接池数量的公式,强烈推荐你课后去看一下。
|
||||
|
||||
## 为什么说异步比同步的性能好?
|
||||
|
||||
然后我们再来思考这样一个问题。我们开发的很多业务服务实际的情况是,并发线程数越多总体性能越好,几百甚至上千个线程才达到最佳性能。这并不符合我们上面说的那个结论啊?什么原因?
|
||||
|
||||
原因是这样的,我们上面这个结论它有一个适用范围,它的适用范围是,像数据库服务这样,只依赖于本地计算资源的服务。
|
||||
|
||||
如果说,我们的业务服务,它在处理请求过程中,还需要去调用其他服务,这种情况就不适用于我们上面所说的结论。这里面的其它服务包括数据库服务或者是下游的业务服务等等。不适用的原因是,我们线程在执行业务逻辑过程中,很大一部分时间都花在等待外部服务上了,在这个等待的过程中,几乎不需要CPU参与。换句话说,每个线程需要的CPU时间是非常少的,这样的情况下,一个CPU核心需要非常多的线程才能把它“喂饱”,这就是为什么这些业务服务需要非常多的线程数,才能达到最佳性能的原因。
|
||||
|
||||
我们刚刚讲过,线程数过多很容易导致CPU频繁的在这些线程之间切换,虽然CPU看起来已经在满负荷运行了,但CPU并没有把所有的时间都用在执行我们的业务逻辑上,其中一部分CPU时间浪费在线程上下文切换上了。怎么来优化这种情况呢?要想让CPU高效地执行业务逻辑,最佳方式就是我们开头提到的流水线,用和CPU核数相同的线程数,通过源源不断地供给请求,让CPU一直不停地执行业务逻辑。**所以优化的关键点是,减少线程的数量**,把线程数量控制在和CPU核数相同的数量级这样一个范围。
|
||||
|
||||
要减少线程数量,有这样两个问题需要解决。
|
||||
|
||||
第一个问题是,如何用少量的线程来处理大量并发请求呢?我们可以用一个请求队列,和一组数量固定的执行线程,来解决这个问题。线程的数量就等于CPU的核数。接收到的请求先放入请求队列,然后分配给执行线程去处理。这样基本上能达到,让每个CPU的核心相对固定到一个线程上,不停地执行业务逻辑这样一个效果。
|
||||
|
||||
第二个问题是,执行线程在需要调用外部服务的时候,如何避免线程等待外部服务,同时还要保证及时处理返回的响应呢?我们希望的情况是,执行线程需要调用外部服务的时候,把请求发送出去之后,不要去等待响应,而是去继续处理下一个请求。等外部请求的响应回来之后,能有一个通知,来触发执行线程再执行后续的业务逻辑,直到给客户端返回响应。这其实就是我们通常所说的**异步IO模型(AIO,Asynchronous I/O)**,这个模型的关键就是,线程不去等待Socket通道上的数据,而是待数据到达时,由操作系统来发起一个通知,触发业务线程来处理。Linux内核从2.6开始才加入了AIO的支持,到目前为止AIO还没有被广泛使用。
|
||||
|
||||
使用更广泛的是**IO多路复用模型(IO Multiplexing)**,IO多路复用本质上还是一种同步IO模型。但是,它允许一个线程同时等待多个Socket通道,任意一个通道上有数据到来,就解除等待去处理。IO多路复用没有AIO那么理想化,但也只是多了一个线程用于等待响应,相比AIO来说,效果也差不了多少,在内核AIO支持还不完善的时代,是一个非常务实且高效的网络IO模型。
|
||||
|
||||
很多编程语言中,都有一些网络IO框架,封装了这些IO模型,来帮我们解决这个问题,比如Java语言中的BIO、NIO、AIO分别对应了同步IO模型、IO多路复用模型和异步IO模型。
|
||||
|
||||
解决了上面这两个问题之后,我们用很少量的线程就可以处理大量的并发请求。这种情况下,负责返回响应的线程和接收请求的线程,不再是同一个线程,这其实就是我们所说的**异步模型**。你可以看到,**异步模型并不会让程序的业务逻辑执行得更快,但是它可以非常有效地避免线程等待,大幅减少CPU在线程上下文切换上浪费的时间。**这样,在同样的计算机配置下,异步模型相比同步模型,可以更高效地利用计算机资源,从而拥有更好的总体的吞吐能力。
|
||||
|
||||
## 小结
|
||||
|
||||
以上就是本节课的全部内容了,我们来简单地做个小结。
|
||||
|
||||
理论上,线程数量设置为CPU核数,并且线程没有等待的情况下,CPU几乎不会发生线程上下文切换,这个时候程序的执行效率是最高的。实际情况下,对于一个请求/响应模型的服务,并发线程数设置为CPU核数N倍时性能最佳。这个N取决于业务逻辑的执行时间、线程等待时间等因素,N的大致的经验值范围是[2, 10]。
|
||||
|
||||
使用异步模型编写微服务,配合异步IO或者IO多路复用,可以有效地避免线程等待,用少量的线程处理大量并发请求,大幅减少线程上下文切换的开销,从而达到提升服务总体性能的效果。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后留给你一道思考题。IO多路复用,它只是一种IO模型,实际上有多种实现。在Linux中,有select、poll、epoll三种实现方式,课后请你去查阅一下资料,看看这三种实现方式有什么区别?
|
||||
|
||||
感谢阅读,如果今天的内容让你有所收获,欢迎把它分享给你的朋友。
|
||||
115
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场2|庄振运:与程序员相关的SSD性能知识.md
Normal file
115
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场2|庄振运:与程序员相关的SSD性能知识.md
Normal file
@@ -0,0 +1,115 @@
|
||||
<audio id="audio" title="大咖助场2|庄振运:与程序员相关的SSD性能知识" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/48/037884390233cb1cc6b8bbaa3f348b48.mp3"></audio>
|
||||
|
||||
你好,我是庄振运。我是[《性能工程高手课》](https://time.geekbang.org/column/intro/100041101)的专栏作者,很荣幸受邀来到陶辉老师的专栏做一期分享。今天我们来讲一点SSD相关的性能知识。SSD(Solid State Drive)是硬盘的一种,有时候也叫Flash或者固态硬盘。
|
||||
|
||||
最近几年,SSD的发展和演化非常迅速。随着市场规模的增大和技术的进步,SSD的价格也大幅度降低了。在很多实时的后台系统中,SSD几乎已经成了标准配置了。所以了解它的机制和性能,对你的工作会很有益处的。
|
||||
|
||||
相对于传统硬盘HDD(Hard Disk Drive),SSD有完全不同的内部工作原理和全新的特性。有些机制不太容易理解,而且根据你工作的领域,需要理解的深度也不一样。所以,我把这节课的内容按照由浅入深的原则分成了三个层次。
|
||||
|
||||
第一个层次是关注SSD的外部性能指标;第二个层次是了解它的内部工作机制;第三个层次是设计对SSD友好的应用程序。
|
||||
|
||||
## 比HDD更快的硬盘
|
||||
|
||||
很多人对传统硬盘了解较多,毕竟这种硬盘在业界用了好几十年了,很多教科书里面都讲述过。所以,对SSD的性能,我先用对比的方式带你看看它们的外部性能指标和特性。
|
||||
|
||||
一个硬盘的性能最主要体现在这三个指标:IOPS,带宽/吞吐率和访问延迟。**IOPS** (Input/Output Per Second) ,即每秒钟系统能处理的读写请求数量。**访问延迟**,指的是从发起IO请求到存储系统把IO处理完成的时间间隔。**吞吐率**(Throughput)或者带宽(Bandwidth),衡量的是实际数据传输速率。
|
||||
|
||||
对于传统硬盘我们应该比较熟悉它的内部是如何操作的,简单来说,当应用程序发出硬盘IO请求后,这个请求会进入硬盘的IO队列。当轮到这个IO来存取数据时,磁头需要机械运动到数据存放的位置,这就需要磁头寻址到相应的磁道和旋转到相应的扇区,然后才是数据的传输。对于一块普通硬盘而言,随机IO读写延迟就是8毫秒左右,IO带宽大约每秒100MB,而随机IOPS一般是100左右。
|
||||
|
||||
SSD的种类很多,按照技术来说有单层和多层。按照质量和性能来分,有企业级和普通级。根据安装的接口和协议来分,有SAS, SATA, PCIe和NVMe等。
|
||||
|
||||
我用一张表格来对比一下HDD和SSD的三大性能指标的差异。这里考虑比较流行的NVMe协议的SSD。你可以看到,SSD的随机IO延迟比传统硬盘快百倍以上,一般在微妙级别;IO带宽也高很多倍,可以达到每秒几个GB;随机IOPS更是快了上千倍,可以达到几十万。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7c/04/7c0d537e9334e1d622d40f28168e1c04.jpg" alt="">
|
||||
|
||||
## SSD的性能特性和机制
|
||||
|
||||
SSD的内部工作方式和HDD大相径庭,我们先了解几个概念。
|
||||
|
||||
**单元(Cell)**、**页面(Page)**、**块(Block)**。当今的主流SSD是基于NAND的,它将数字位存储在单元中。每个SSD单元可以存储一位或多位。对单元的每次擦除都会降低单元的寿命,所以单元只能承受一定数量的擦除。单元存储的位数越多,制造成本就越少,SSD的容量也就越大,但是耐久性(擦除次数)也会降低。
|
||||
|
||||
一个页面包括很多单元,典型的页面大小是4KB,页面也是要读写的最小存储单元。SSD上没有“重写”操作,不像HDD可以直接对任何字节重写覆盖。一个页面一旦写入内容后就不能进行部分重写,必须和其它相邻页面一起被整体擦除重置。
|
||||
|
||||
多个页面组合成块。一个块的典型大小为512KB或1MB,也就是大约128或256页。块是擦除的基本单位,每次擦除都是整个块内的所有页面都被重置。
|
||||
|
||||
了解完以上几个基础概念,我们重点看看**IO和垃圾回收(Garbage Collection)** 。对SSD的IO共有三种类型:读取、写入和擦除。读取和写入以页为单位。IO写入的延迟具体取决于磁盘的历史状态,因为如果SSD已经存储了许多数据,那么对页的写入就经常需要移动已有的数据。一般的读写延迟都很低,在微秒级别,远远低于HDD。擦除是以块为单位的。擦除速度相对很慢,通常为几毫秒。所以对同步的IO,发出IO的应用程序可能会因为块的擦除,而经历很大的写入延迟。为了尽量地减少这样的场景,保持空闲块的阈值对于快速的写响应是很有必要的。SSD的垃圾回收(GC)的目的就在于此。GC可以回收用过的块,这样可以确保以后的页写入可以快速分配到一个全新的页。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/70/4b3705248ccb0144516a77849c17dd70.png" alt="">
|
||||
|
||||
**写入放大(Write Amplification, or WA)。** 这是SSD相对于HDD的一个缺点,即实际写入SSD的物理数据量,有可能是应用层写入数据量的多倍。一方面,页级别的写入需要移动已有的数据来腾空页面。另一方面,GC的操作也会移动用户数据来进行块级别的擦除。所以对SSD真正的写操作的数据可能比实际写的数据量大,这就是写入放大。一块SSD只能进行有限的擦除次数,也称为编程/擦除(P/E)周期,所以写入放大效用会缩短SSD的寿命。
|
||||
|
||||
**耗损平衡(Wear Leveling) 。**对每一个块而言,一旦达到最大数量,该块就会死亡。对于SLC块,P/E周期的典型数目是十万次;对于MLC块,P/E周期的数目是一万;而对于TLC块,则可能是几千。为了确保SSD的容量和性能,我们需要在擦除次数上保持平衡,SSD控制器具有这种“耗损平衡”机制可以实现这一目标。在损耗平衡期间,数据在各个块之间移动,以实现均衡的损耗,这种机制也会对前面讲的写入放大推波助澜。
|
||||
|
||||
## 设计对SSD友好的程序
|
||||
|
||||
SSD的IO性能相对于HDD来说,IOPS和访问延迟提升了上千倍,吞吐率也是几十倍,但是SSD的缺点也很明显,有三个:贵、容量小、易损耗。随着技术的发展,这三个缺点近几年在弱化。
|
||||
|
||||
现在越来越多的系统采用SSD来减轻应用程序的IO性能瓶颈。许多部署的结果显示,与HDD相比,SSD带来了极大的应用程序的性能提升。但是,在大多数部署方案中,SSD仅被视为“更快的HDD”,SSD的潜力并未得到充分利用。尽管使用SSD作为存储时应用程序可以获得更好的性能,但是这些收益主要归因于SSD提供的更高的IOPS和带宽。
|
||||
|
||||
进一步讲,如果应用程序的设计充分考虑了SSD的内部机制,从而设计为对SSD友好,则可以更大程度地优化SSD,从而进一步提高应用程序性能,也可以延长SSD的寿命而降低运用成本。接下来我们就看看如何在应用程序层进行一系列SSD友好的设计更改。
|
||||
|
||||
### 为什么要设计SSD友好的软件和应用程序?
|
||||
|
||||
SSD友好的程序可以获得三种好处:
|
||||
|
||||
- 提升应用程序性能;
|
||||
- 提高SSD的 IO效率;
|
||||
- 延长SSD的寿命。
|
||||
|
||||
我分别说明一下。
|
||||
|
||||
**更好的应用程序性能。**尽管从HDD迁移到SSD通常意味着更好的应用程序性能,这主要是得益于SSD的IO性能更好,但在不更改应用程序设计的情况下简单地采用SSD可能无法获得最佳性能。我们曾经有一个应用程序就是如此。该应用程序需要不断写入文件以保存数据,主要性能瓶颈就是硬盘IO。使用HDD时,最大应用程序吞吐量为每秒142个查询(QPS)。无论对应用程序设计进行各种更改还是调优,这都是可以获得的最好性能。
|
||||
|
||||
当迁移到具有相同应用程序的SSD时,吞吐量提高到2万QPS,速度提高了140倍。这主要来自SSD提供的更高IOPS。在对应用程序设计进行进一步优化使其对SSD友好之后,吞吐量提高到10万QPS,与原来的简单设计相比,提高了4倍。
|
||||
|
||||
这其中的秘密就是使用多个并发线程来执行IO,这就利用了SSD的内部并行性。记住,多个IO线程对HDD毫无益处,因为HDD只有一个磁头。
|
||||
|
||||
**更高效的存储IO。** SSD上的最小内部IO单元是一页,比如4KB大小。因此对SSD的单字节读/写必须在页面级进行。应用程序对SSD的写操作可能会导致对SSD上的物理写操作变大,这就是“写放大(WA)”。因为有这个特性,如果应用程序的数据结构或IO对SSD不友好,就会让写放大效果无谓的更大,导致SSD的IO不能被充分利用。
|
||||
|
||||
**更长的使用寿命。**SSD会磨损,因为每个存储单元只能维持有限数量的写入擦除周期。实际上,SSD的寿命取决于四个因素:SSD大小、最大擦除周期数、写入放大系数和应用程序写入速率。例如,假设有一个1TB大小的SSD,一个写入速度为100MB每秒的应用程序和一个擦除周期数为1万的SSD。当写入放大倍数为4时,SSD仅可持续10个月。具有3千个擦除周期和写入放大系数为10的SSD只能使用一个月。鉴于SSD相对较高的成本,我们很希望这些应用程序对SSD友好,从而延长SSD的使用寿命。
|
||||
|
||||
### SSD友好的设计原则
|
||||
|
||||
在设计程序的时候,我们可以把程序设计成对SSD友好,以获得前面提到的三种好处。那有哪些对SSD友好的程序设计呢?我这里总结了四个原则,大体上分为两类:数据结构和IO处理。
|
||||
|
||||
**1.数据结构:避免就地更新的优化**
|
||||
|
||||
传统HDD的寻址延迟很大,因此,使用HDD的应用程序通常经过优化,以执行不需要寻址的就地更新(比如只在一个文件后面写入)。如下图所示,执行随机更新时,吞吐量一般只能达到约170QPS;而对于同一个HDD,就地更新可以达到280QPS,远高于随机更新(如下左图所示)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/fb/42339220b197186548330ayy0b08d9fb.jpg" alt="">
|
||||
|
||||
在设计与SSD配合使用的应用程序时,这些考虑就不再有效了。对SSD而言,随机读写和顺序读写性能类似,就地更新不会获得任何IOPS优势。此外,就地更新实际上会导致SSD性能下降。原因是包含数据的SSD页面无法直接重写,因此在更新存储的数据时,必须先将相应的SSD页面读入SSD缓冲区,然后将数据写入干净页面。 SSD中的“读取-修改-写入”过程与HDD上的直接“仅写入”行为形成鲜明对比。相比之下,SSD上的随机更新就不会引起读取和修改步骤(即仅仅“写入”),因此速度更快。使用SSD,以上相同的应用程序可以通过随机更新或就地更新来达到大约2万QPS(如上右图所示)。
|
||||
|
||||
**2.数据结构:将热数据与冷数据分开**
|
||||
|
||||
对于几乎所有处理存储的应用程序,磁盘上存储的数据的访问概率均不相同。我们考虑这样一个需要跟踪用户活动的社交网络应用程序,对于用户数据存储,简单的解决方案是基于用户属性(例如注册时间)将所有用户压缩在同一位置(例如某个SSD上的文件),以后需要更新热门用户的活动时,SSD需要在页面级别进行访问(即读取/修改/写入)。因此,如果用户的数据大小小于一页,则附近的用户数据也将一起访问。如果应用程序其实并不需要附近用户的数据,则额外的数据不仅会浪费IO带宽,而且会不必要地磨损SSD。
|
||||
|
||||
为了缓解这种性能问题,在将SSD用作存储设备时,应将热数据与冷数据分开。以不同级别或不同方式来进行分隔,例如,存到不同的文件,文件的不同部分或不同的表。
|
||||
|
||||
**3. IO处理:避免长而繁重的写入**
|
||||
|
||||
SSD通常具有GC机制,不断地回收存储块以供以后使用。 GC可以后台或前台方式工作。
|
||||
|
||||
SSD控制器通常保持一个空闲块的阈值。每当可用块数下降到阈值以下时,后台GC就会启动。由于后台GC是异步发生的(即非阻塞),因此它不会影响应用程序的IO延迟,但是,如果块的请求速率超过了GC速率,并且后台GC无法跟上,则将触发前台GC。
|
||||
|
||||
在前台GC期间,必须即时擦除(即阻塞)每个块以供应用程序使用,这时发出写操作的应用程序所经历的写延迟会受到影响。具体来说,释放块的前台GC操作可能会花费数毫秒以上的时间,从而导致较大的应用程序IO延迟。出于这个原因,最好避免进行长时间的大量写入操作,以免永远不使用前台GC。
|
||||
|
||||
**4. IO处理:避免SSD存储太满**
|
||||
|
||||
SSD磁盘存储太满会影响写入放大系数和GC导致的写入性能。在GC期间,需要擦除块以创建空闲块。擦除块前需要移动并保留有效数据才能获得空闲块。有时为了获得一个空闲块,我们需要压缩好几个存储块。每个空闲块的生产需要压缩的块数取决于磁盘的空间使用率。
|
||||
|
||||
假设磁盘满百分比平均为A%,要释放一块,则需要压缩1 /(1-A%)块。显然,SSD的空间使用率越高,将需要移动更多的块以释放一个块,这将占用更多的资源并导致更长的IO等待时间。例如,如果A=80%,则大约移动五个数据块以释放一个块;当A=95%时,将移动约20个块。
|
||||
|
||||
## 总结
|
||||
|
||||
各种存储系统的基础是传统硬盘或者固态硬盘,固态硬盘SSD的IO性能比传统硬盘高很多。如果系统对IOPS或者延迟要求很高,一般都采用SSD。
|
||||
|
||||
现在已经有很多专门针对SSD来设计的文件系统、数据库系统和数据基础架构,它们的性能比使用HDD的系统都有了很大的提升。
|
||||
|
||||
SSD有不同于HDD工作原理,所以在进行应用程序设计的时候,如果可以做到对SSD友好,那么就可以充分发挥SSD的全部性能潜能,应用程序的性能会进一步提高。你可以参考我们今天总结的四个设计原则进行实践。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后给你留几道思考题。你们公司里面,有哪些后台服务和应用是使用SSD作为存储的?而对这些使用SSD的系统,有没有充分考虑SSD的特性,做深层的优化呢(比如降低损耗)?
|
||||
|
||||
感谢阅读,如果今天的内容让你有所收获,欢迎把它分享给你的朋友。
|
||||
346
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场3|傅健:那些年,影响我们达到性能巅峰的常见绊脚石(上).md
Normal file
346
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场3|傅健:那些年,影响我们达到性能巅峰的常见绊脚石(上).md
Normal file
@@ -0,0 +1,346 @@
|
||||
<audio id="audio" title="大咖助场3|傅健:那些年,影响我们达到性能巅峰的常见绊脚石(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/28/d883df652733fbbb1d35f7b672b4f428.mp3"></audio>
|
||||
|
||||
你好,我是傅健。这里有部分同学可能认识我,我在极客时间开设了一门视频课[《Netty源码剖析与实战》](https://time.geekbang.org/course/intro/237),很荣幸受邀来到陶辉老师的专栏做一些分享。今天我会围绕这门课程的主题——系统性能调优,结合我自身的工作经历补充一些内容,期待能给你一些新思路。
|
||||
|
||||
其实说起性能调优,我们往往有很多理论依据可以参考,例如针对分布式系统的NWR、CAP等,也有很多实践的“套路”,如绘制火焰图、监控调用链等。当然,这些内容多多少少陶辉老师都有讲到。实际上,不管方式、方法有多少,我们的终极目标都是一致的,那就是在固定的资源条件下,将系统的响应速度调整到极致。
|
||||
|
||||
但是在严谨地评价一个系统性能时,我们很少粗略地使用这种表述:在压力A(如1000 TPS)下,基于软硬件配置B,我们应用的C操作响应时间是D毫秒。而是加上一个百分位,例如:在压力A(如1000 TPS)下,基于软硬件配置B,我们应用的C操作响应时间**99%**已经达到D毫秒。或者当需要提供更为详细的性能报告时,我们提供的往往是类似于下面表格形式的数据来反映性能:不仅包括常见的百分比(95%、99%等或者常见的四分位),也包括平均数、中位数、最大值等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/ca/8f50ee7ca85c3fb40b516a195ec6b6ca.jpg" alt="">
|
||||
|
||||
那为什么要如此“严谨”?不可以直接说达到了某某水平吗?究其原因,还是我们的系统很难达到一个完美的极致,总有一些请求的处理时间超出了我们的“预期”,让我们的系统不够平滑(即常说的系统有“毛刺”)。所以在追求极致的路上,我们的工作重心已经不再是“大刀阔斧”地进行主动性能调优以满足99%的需求,而是着重观察、分析那掉队的1%请求,找出这些“绊脚石”,再各个击破,从而提高我们系统性能的“百分比”。例如,从2个9(99%)再进一步提高到3个9(99.9%)。而实际上,我们不难发现,这些所谓的绊脚石其实都是类似的,所以这期分享我就带你看看究竟有哪些绊脚石,我们结合具体场景总结应对策略。
|
||||
|
||||
## 场景1:重试、重定向
|
||||
|
||||
### 案例
|
||||
|
||||
当我们使用下游服务的API接口时,偶尔会出现延时较大的情况,而这些延时较大的调用最后也能成功,且没有任何明显的时间规律。例如响应延时正常时,API调用性能度量数据如下:
|
||||
|
||||
```
|
||||
{
|
||||
"stepName": "CallRemoteService"
|
||||
"values": {
|
||||
"componentType": "RemoteService",
|
||||
"startTime": "2020-07-06T10:50:41.102Z",
|
||||
"totalDurationInMS": 2,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而响应延时超出预期时,度量数据如下:
|
||||
|
||||
```
|
||||
{
|
||||
"stepName": "CallRemoteService"
|
||||
"values": {
|
||||
"componentType": "RemoteService",
|
||||
"startTime": "2020-07-06T04:31:55.805Z",
|
||||
"totalDurationInMS": 2005,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 解析
|
||||
|
||||
这种情况可以说非常典型了,单从以上度量数据来看,没有什么参考意义,因为所有的性能问题都是这样的特征,即延时增大了。这里面可能的原因也有许多,例如GC影响、网络抖动等等,但是除了这些原因之外,其实最常见、最简单的原因往往都是“重试”。**重试成功前的访问往往都是很慢的,因为可能遇到了各种需要重试的错误,同时重试本身也会增加响应时间。**那作为第一个绊脚石,我们现在就对它进行一个简单的分析。
|
||||
|
||||
以这个案例为例,经过查询后你会发现:虽然最终是成功的,但是我们中途进行了重试,具体而言就是我们在使用HttpClient访问下游服务时,自定义了重试的策略:当遇到ConnectTimeoutException、SocketTimeoutException等错误时直接进行重试。
|
||||
|
||||
```
|
||||
//构建http client
|
||||
CloseableHttpClient httpClient = HttpClients.custom().
|
||||
setConnectionTimeToLive(3, TimeUnit.MINUTES).
|
||||
//省略其它非关键代码
|
||||
setServiceUnavailableRetryStrategy(new DefaultServiceUnavailableRetryStrategy(1, 50)).
|
||||
//设置了一个自定义的重试规则
|
||||
setRetryHandler(new CustomizedHttpRequestRetryHandler()).
|
||||
build();
|
||||
|
||||
//自定义的重试规则
|
||||
@Immutable
|
||||
public class CustomizedHttpRequestRetryHandler implements HttpRequestRetryHandler {
|
||||
|
||||
private final static int RETRY_COUNT = 1;
|
||||
|
||||
CustomizedHttpRequestRetryHandler() {}
|
||||
|
||||
@Override
|
||||
public boolean retryRequest(final IOException exception, final int executionCount, final HttpContext context) {
|
||||
//控制重试次数
|
||||
if (executionCount > RETRY_COUNT) {
|
||||
return false;
|
||||
}
|
||||
//遇到下面这些异常情况时,进行重试
|
||||
if (exception instanceof ConnectTimeoutException || exception instanceof NoHttpResponseException || exception instanceof SocketTimeoutException) {
|
||||
return true;
|
||||
}
|
||||
|
||||
//省略其它非关键代码
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果查询日志的话(由org.apache.http.impl.execchain.RetryExec#execute输出),我们确实发现了重试的痕迹,且可以完全匹配上我们的请求和时间:
|
||||
|
||||
```
|
||||
[07/06/2020 04:31:57.808][threadpool-0]INFO RetryExec-I/O exception (org.apache.http.conn.ConnectTimeoutException) caught when processing request to {}->http://10.224.86.130:8080: Connect to 10.224.86.130:8080 [/10.224.86.130] failed: connect timed out
|
||||
|
||||
[07/06/2020 04:31:57.808][threadpool-0]INFO RetryExec-Retrying request to {}->http://10.224.86.130:8080
|
||||
|
||||
```
|
||||
|
||||
另外除了针对异常的重试外,我们有时候也需要对于服务的短暂不可用(返回503:SC_SERVICE_UNAVAILABLE)进行重试,正如上文贴出的代码中我们设置了DefaultServiceUnavailableRetryStrategy。
|
||||
|
||||
### 小结
|
||||
|
||||
这个案例极其简单且常见,但是这里我需要额外补充下:假设遇到这种问题,又没有明确给出重试的痕迹(日志等)时,我们应该怎么去判断是不是重试“捣鬼”的呢?
|
||||
|
||||
一般而言,我们可以直接通过下游服务的调用次数数据来核对是否发生了重试。但是如果下游也没有记录,在排除完其它可能原因后,我们仍然不能排除掉重试的原因,因为重试的痕迹可能由于某种原因被隐藏起来了,例如使用的开源组件压根没有打印日志,或者是打印了但是我们应用层的日志框架没有输出。这个时候,我们也没有更好的方法,只能翻阅源码查找线索。
|
||||
|
||||
另外除了直接的重试导致延时增加外,还有一种类似情况也经常发生,即“重定向”,而比较坑的是,对于重定向行为,很多都被“内置”起来了:即不输出INFO日志。例如Apache HttpClient对响应码3XX的处理(参考org.apache.http.impl.client.DefaultRedirectStrategy)只打印了Debug日志:
|
||||
|
||||
```
|
||||
final String location = locationHeader.getValue();
|
||||
if (this.log.isDebugEnabled()) {
|
||||
this.log.debug("Redirect requested to location '" + location + "'");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
再如,当我们使用Jedis访问Redis集群中的结点时,如果数据不在当前的节点了,也会发生“重定向”,而它并没有打印出日志让我们知道这件事的发生(参考redis.clients.jedis.JedisClusterCommand):
|
||||
|
||||
```
|
||||
private T runWithRetries(final int slot, int attempts, boolean tryRandomNode, JedisRedirectionException redirect) {
|
||||
//省略非关键代码
|
||||
} catch (JedisRedirectionException jre) {
|
||||
// if MOVED redirection occurred,
|
||||
if (jre instanceof JedisMovedDataException) {
|
||||
//此处没有输入任何日志指明接下来的执行会“跳转”了。
|
||||
// it rebuilds cluster's slot cache recommended by Redis cluster specification
|
||||
this.connectionHandler.renewSlotCache(connection);
|
||||
}
|
||||
|
||||
//省略非关键代码
|
||||
return runWithRetries(slot, attempts - 1, false, jre);
|
||||
} finally {
|
||||
releaseConnection(connection);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
综上,重试是最普通的,也是最简单的导致延时增加的“绊脚石”,而重试问题的界定难度取决于自身和下游服务是否有明显的痕迹指明。而对于这种绊脚石的消除,一方面我们应该主动出击,尽量减少引发重试的因素。另一方面,我们一定要控制好重试,例如:
|
||||
|
||||
- 控制好重试的次数;
|
||||
- 错峰重试的时间;
|
||||
- 尽可能准确识别出重试的成功率,以减少不必要的重试,例如我们可以通过“熔断”“快速失败”等机制来实现。
|
||||
|
||||
## 场景2:失败引发轮询
|
||||
|
||||
### 案例
|
||||
|
||||
在使用Apache HttpClient发送HTTP请求时,稍有经验的程序员都知道去控制下超时时间,这样,在连接不上服务器或者服务器无响应时,响应延时都会得到有效的控制,例如我们会使用下面的代码来配置HttpClient:
|
||||
|
||||
```
|
||||
RequestConfig requestConfig = RequestConfig.custom().
|
||||
setConnectTimeout(2 * 1000). //控制连接建立时间
|
||||
setConnectionRequestTimeout(1 * 1000).//控制获取连接时间
|
||||
setSocketTimeout(3 * 1000).//控制“读取”数据等待时间
|
||||
build();
|
||||
|
||||
```
|
||||
|
||||
以上面的代码为例,你能估算出响应时间最大是多少么?上面的代码实际设置了三个参数,是否直接相加就能计算出最大延时时间?即所有请求100%控制在6秒。
|
||||
|
||||
先不说结论,通过实际的生产线观察,我们确实发现大多符合我们的预期:可以说99.9%的响应都控制在6秒以内,但是总有一些“某年某月某天”,发现有一些零星的请求甚至超过了10秒,这又是为什么?
|
||||
|
||||
### 解析
|
||||
|
||||
经过问题跟踪,我们发现我们访问的URL是一个下游服务的域名(大多如此,并不稀奇),而这个域名本身有点特殊,由于负载均衡等因素的考虑,我们将它绑定到了多个IP地址。所以假设这些IP地址中,一些IP地址指向的服务临时不服务时,则会引发轮询,即轮询其它IP地址直到最终成功或失败,而每一次轮询中的失败都会额外增加一倍ConnectTimeout,所以我们发现超过6秒甚至10秒的请求也不稀奇了。我们可以通过HttpClient的源码来验证下这个逻辑(参考org.apache.http.impl.conn.DefaultHttpClientConnectionOperator.connect方法):
|
||||
|
||||
```
|
||||
public void connect(
|
||||
final ManagedHttpClientConnection conn,
|
||||
final HttpHost host,
|
||||
final InetSocketAddress localAddress,
|
||||
final int connectTimeout,
|
||||
final SocketConfig socketConfig,
|
||||
final HttpContext context) throws IOException {
|
||||
final Lookup<ConnectionSocketFactory> registry = getSocketFactoryRegistry(context);
|
||||
final ConnectionSocketFactory sf = registry.lookup(host.getSchemeName());
|
||||
//域名解析,可能解析出多个地址
|
||||
final InetAddress[] addresses = host.getAddress() != null ?
|
||||
new InetAddress[] { host.getAddress() } : this.dnsResolver.resolve(host.getHostName());
|
||||
final int port = this.schemePortResolver.resolve(host);
|
||||
|
||||
//对于解析出的地址,进行连接,如果中途有失败,尝试下一个
|
||||
for (int i = 0; i < addresses.length; i++) {
|
||||
final InetAddress address = addresses[i];
|
||||
final boolean last = i == addresses.length - 1;
|
||||
|
||||
Socket sock = sf.createSocket(context);
|
||||
conn.bind(sock);
|
||||
|
||||
final InetSocketAddress remoteAddress = new InetSocketAddress(address, port);
|
||||
if (this.log.isDebugEnabled()) {
|
||||
this.log.debug("Connecting to " + remoteAddress);
|
||||
}
|
||||
try {
|
||||
//使用解析出的地址执行连接
|
||||
sock = sf.connectSocket(
|
||||
connectTimeout, sock, host, remoteAddress, localAddress, context);
|
||||
conn.bind(sock);
|
||||
if (this.log.isDebugEnabled()) {
|
||||
this.log.debug("Connection established " + conn);
|
||||
}
|
||||
//如果连接成功,则直接退出,不继续尝试其它地址
|
||||
return;
|
||||
} catch (final SocketTimeoutException ex) {
|
||||
if (last) {
|
||||
throw new ConnectTimeoutException(ex, host, addresses);
|
||||
}
|
||||
} catch (final ConnectException ex) {
|
||||
if (last) { //如果连接到最后一个地址,还是失败,则抛出异常。如果不是最后一个,则轮询下一个地址进行连接。
|
||||
final String msg = ex.getMessage();
|
||||
if ("Connection timed out".equals(msg)) {
|
||||
throw new ConnectTimeoutException(ex, host, addresses);
|
||||
} else {
|
||||
throw new HttpHostConnectException(ex, host, addresses);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.log.isDebugEnabled()) {
|
||||
this.log.debug("Connect to " + remoteAddress + " timed out. " +
|
||||
"Connection will be retried using another IP address");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过以上代码,我们可以清晰地看出:在一个域名能解析出多个IP地址的场景下,如果其中部分IP指向的服务不可达时,延时就可能会增加。这里不妨再举个例子,对于Redis集群,我们会在客户端配置多个连接节点(例如在SpringBoot中配置spring.redis.cluster.nodes=10.224.56.101:8001,10.224.56.102:8001),通过连接节点来获取整个集群的信息(其它所有节点)。正常情况下,我们都会连接成功,所以我们没有看到长延时情况,但是假设刚初始化时,连接的部分节点不服务了,那这个时候就会连接其它配置的节点,从而导致延时倍增。
|
||||
|
||||
### 小结
|
||||
|
||||
这部分我们主要看了失败引发轮询造成的长延时案例,细心的同学可能会觉得这不就是上一部分介绍的重试么?但是实际上,你仔细体会,两者虽然都旨在提供系统可靠性,但却略有区别:重试一般指的是针对同一个目标进行的再次尝试,而轮询则更侧重对同类目标的遍历。
|
||||
|
||||
另外,除了以上介绍的开源组件(Apache HttpClient和RedisClient)案例外,还有其它一些常见组件都可能因为轮询而造成延时超过预期。例如对于Oracle连接,我们采用下面的这种配置时,也可能会出现轮询的情况:
|
||||
|
||||
```
|
||||
DBURL=jdbc:oracle:thin:@(DESCRIPTION=(load_balance=off)(failover=on)(ADDRESS=(PROTOCOL=TCP)(HOST=10.224.11.91)(PORT=1804))(ADDRESS=(PROTOCOL=TCP)(HOST=10.224.11.92)(PORT=1804))(ADDRESS=(PROTOCOL=TCP)(HOST= 10.224.11.93)(PORT=1804))(CONNECT_DATA=(SERVICE_NAME=xx.yy.com)(FAILOVER_MODE=(TYPE=SELECT)(METHOD=BASIC)(RETRIES=6)(DELAY=5))))
|
||||
DBUserName=admin
|
||||
DBPassword=password
|
||||
|
||||
```
|
||||
|
||||
其实通过对以上三个案例的观察,我们不难得出一个小规律:**假设我们配置了多个同种资源,那么就很有可能存在轮询情况,这种轮询会让延时远超出我们的预期。**只是幸运的是,在大多情况下,轮询第一次就成功了,所以我们很难观察到长延时的情况。针对这种情况造成的延时,我们除了在根源上消除外因,还要特别注意控制好超时时间,假设我们不知道这种情况,我们乐观地设置了一个很大的时间,则实际发生轮询时,这个时间会被放大很多倍。
|
||||
|
||||
这里再回到最开始的案例啰嗦几句,对于HttpClient的访问,是否加上最大轮询时间就是最大的延时时间呢?其实仍然不是,至少我们还忽略了一个时间,即DNS解析花费的时间。这里就不再展开讲了。
|
||||
|
||||
## 场景3:GC的“STW”停顿
|
||||
|
||||
### 案例
|
||||
|
||||
系统之间调用是服务最常见的方式,但这也是最容易发生“掐架”的斗争之地。例如对于组件A,运维或者测试工程师反映某接口偶然性能稍差,而对于这个接口而言,实际逻辑“简单至极”,直接调用组件B的接口,而对于这个接口的调用,平时确实都是接近1ms的:
|
||||
|
||||
```
|
||||
[07/04/2020 07:18:16.495 pid=3160 tid=3078502112] Info:[ComponentA] Send to Component B:
|
||||
[07/04/2020 07:18:16.496 pid=3160 tid=3078502112] Info:[ComponentA] Receive response from B
|
||||
|
||||
```
|
||||
|
||||
而反映的性能掉队不经常发生,而且发生时,也没有没有特别的信息,例如下面这段日志,延时达到了400ms:
|
||||
|
||||
```
|
||||
[07/04/2020 07:16:27.222 pid=4725 tid=3078407904] Info: [ComponentA] Send to Component B:
|
||||
[07/04/2020 07:16:27.669 pid=4725 tid=3078407904] Info: [ComponentA] Receive response from B
|
||||
|
||||
```
|
||||
|
||||
同时,对比下,我们也发现这2次请求其实很近,只有2分钟的差距。那么这又是什么导致的呢?
|
||||
|
||||
### 解析
|
||||
|
||||
对于这种情况,很明显,A组件往往会直接“甩锅”给B组件。于是,B组件工程师查询了日志:
|
||||
|
||||
```
|
||||
[07/04/2020 07:16:27.669][nioEventLoopGroup-4-1]INFO [ComponentB] Received request from Component A
|
||||
[07/04/2020 07:16:27.669][nioEventLoopGroup-4-1]INFO [ComponentB] Response to Component B
|
||||
|
||||
```
|
||||
|
||||
然后B组件也开始甩锅:鉴于我们双方组件传输层都是可靠的,且N年没有改动,那这次的慢肯定是网络抖动造成的了。貌似双方也都得到了理想的理由,但是问题还是没有解决,这种类似的情况还是时而发生,那问题到底还有别的什么原因么?
|
||||
|
||||
鉴于我之前的经验,其实我们早先就知道Java的STW(Stop The World)现象,可以说Java最省心的地方也是最容易让人诟病的地方。即不管怎么优化,或采用更先进的垃圾回收算法,都避免不了GC,而GC会引发停顿。其实上面这个案例,真实的原因确实就是B组件的GC导致了它的处理停顿,从而没有及时接受到A发出的信息,何以见得呢?
|
||||
|
||||
早先在设计B组件时,我们就考虑到未来某天可能会发生类似的事情,所以加了一个GC的跟踪日志,我们先来看看日志:
|
||||
|
||||
```
|
||||
{
|
||||
"metricName": "java_gc",
|
||||
"componentType": "B",
|
||||
"componentAddress": "10.224.3.10",
|
||||
"componentVer": "1.0",
|
||||
"poolName": "test001",
|
||||
"trackingID": "269",
|
||||
"metricType": "innerApi",
|
||||
"timestamp": "2020-07-04T07:16:27.219Z",
|
||||
"values": {
|
||||
"steps": [
|
||||
],
|
||||
"totalDurationInMS": 428
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在07:16:27.219时,发生了GC,且一直持续了428ms,所以最悲观的停顿时间是从219ms到647ms,而我们观察下A组件的请求发送时间222是落在这个区域的,再核对B组件接收这个请求的时间是669,是GC结束后的时间。所以很明显,排除其它原因以后,这明显是受了GC的影响。
|
||||
|
||||
### 小结
|
||||
|
||||
假设某天我们看到零星请求有“掉队”,且没有什么规律,但是又持续发生,我们往往都会怀疑是网络抖动,但是假设我们的组件是部署在同一个网络内,实际上,不大可能是网络原因导致的,而更可能是GC的原因。当然,跟踪GC有N多方法,这里我只是额外贴上了组件B使用的跟踪代码:
|
||||
|
||||
```
|
||||
List<GarbageCollectorMXBean> gcbeans = ManagementFactory.getGarbageCollectorMXBeans();
|
||||
for (GarbageCollectorMXBean gcbean : gcbeans) {
|
||||
LOGGER.info("GC bean: " + gcbean);
|
||||
if (!(gcbean instanceof NotificationEmitter))
|
||||
continue;
|
||||
|
||||
NotificationEmitter emitter = (NotificationEmitter) gcbean;
|
||||
|
||||
//注册一个GC(垃圾回收)的通知回调
|
||||
emitter.addNotificationListener(new NotificationListenerImplementation(), notification -> {
|
||||
return GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION
|
||||
.equals(notification.getType());
|
||||
}, null);
|
||||
|
||||
}
|
||||
|
||||
public final static class NotificationListenerImplementation implements NotificationListener {
|
||||
|
||||
@Override
|
||||
public void handleNotification(Notification notification, Object handback) {
|
||||
GarbageCollectionNotificationInfo info = GarbageCollectionNotificationInfo
|
||||
.from((CompositeData) notification.getUserData());
|
||||
String gctype = info.getGcAction().replace("end of ", "");
|
||||
//此处只获取major gc的相关信息
|
||||
if(gctype.toLowerCase().contains("major")){
|
||||
long id = info.getGcInfo().getId();
|
||||
long startTime = info.getGcInfo().getStartTime();
|
||||
long duration = info.getGcInfo().getDuration();
|
||||
//省略非关键代码,记录GC相关信息,如耗费多久、开始时间点等。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
另外同样是停顿,发生的时机不同,呈现的效果也不完全相同,具体问题还得具体分析。至于应对这个问题的策略,就是我们写Java程序一直努力的方向:减少GC引发的STW时间。
|
||||
|
||||
以上是我总结的3种常见“绊脚石”,那其实类似这样的问题还有很多,下一期分享我会再总结出4个场景化的问题,和你一起探讨应对策略。
|
||||
200
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场4|傅健:那些年,影响我们达到性能巅峰的常见绊脚石(下).md
Normal file
200
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场4|傅健:那些年,影响我们达到性能巅峰的常见绊脚石(下).md
Normal file
@@ -0,0 +1,200 @@
|
||||
<audio id="audio" title="大咖助场4|傅健:那些年,影响我们达到性能巅峰的常见绊脚石(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/98/93/984033f1c5437fa23afcc74262afaa93.mp3"></audio>
|
||||
|
||||
你好,我是傅健,又见面了。上一期分享我们总结了3个场景化的问题以及应对策略,这一期我们就接着“系统性能优化”这个主题继续总结。
|
||||
|
||||
## 场景1:资源争用
|
||||
|
||||
### 案例
|
||||
|
||||
一段时间,我们总是监控到一些性能“掉队”的请求,例如平时我们访问Cassandra数据库都在10ms以内,但是偶尔能达到3s,你可以参考下面这个度量数据:
|
||||
|
||||
```
|
||||
{
|
||||
"stepName": "QueryInformation",
|
||||
"values": {
|
||||
"componentType": "Cassandra",
|
||||
"totalDurationInMS": 3548,
|
||||
"startTime": "2018-05-11T08:20:28.889Z",
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
持续观察后,我们发现这些掉队的请求都集中在每天8点20分,话说“百果必有因”,这又是什么情况呢?
|
||||
|
||||
### 解析
|
||||
|
||||
这种问题,其实相对好查,因为它们有其发生的规律,这也是我们定位性能问题最基本的手段,即找规律:发生在某一套环境?某一套机器?某个时间点?等等,这些都是非常有用的线索。而这个案例就是固定发生在某个时间点。既然是固定时间点,说明肯定有某件事固定发生在这个点,所以查找问题的方向自然就明了了。
|
||||
|
||||
首先,我们上来排除了应用程序及其下游应用程序定时去做任务的情况。那么除了应用程序自身做事情外,还能是什么?可能我们会想到:运行应用程序的机器在定时做事情。果然,我们查询了机器的CronJob,发现服务器在每天的8点20分(业务低峰期)都会去归档业务的日志,而这波集中的日志归档操作,又带来了什么影响呢?
|
||||
|
||||
日志备份明显是磁盘操作,所以我们要查看的是磁盘的性能影响,如果磁盘都转不动了,可想而知会引发什么。我们一般都会有图形化的界面去监控磁盘性能,但是这里为了更为通用的演示问题,所以我使用了SAR的结果来展示当时的大致情况(说是大致,是因为SAR的历史记录结果默认是以10分钟为间隔,所以只显示8:20分的数据,可能8:21分才是尖峰,但是却不能准确反映):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/88/ba6f20c32d3a3288f4e3467b70b68988.png" alt="">
|
||||
|
||||
从上面的结果我们可以看出,平时磁盘await只要2ms,而8:20分的磁盘操作达到了几百毫秒。磁盘性能的急剧下降影响了应用程序的性能。
|
||||
|
||||
### 小结
|
||||
|
||||
在这个案例中,服务器上的定时日志归档抢占了我们的资源,导致应用程序速度临时下降,即资源争用导致了性能掉队。我们再延伸一点来看,除了这种情况外,还有许多类似的资源争用都有可能引起这类问题,例如我们有时候会在机器上装一些logstash、collectd等监控软件,而这些监控软件如果不限制它们对资源的使用,同样也可能会争用我们很多的资源。诸如此类,不一一枚举,而针对这种问题,很明显有好几种方法可以供我们去尝试解决,以当前的案例为例:
|
||||
|
||||
- 降低资源争用,例如让备份慢一点;
|
||||
- 错峰,错开备份时间,例如不让每个机器都是8点20分去备份,而是在大致一个时间范围内随机时间进行;
|
||||
- 避免资源共享,避免多个机器/虚拟机使用同一块磁盘。
|
||||
|
||||
上面讨论的争用都发生在一个机器内部,实际上,资源争用也常发生在同一资源(宿主、NFS磁盘等)的不同虚拟机之间,这也是值得注意的一个点。
|
||||
|
||||
## 场景2:延时加载
|
||||
|
||||
### 案例
|
||||
|
||||
某日,某测试工程师胸有成竹地抱怨:“你这个API接口性能不行啊,我们每次执行自动化测试时,总有响应很慢的请求。”于是一个常见的争执场景出现了:当面演示调用某个API若干次,结果都是响应极快,测试工程师坚持他真的看到了请求很慢的时候,但是开发又坚定说在我的机器上它就是可以啊。于是互掐不停。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/53/ba0e43c92a1690a8266746157b66d653.jpg" alt="">
|
||||
|
||||
### 解析
|
||||
|
||||
开发者后来苦思冥想很久,和测试工程师一起比较了下到底他们的测试有什么不同,唯一的区别在于测试的时机不同:自动化测试需要做的是回归测试,所以每次都会部署新的包并执行重启,而开发者复现问题用的系统已经运行若干天,那这有什么区别呢?这就引入了我们这部分要讨论的内容——延时加载。
|
||||
|
||||
我们知道当系统去执行某个操作时,例如访问某个服务,往往都是“按需执行”,这非常符合我们的行为习惯。例如,需要外卖点餐时,我们才打开某APP,我们一般不会在还没有点餐的时候,就把APP打开“守株待兔”某次点餐的发生。这种思路写出来的系统,会让我们的系统在上线之时,可以轻装上阵。例如,非常类似下面的Java“延时初始化”伪代码:
|
||||
|
||||
```
|
||||
public class AppFactory{
|
||||
private static App app;
|
||||
synchronized App getApp() {
|
||||
if (App == null)
|
||||
app= openAppAndCompleteInit();
|
||||
return app;
|
||||
}
|
||||
//省略其它非关键代码
|
||||
}
|
||||
|
||||
App app = AppFactory.getApp();
|
||||
app.order("青椒土豆丝");
|
||||
|
||||
```
|
||||
|
||||
但这里的问题是什么呢?假设打开APP的操作非常慢,那等我们需要点餐的时候,就会额外耗费更长的时间(而一旦打开运行在后台,后面不管怎么点都会很快)。这点和我们的案例其实是非常类似的。
|
||||
|
||||
我们现在回到案例,持续观察发现,这个有性能问题的API只出现在第一次访问,或者说只出现在系统重启后的第一次访问。当它的API被触发时,它会看看本地有没有授权相关的信息,如果没有则远程访问一个授权服务拿到相关的认证信息,然后缓存认证信息,最后再使用这个认证信息去访问其它组件。而问题恰巧就出现在访问授权服务器完成初始化操作耗时比较久,所以呈现出第一次访问较慢,后续由于已缓存消息而变快的现象。
|
||||
|
||||
那这个问题怎么解决呢?我们可以写一个“加速器”,说白了,就是把“延时加载”变为“主动加载”。在系统启动之后、正常提供服务之前,就提前访问授权服务器拿到授权信息,这样等系统提供服务之后,我们的第一个请求就很快了。类似使用Java伪代码,对上述的延时加载修改如下:
|
||||
|
||||
```
|
||||
public class AppFactory{
|
||||
private static App app = openAppAndCompleteInit();
|
||||
synchronized App getApp() {
|
||||
return app;
|
||||
}
|
||||
//省略其它非关键代码
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 小结
|
||||
|
||||
延时加载固然很好,可以让我们的系统轻装上阵,写出的程序也符合我们的行为习惯,但是它常常带来的问题是,在第一次访问时可能性能不符合预期。当遇到这种问题时,我们也可以根据它的出现特征来分析是不是属于这类问题,即是否是启动完成后的第一次请求。如果是此类问题,我们可以通过变“被动加载”为“主动加载”的方式来加速访问,从而解决问题。
|
||||
|
||||
但是这里我不得不补充一点,是否在所有场景下,我们都需要化被动为主动呢?实际上,还得具体情况具体分析,例如我们打开一个网页,里面内嵌了很多小图片链接,但我们是否会为了响应速度,提前将这些图片进行加载呢?一般我们都不会这么做。所以具体问题具体分析永远是真理。针对我们刚刚讨论的案例,这种加速只是一次性的而已,而且资源数量和大小都是可控的,所以这种修改是值得,也是可行的。
|
||||
|
||||
## 场景3:网络抖动
|
||||
|
||||
### 案例
|
||||
|
||||
我们来看一则[新闻](https://finance.sina.com.cn/money/bank/bank_hydt/2019-12-05/doc-iihnzhfz3876113.shtml):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/fd/518c5a356e5b956a88be92b2476f8ffd.jpg" alt="">
|
||||
|
||||
类似的新闻还有许多,你可以去搜一搜,然后你就会发现:它们都包含一个关键词——网络抖动。
|
||||
|
||||
### 解析
|
||||
|
||||
那什么是网络抖动呢?网络抖动是衡量网络服务质量的一个指标。假设我们的网络最大延迟为100ms,最小延迟为10ms,那么网络抖动就是90ms,即网络延时的最大值与最小值的差值。差值越小,意味着抖动越小,网络越稳定。反之,当网络不稳定时,抖动就会越大,网络延时差距也就越大,反映到上层应用自然是响应速度的“掉队”。
|
||||
|
||||
为什么网络抖动如此难以避免?这是因为网络的延迟包括两个方面:传输延时和处理延时。忽略处理延时这个因素,假设我们的一个主机进行一次服务调用,需要跨越千山万水才能到达服务器,我们中间出“岔子”的情况就会越多。我们在Linux下面可以使用traceroute命令来查看我们跋山涉水的情况,例如从我的Linux机器到百度的情况是这样的:
|
||||
|
||||
```
|
||||
[root@linux~]# traceroute -n -T www.baidu.com
|
||||
traceroute to www.baidu.com (119.63.197.139), 30 hops max, 60 byte packets
|
||||
1 10.224.2.1 0.452 ms 0.864 ms 0.914 ms
|
||||
2 1.1.1.1 0.733 ms 0.774 ms 0.817 ms
|
||||
3 10.224.16.193 0.361 ms 0.369 ms 0.362 ms
|
||||
4 10.224.32.9 0.355 ms 0.414 ms 0.478 ms
|
||||
5 10.140.199.77 0.400 ms 0.396 ms 0.544 ms
|
||||
6 10.124.104.244 12.937 ms 12.655 ms 12.706 ms
|
||||
7 10.124.104.195 12.736 ms 12.628 ms 12.851 ms
|
||||
8 10.124.104.217 13.093 ms 12.857 ms 12.954 ms
|
||||
9 10.112.4.65 12.586 ms 12.510 ms 12.609 ms
|
||||
10 10.112.8.222 44.250 ms 44.183 ms 44.174 ms
|
||||
11 10.112.0.122 44.926 ms 44.360 ms 44.479 ms
|
||||
12 10.112.0.78 44.433 ms 44.320 ms 44.508 ms
|
||||
13 10.75.216.50 44.295 ms 44.194 ms 44.386 ms
|
||||
14 10.75.224.202 46.191 ms 46.135 ms 46.042 ms
|
||||
15 119.63.197.139 44.095 ms 43.999 ms 43.927 ms
|
||||
|
||||
```
|
||||
|
||||
通过上面的命令结果我们可以看出,我的机器到百度需要很多“路”。当然大多数人并不喜欢使用traceroute来评估这段路的艰辛程度,而是直接使用ping来简单看看“路”的远近。例如通过以下结果,我们就可以看出,我们的网络延时达到了40ms,这时网络延时就可能是一个问题了。
|
||||
|
||||
```
|
||||
[root@linux~]# ping www.baidu.com
|
||||
PING www.wshifen.com (103.235.46.39) 56(84) bytes of data.
|
||||
64 bytes from 103.235.46.39: icmp_seq=1 ttl=41 time=46.2 ms
|
||||
64 bytes from 103.235.46.39: icmp_seq=2 ttl=41 time=46.3 ms
|
||||
|
||||
```
|
||||
|
||||
其实上面这两个工具的使用只是直观反映网络延时,它们都默认了一个潜规则:网络延时越大,网络越抖动。且不说这个规则是否完全正确,至少从结果来看,评估网络抖动并不够直观。
|
||||
|
||||
所以我们可以再寻求一些其它的工具。例如可以使用MTR工具,它集合了tractroute和ping。我们可以看下执行结果:下图中的best和wrst字段,即为最好的情况与最坏的情况,两者的差值也能在一定程度上反映出抖动情况,其中不同的host相当于traceroute经过的“路”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/4f/04a239ef67790671e494cde7416ddb4f.png" alt="">
|
||||
|
||||
### 小结
|
||||
|
||||
对于网络延时造成的抖动,特别是传输延迟造成的抖动,我们一般都是“有心无力”的。只能说,我们需要做好网络延时的抖动监测,从而能真正定位到这个问题,避免直接无证据就“甩锅”于网络。
|
||||
|
||||
另外,在做设计和部署时,我们除了容灾和真正的业务需求,应尽量避免或者说减少“太远”的访问,尽量将服务就近到一个机房甚至一个机柜,这样就能大幅度降低网络延迟,抖动情况也会降低许多,这也是为什么CDN等技术兴起的原因。
|
||||
|
||||
那同一网络的网络延时可以有多好呢?我们可以自己测试下。例如,我的Linux机器如果ping同一个网段的机器,是连1ms都不到的:
|
||||
|
||||
```
|
||||
[root@linux~]# ping 10.224.2.146
|
||||
PING 10.224.2.146 (10.224.2.146) 56(84) bytes of data.
|
||||
64 bytes from 10.224.2.146: icmp_seq=1 ttl=64 time=0.212 ms
|
||||
64 bytes from 10.224.2.146: icmp_seq=2 ttl=64 time=0.219 ms
|
||||
64 bytes from 10.224.2.146: icmp_seq=3 ttl=64 time=0.154 ms
|
||||
|
||||
```
|
||||
|
||||
## 场景4:缓存失效
|
||||
|
||||
### 案例
|
||||
|
||||
在产线上,我们会经常观察到一些API接口调用周期性(固定时间间隔)地出现“长延时”,而另外一些接口调用也会偶尔如此,只是时间不固定。继续持续观察,你会发现,虽然后者时间不够规律,但是它们的出现时间间隔都是大于一定时间间隔的。那这种情况是什么原因导致的呢?
|
||||
|
||||
### 解析
|
||||
|
||||
当我们遇到上述现象,很有可能是因为“遭遇”了缓存失效。缓存的定义与作用这里不多赘述,你应该非常熟悉了。同时我们也都知道,缓存是以空间换时间的方式来提高性能,讲究均衡。在待缓存内容很多的情况下,不管我们使用本地缓存,还是Redis、Memcached等缓存方案,我们经常都会受限于“空间”或缓存条目本身的时效性,而给缓存设置一个失效时间。而当失效时间到来时,我们就需要访问数据的源头从而增加延时。这里以下面的缓存构建代码作为示例:
|
||||
|
||||
```
|
||||
CacheBuilder.newBuilder().maximumSize(10_000).expireAfterWrite(10, TimeUnit.MINUTES).build();
|
||||
|
||||
```
|
||||
|
||||
我们可以看到:一旦缓存后,10分钟就会失效,但是失效后,我们不见得刚好有请求过来。如果这个接口是频繁调用的,则长延时请求的出现频率会呈现出周期性的10分钟间隔规律,即案例描述的前者情况。而如果这个接口调用很不频繁,则我们只能保证在10分钟内的延时是平滑的。当然,这里讨论的场景都是在缓存够用的情况下,如果缓存的条目超过了设定的最大允许值,这可能会提前淘汰一些缓存内容,这个时候长延时请求出现的规律就无章可循了。
|
||||
|
||||
### 小结
|
||||
|
||||
缓存失效是导致偶发性延时增加的常见情况,也是相对来说比较好定位的问题,因为接口的执行路径肯定是不同的,只要我们有充足的日志,即可找出问题所在。另外,针对这种情况,一方面我们可以增加缓存时间来尽力减少长延时请求,但这个时候要求的空间也会增大,同时也可能违反了缓存内容的时效性要求;另一方面,在一些情况下(比如缓存条目较少、缓存的内容可靠性要求不高),我们可以取消缓存的TTL,更新数据库时实时更新缓存,这样读取数据就可以一直通过缓存进行。总之,应情况调整才是王道。
|
||||
|
||||
## 总结
|
||||
|
||||
结合我上一期的分享,我们一共总结了7种常见“绊脚石”及其应对策略,那通过了解它们,我们是否就有十足的信心能达到性能巅峰了呢?
|
||||
|
||||
其实在实践中,我们往往还是很难的,特别是当前微服务大行其道,决定我们系统性能的因素往往是下游微服务,而下游微服务可能来源于不同的团队或组织。这时,已经不再单纯是技术本身的问题了,而是沟通、协调甚至是制度等问题。但是好在对于下游微服务,我们依然可以使用上面的分析思路来找出问题所在,不过通过上面的各种分析你也可以知道,让性能做到极致还是很难的,总有一些情况超出我们的预期,例如我们使用的磁盘发生损害,彻底崩溃前也会引起性能下降。
|
||||
|
||||
另外一个值得思考的问题是,是否有划算的成本收益比去做无穷无尽的优化,当然对于技术极客来说,能不能、让不让解决问题也许不是最重要的,剥丝抽茧、了解真相才是最有成就感的事儿。
|
||||
|
||||
感谢阅读,希望今天的分享能让你有所收获!如果你发现除了上述我介绍的那些“绊脚石”外,还有其它一些典型情况存在,也欢迎你在留言区中分享出来作为补充。
|
||||
166
极客时间专栏/系统性能调优必知必会/基础设施优化/01 | CPU缓存:怎样写代码能够让CPU执行得更快?.md
Normal file
166
极客时间专栏/系统性能调优必知必会/基础设施优化/01 | CPU缓存:怎样写代码能够让CPU执行得更快?.md
Normal file
@@ -0,0 +1,166 @@
|
||||
<audio id="audio" title="01 | CPU缓存:怎样写代码能够让CPU执行得更快?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7b/1d/7b26aa9f7d8ba80b35c0ecaba009151d.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
这是课程的第一讲,我们先从主机最重要的部件CPU开始,聊聊如何通过提升CPU缓存的命中率来优化程序的性能。
|
||||
|
||||
任何代码的执行都依赖CPU,通常,使用好CPU是操作系统内核的工作。然而,当我们编写计算密集型的程序时,CPU的执行效率就开始变得至关重要。由于CPU缓存由更快的SRAM构成(内存是由DRAM构成的),而且离CPU核心更近,如果运算时需要的输入数据是从CPU缓存,而不是内存中读取时,运算速度就会快很多。所以,了解CPU缓存对性能的影响,便能够更有效地编写我们的代码,优化程序性能。
|
||||
|
||||
然而,很多同学并不清楚CPU缓存的运行规则,不知道如何写代码才能够配合CPU缓存的工作方式,这样,便放弃了可以大幅提升核心计算代码执行速度的机会。而且,越是底层的优化,适用范围越广,CPU缓存便是如此,它的运行规则对分布式集群里各种操作系统、编程语言都有效。所以,一旦你能掌握它,集群中巨大的主机数量便能够放大优化效果。
|
||||
|
||||
接下来,我们就看看,CPU缓存结构到底是什么样的,又该如何优化它?
|
||||
|
||||
## CPU的多级缓存
|
||||
|
||||
刚刚我们提到,CPU缓存离CPU核心更近,由于电子信号传输是需要时间的,所以离CPU核心越近,缓存的读写速度就越快。但CPU的空间很狭小,离CPU越近缓存大小受到的限制也越大。所以,综合硬件布局、性能等因素,CPU缓存通常分为大小不等的三级缓存。
|
||||
|
||||
CPU缓存的材质SRAM比内存使用的DRAM贵许多,所以不同于内存动辄以GB计算,它的大小是以MB来计算的。比如,在我的Linux系统上,离CPU最近的一级缓存是32KB,二级缓存是256KB,最大的三级缓存则是20MB(Windows系统查看缓存大小可以用wmic cpu指令,或者用[CPU-Z](https://www.cpuid.com/softwares/cpu-z.html)这个工具)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/87/deff13454dcb6b15e1ac4f6f538c4987.png" alt="">
|
||||
|
||||
你可能注意到,三级缓存要比一、二级缓存大许多倍,这是因为当下的CPU都是多核心的,每个核心都有自己的一、二级缓存,但三级缓存却是一颗CPU上所有核心共享的。
|
||||
|
||||
程序执行时,会先将内存中的数据载入到共享的三级缓存中,再进入每颗核心独有的二级缓存,最后进入最快的一级缓存,之后才会被CPU使用,就像下面这张图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/92/0c/9277d79155cd7f925c27f9c37e0b240c.jpg" alt="">
|
||||
|
||||
缓存要比内存快很多。CPU访问一次内存通常需要100个时钟周期以上,而访问一级缓存只需要4~5个时钟周期,二级缓存大约12个时钟周期,三级缓存大约30个时钟周期(对于2GHZ主频的CPU来说,一个时钟周期是0.5纳秒。你可以在LZMA的[Benchmark](https://www.7-cpu.com/)中找到几种典型CPU缓存的访问速度)。
|
||||
|
||||
如果CPU所要操作的数据在缓存中,则直接读取,这称为缓存命中。命中缓存会带来很大的性能提升,**因此,我们的代码优化目标是提升CPU缓存的命中率。**
|
||||
|
||||
当然,缓存命中率是很笼统的,具体优化时还得一分为二。比如,你在查看CPU缓存时会发现有2个一级缓存(比如Linux上就是上图中的index0和index1),这是因为,CPU会区别对待指令与数据。比如,“1+1=2”这个运算,“+”就是指令,会放在一级指令缓存中,而“1”这个输入数字,则放在一级数据缓存中。虽然在冯诺依曼计算机体系结构中,代码指令与数据是放在一起的,但执行时却是分开进入指令缓存与数据缓存的,因此我们要分开来看二者的缓存命中率。
|
||||
|
||||
## 提升数据缓存的命中率
|
||||
|
||||
我们先来看数据的访问顺序是如何影响缓存命中率的。
|
||||
|
||||
比如现在要遍历二维数组,其定义如下(这里我用的是伪代码,在GitHub上我为你准备了可运行验证的C/C++、Java[示例代码](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/traverse_2d_array),你可以参照它们编写出其他语言的可执行代码):
|
||||
|
||||
```
|
||||
int array[N][N];
|
||||
|
||||
```
|
||||
|
||||
你可以思考一下,用array[j][i]和array[i][j]访问数组元素,哪一种性能更快?
|
||||
|
||||
```
|
||||
for(i = 0; i < N; i+=1) {
|
||||
for(j = 0; j < N; j+=1) {
|
||||
array[i][j] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在我给出的GitHub地址上的C++代码实现中,前者array[j][i]执行的时间是后者array[i][j]的8倍之多(请参考[traverse_2d_array.cpp](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/traverse_2d_array),如果使用Python代码,traverse_2d_array.py由于数组容器的差异,性能差距不会那么大)。
|
||||
|
||||
为什么会有这么大的差距呢?这是因为二维数组array所占用的内存是连续的,比如若长度N的值为2,那么内存中从前至后各元素的顺序是:
|
||||
|
||||
```
|
||||
array[0][0],array[0][1],array[1][0],array[1][1]。
|
||||
|
||||
```
|
||||
|
||||
如果用array[i][j]访问数组元素,则完全与上述内存中元素顺序一致,因此访问array[0][0]时,缓存已经把紧随其后的3个元素也载入了,CPU通过快速的缓存来读取后续3个元素就可以。如果用array[j][i]来访问,访问的顺序就是:
|
||||
|
||||
```
|
||||
array[0][0],array[1][0],array[0][1],array[1][1]
|
||||
|
||||
```
|
||||
|
||||
此时内存是跳跃访问的,如果N的数值很大,那么操作array[j][i]时,是没有办法把array[j+1][i]也读入缓存的。
|
||||
|
||||
到这里我们还有2个问题没有搞明白:
|
||||
|
||||
1. 为什么两者的执行时间有约7、8倍的差距呢?
|
||||
1. 载入array[0][0]元素时,缓存一次性会载入多少元素呢?
|
||||
|
||||
其实这两个问题的答案都与CPU Cache Line相关,它定义了缓存一次载入数据的大小,Linux上你可以通过coherency_line_size配置查看它,通常是64字节。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7d/de/7dc8d0c5a1461d9aed086e7a112c01de.png" alt="">
|
||||
|
||||
因此,我测试的服务器一次会载入64字节至缓存中。当载入array[0][0]时,若它们占用的内存不足64字节,CPU就会顺序地补足后续元素。顺序访问的array[i][j]因为利用了这一特点,所以就会比array[j][i]要快。也正因为这样,当元素类型是4个字节的整数时,性能就会比8个字节的高精度浮点数时速度更快,因为缓存一次载入的元素会更多。
|
||||
|
||||
**因此,遇到这种遍历访问数组的情况时,按照内存布局顺序访问将会带来很大的性能提升。**
|
||||
|
||||
再来看为什么执行时间相差8倍。在二维数组中,其实第一维元素存放的是地址,第二维存放的才是目标元素。由于64位操作系统的地址占用8个字节(32位操作系统是4个字节),因此,每批Cache Line最多也就能载入不到8个二维数组元素,所以性能差距大约接近8倍。(用不同的步长访问数组,也能验证CPU Cache Line对性能的影响,可参考我给你准备的[Github](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/traverse_1d_array)上的测试代码)。
|
||||
|
||||
关于CPU Cache Line的应用其实非常广泛,如果你用过Nginx,会发现它是用哈希表来存放域名、HTTP头部等数据的,这样访问速度非常快,而哈希表里桶的大小如server_names_hash_bucket_size,它默认就等于CPU Cache Line的值。由于所存放的字符串长度不能大于桶的大小,所以当需要存放更长的字符串时,就需要修改桶大小,但Nginx官网上明确建议它应该是CPU Cache Line的整数倍。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/2b/4fa0080e0f688bd484fe701686e6262b.png" alt="">
|
||||
|
||||
为什么要做这样的要求呢?就是因为按照cpu cache line(比如64字节)来访问内存时,不会出现多核CPU下的伪共享问题,可以**尽量减少访问内存的次数**。比如,若桶大小为64字节,那么根据地址获取字符串时只需要访问一次内存,而桶大小为50字节,会导致最坏2次访问内存,而70字节最坏会有3次访问内存。
|
||||
|
||||
如果你在用Linux操作系统,可以通过一个名叫Perf的工具直观地验证缓存命中的情况(可以用yum install perf或者apt-get install perf安装这个工具,这个[网址](http://www.brendangregg.com/perf.html)中有大量案例可供参考)。
|
||||
|
||||
执行perf stat可以统计出进程运行时的系统信息(通过-e选项指定要统计的事件,如果要查看三级缓存总的命中率,可以指定缓存未命中cache-misses事件,以及读取缓存次数cache-references事件,两者相除就是缓存的未命中率,用1相减就是命中率。类似的,通过L1-dcache-load-misses和L1-dcache-loads可以得到L1缓存的命中率),此时你会发现array[i][j]的缓存命中率远高于array[j][i]。
|
||||
|
||||
当然,perf stat还可以通过指令执行速度反映出两种访问方式的优劣,如下图所示(instructions事件指明了进程执行的总指令数,而cycles事件指明了运行的时钟周期,二者相除就可以得到每时钟周期所执行的指令数,缩写为IPC。如果缓存未命中,则CPU要等待内存的慢速读取,因此IPC就会很低。array[i][j]的IPC值也比array[j][i]要高得多):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/1c/29d4a9fa5b8ad4515d7129d71987b01c.png" alt=""><br>
|
||||
<img src="https://static001.geekbang.org/resource/image/94/3d/9476f52cfc63825e7ec836580e12c53d.png" alt="">
|
||||
|
||||
## 提升指令缓存的命中率
|
||||
|
||||
说完数据的缓存命中率,再来看指令的缓存命中率该如何提升。
|
||||
|
||||
我们还是用一个例子来看一下。比如,有一个元素为0到255之间随机数字组成的数组:
|
||||
|
||||
```
|
||||
int array[N];
|
||||
for (i = 0; i < TESTN; i++) array[i] = rand() % 256;
|
||||
|
||||
```
|
||||
|
||||
接下来要对它做两个操作:一是循环遍历数组,判断每个数字是否小于128,如果小于则把元素的值置为0;二是将数组排序。那么,先排序再遍历速度快,还是先遍历再排序速度快呢?
|
||||
|
||||
```
|
||||
for(i = 0; i < N; i++) {
|
||||
if (array [i] < 128) array[i] = 0;
|
||||
}
|
||||
sort(array, array +N);
|
||||
|
||||
```
|
||||
|
||||
我先给出答案:先排序的遍历时间只有后排序的三分之一(参考GitHub中的[branch_predict.cpp代码](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/branch_predict))。为什么会这样呢?这是因为循环中有大量的if条件分支,而CPU**含有分支预测器**。
|
||||
|
||||
当代码中出现if、switch等语句时,意味着此时至少可以选择跳转到两段不同的指令去执行。如果分支预测器可以预测接下来要在哪段代码执行(比如if还是else中的指令),就可以提前把这些指令放在缓存中,CPU执行时就会很快。当数组中的元素完全随机时,分支预测器无法有效工作,而当array数组有序时,分支预测器会动态地根据历史命中数据对未来进行预测,命中率就会非常高。
|
||||
|
||||
究竟有多高呢?我们还是用Linux上的perf来做个验证。使用 -e选项指明branch-loads事件和branch-load-misses事件,它们分别表示分支预测的次数,以及预测失败的次数。通过L1-icache-load-misses也能查看到一级缓存中指令的未命中情况。
|
||||
|
||||
下图是我在GitHub上为你准备的验证程序执行的perf分支预测统计数据(代码见[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/branch_predict)),你可以看到,先排序的话分支预测的成功率非常高,而且一级指令缓存的未命中率也有大幅下降。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/72/2902b3e08edbd1015b1e9ecfe08c4472.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/60/9503d2c8f7deb3647eebb8d68d317e60.png" alt="">
|
||||
|
||||
C/C++语言中编译器还给应用程序员提供了显式预测分支概率的工具,如果if中的条件表达式判断为“真”的概率非常高,我们可以用likely宏把它括在里面,反之则可以用unlikely宏。当然,CPU自身的条件预测已经非常准了,仅当我们确信CPU条件预测不会准,且我们能够知晓实际概率时,才需要加入这两个宏。
|
||||
|
||||
```
|
||||
#define likely(x) __builtin_expect(!!(x), 1)
|
||||
#define unlikely(x) __builtin_expect(!!(x), 0)
|
||||
if (likely(a == 1)) …
|
||||
|
||||
```
|
||||
|
||||
## 提升多核CPU下的缓存命中率
|
||||
|
||||
前面我们都是面向一个CPU核心谈数据及指令缓存的,然而现代CPU几乎都是多核的。虽然三级缓存面向所有核心,但一、二级缓存是每颗核心独享的。我们知道,即使只有一个CPU核心,现代分时操作系统都支持许多进程同时运行。这是因为操作系统把时间切成了许多片,微观上各进程按时间片交替地占用CPU,这造成宏观上看起来各程序同时在执行。
|
||||
|
||||
因此,若进程A在时间片1里使用CPU核心1,自然也填满了核心1的一、二级缓存,当时间片1结束后,操作系统会让进程A让出CPU,基于效率并兼顾公平的策略重新调度CPU核心1,以防止某些进程饿死。如果此时CPU核心1繁忙,而CPU核心2空闲,则进程A很可能会被调度到CPU核心2上运行,这样,即使我们对代码优化得再好,也只能在一个时间片内高效地使用CPU一、二级缓存了,下一个时间片便面临着缓存效率的问题。
|
||||
|
||||
因此,操作系统提供了将进程或者线程绑定到某一颗CPU上运行的能力。如Linux上提供了sched_setaffinity方法实现这一功能,其他操作系统也有类似功能的API可用。我在GitHub上提供了一个示例程序(代码见[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/cpu_migrate)),你可以看到,当多线程同时执行密集计算,且CPU缓存命中率很高时,如果将每个线程分别绑定在不同的CPU核心上,性能便会获得非常可观的提升。Perf工具也提供了cpu-migrations事件,它可以显示进程从不同的CPU核心上迁移的次数。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我给你介绍了CPU缓存对程序性能的影响。这是很底层的性能优化,它对各种编程语言做密集计算时都有效。
|
||||
|
||||
CPU缓存分为数据缓存与指令缓存,对于数据缓存,我们应在循环体中尽量操作同一块内存上的数据,由于缓存是根据CPU Cache Line批量操作数据的,所以顺序地操作连续内存数据时也有性能提升。
|
||||
|
||||
对于指令缓存,有规律的条件分支能够让CPU的分支预测发挥作用,进一步提升执行效率。对于多核系统,如果进程的缓存命中率非常高,则可以考虑绑定CPU来提升缓存命中率。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后请你思考下,多线程并行访问不同的变量,这些变量在内存布局是相邻的(比如类中的多个变量),此时CPU缓存就会失效,为什么?又该如何解决呢?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
133
极客时间专栏/系统性能调优必知必会/基础设施优化/02 | 内存池:如何提升内存分配的效率?.md
Normal file
133
极客时间专栏/系统性能调优必知必会/基础设施优化/02 | 内存池:如何提升内存分配的效率?.md
Normal file
@@ -0,0 +1,133 @@
|
||||
<audio id="audio" title="02 | 内存池:如何提升内存分配的效率?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/72/4dd6f9a040c1699d6c4daf496517a472.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲我们提到,高频地命中CPU缓存可以提升性能。这一讲我们把关注点从CPU转移到内存,看看如何提升内存分配的效率。
|
||||
|
||||
或许有同学会认为,我又不写底层框架,内存分配也依赖虚拟机,并不需要应用开发者了解。如果你也这么认为,我们不妨看看这个例子:在Linux系统中,用Xmx设置JVM的最大堆内存为8GB,但在近百个并发线程下,观察到Java进程占用了14GB的内存。为什么会这样呢?
|
||||
|
||||
这是因为,绝大部分高级语言都是用C语言编写的,包括Java,申请内存必须经过C库,而C库通过预分配更大的空间作为内存池,来加快后续申请内存的速度。这样,预分配的6GB的C库内存池就与JVM中预分配的8G内存池叠加在一起,造成了Java进程的内存占用超出了预期。
|
||||
|
||||
掌握内存池的特性,既可以避免写程序时内存占用过大,导致服务器性能下降或者进程OOM(Out Of Memory,内存溢出)被系统杀死,还可以加快内存分配的速度。在系统空闲时申请内存花费不了多少时间,但是对于分布式环境下繁忙的多线程服务,获取内存的时间会上升几十倍。
|
||||
|
||||
另一方面,内存池是非常底层的技术,当我们理解它后,可以更换适合应用场景的内存池。在多种编程语言共存的分布式系统中,内存池有很广泛的应用,优化内存池带来的任何微小的性能提升,都将被分布式集群巨大的主机规模放大,从而带来整体上非常可观的收益。
|
||||
|
||||
接下来,我们就通过对内存池的学习,看看如何提升内存分配的效率。
|
||||
|
||||
## 隐藏的内存池
|
||||
|
||||
实际上,在你的业务代码与系统内核间,往往有两层内存池容易被忽略,尤其是其中的C库内存池。
|
||||
|
||||
当代码申请内存时,首先会到达应用层内存池,如果应用层内存池有足够的可用内存,就会直接返回给业务代码,否则,它会向更底层的C库内存池申请内存。比如,如果你在Apache、Nginx等服务之上做模块开发,这些服务中就有独立的内存池。当然,Java中也有内存池,当通过启动参数Xmx指定JVM的堆内存为8GB时,就设定了JVM堆内存池的大小。
|
||||
|
||||
你可能听说过Google的TCMalloc和FaceBook的JEMalloc,它们也是C库内存池。当C库内存池无法满足内存申请时,才会向操作系统内核申请分配内存。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/6a/893edd82d03c628fae83b95bd4fbba6a.jpg" alt="">
|
||||
|
||||
回到文章开头的问题,Java已经有了应用层内存池,为什么还会受到C库内存池的影响呢?这是因为,除了JVM负责管理的堆内存外,Java还拥有一些堆外内存,由于它不使用JVM的垃圾回收机制,所以更稳定、持久,处理IO的速度也更快。这些堆外内存就会由C库内存池负责分配,这是Java受到C库内存池影响的原因。
|
||||
|
||||
其实不只是Java,几乎所有程序都在使用C库内存池分配出的内存。C库内存池影响着系统下依赖它的所有进程。我们就以Linux系统的默认C库内存池Ptmalloc2来具体分析,看看它到底对性能发挥着怎样的作用。
|
||||
|
||||
C库内存池工作时,会预分配比你申请的字节数更大的空间作为内存池。比如说,当主进程下申请1字节的内存时,Ptmalloc2会预分配132K字节的内存(Ptmalloc2中叫Main Arena),应用代码再申请内存时,会从这已经申请到的132KB中继续分配。
|
||||
|
||||
如下所示(你可以在[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/2-memory/alloc_address)找到示例程序,注意地址的单位是16进制):
|
||||
|
||||
```
|
||||
# cat /proc/2891/maps | grep heap
|
||||
01643000-01664000 rw-p 00000000 00:00 0 [heap]
|
||||
|
||||
```
|
||||
|
||||
当我们释放这1字节时,Ptmalloc2也不会把内存归还给操作系统。Ptmalloc2认为,与其把这1字节释放给操作系统,不如先缓存着放进内存池里,仍然当作用户态内存留下来,进程再次申请1字节的内存时就可以直接复用,这样速度快了很多。
|
||||
|
||||
你可能会想,132KB不多呀?为什么这一讲开头提到的Java进程,会被分配了几个GB的内存池呢?这是因为**多线程与单线程的预分配策略并不相同**。
|
||||
|
||||
每个**子线程预分配的内存是64MB**(Ptmalloc2中被称为Thread Arena,32位系统下为1MB,64位系统下为64MB)。如果有100个线程,就将有6GB的内存都会被内存池占用。当然,并不是设置了1000个线程,就会预分配60GB的内存,子线程内存池最多只能到8倍的CPU核数,比如在32核的服务器上,最多只会有256个子线程内存池,但这也非常夸张了,16GB(64MB * 256 = 16GB)的内存将一直被Ptmalloc2占用。
|
||||
|
||||
回到本文开头的问题,Linux下的JVM编译时默认使用了Ptmalloc2内存池,因此每个线程都预分配了64MB的内存,这造成含有上百个Java线程的JVM多使用了6GB的内存。在多数情况下,这些预分配出来的内存池,可以提升后续内存分配的性能。
|
||||
|
||||
然而,Java中的JVM内存池已经管理了绝大部分内存,确实不能接受莫名多出来6GB的内存,那该怎么办呢?既然我们知道了Ptmalloc2内存池的存在,就有两种解决办法。
|
||||
|
||||
首先可以调整Ptmalloc2的工作方式。**通过设置MALLOC_ARENA_MAX环境变量,可以限制线程内存池的最大数量**,当然,线程内存池的数量减少后,会影响Ptmalloc2分配内存的速度。不过由于Java主要使用JVM内存池来管理对象,这点影响并不重要。
|
||||
|
||||
其次可以更换掉Ptmalloc2内存池,选择一个预分配内存更少的内存池,比如Google的TCMalloc。
|
||||
|
||||
这并不是说Google出品的TCMalloc性能更好,而是在特定的场景中的选择不同。而且,盲目地选择TCMalloc很可能会降低性能,否则Linux系统早把默认的内存池改为TCMalloc了。
|
||||
|
||||
TCMalloc和Ptmalloc2是目前最主流的两个内存池,接下来我带你通过对比TCMalloc与Ptmalloc2内存池,看看到底该如何选择内存池。
|
||||
|
||||
## 选择Ptmalloc2还是TCMalloc?
|
||||
|
||||
先来看TCMalloc适用的场景,**它对多线程下小内存的分配特别友好。**
|
||||
|
||||
比如,在2GHz的CPU上分配、释放256K字节的内存,Ptmalloc2耗时32纳秒,而TCMalloc仅耗时10纳秒(测试代码参见[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/2-memory/benchmark))。**差距超过了3倍,为什么呢?**这是因为,Ptmalloc2假定,如果线程A申请并释放了的内存,线程B可能也会申请类似的内存,所以它允许内存池在线程间复用以提升性能。
|
||||
|
||||
因此,每次分配内存,Ptmalloc2一定要加锁,才能解决共享资源的互斥问题。然而,加锁的消耗并不小。如果你监控分配速度的话,会发现单线程服务调整为100个线程,Ptmalloc2申请内存的速度会变慢10倍。TCMalloc针对小内存做了很多优化,每个线程独立分配内存,无须加锁,所以速度更快!
|
||||
|
||||
而且,**线程数越多,Ptmalloc2出现锁竞争的概率就越高。**比如我们用40个线程做同样的测试,TCMalloc只是从10纳秒上升到25纳秒,只增长了1.5倍,而Ptmalloc2则从32纳秒上升到137纳秒,增长了3倍以上。
|
||||
|
||||
下图是TCMalloc作者给出的性能测试数据,可以看到线程数越多,二者的速度差距越大。所以,**当应用场景涉及大量的并发线程时,换成TCMalloc库也更有优势!**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/37/56c77fdf3a130fce4c98943f494c9237.png" alt="" title="图片来源:TCMalloc : Thread-Caching Malloc">
|
||||
|
||||
那么,为什么GlibC不把默认的Ptmalloc2内存池换成TCMalloc呢?**因为Ptmalloc2更擅长大内存的分配。**
|
||||
|
||||
比如,单线程下分配257K字节的内存,Ptmalloc2的耗时不变仍然是32纳秒,但TCMalloc就由10纳秒上升到64纳秒,增长了5倍以上!**现在TCMalloc反过来比Ptmalloc2慢了1倍!**这是因为TCMalloc特意针对小内存做了优化。
|
||||
|
||||
多少字节叫小内存呢?TCMalloc把内存分为3个档次,小于等于256KB的称为小内存,从256KB到1M称为中等内存,大于1MB的叫做大内存。TCMalloc对中等内存、大内存的分配速度很慢,比如我们用单线程分配2M的内存,Ptmalloc2耗时仍然稳定在32纳秒,但TCMalloc已经上升到86纳秒,增长了7倍以上。
|
||||
|
||||
所以,**如果主要分配256KB以下的内存,特别是在多线程环境下,应当选择TCMalloc;否则应使用Ptmalloc2,它的通用性更好。**
|
||||
|
||||
## 从堆还是栈上分配内存?
|
||||
|
||||
不知道你发现没有,刚刚讨论的内存池中分配出的都是堆内存,如果你把在堆中分配的对象改为在栈上分配,速度还会再快上1倍(具体测试代码可以在[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/2-memory/benchmark)找到)!为什么?
|
||||
|
||||
可能有同学还不清楚堆和栈内存是如何分配的,我先简单介绍一下。
|
||||
|
||||
如果你使用的是静态类型语言,那么,不使用new关键字分配的对象大都是在栈中的。比如:
|
||||
|
||||
```
|
||||
C/C++/Java语言:int a = 10;
|
||||
|
||||
```
|
||||
|
||||
否则,通过new或者malloc关键字分配的对象则是在堆中的:
|
||||
|
||||
```
|
||||
C语言:int * a = (int*) malloc(sizeof(int));
|
||||
C++语言:int * a = new int;
|
||||
Java语言:int a = new Integer(10);
|
||||
|
||||
```
|
||||
|
||||
另外,对于动态类型语言,无论是否使用new关键字,内存都是从堆中分配的。
|
||||
|
||||
了解了这一点之后,我们再来看看,为什么从栈中分配内存会更快。
|
||||
|
||||
这是因为,由于每个线程都有独立的栈,所以分配内存时不需要加锁保护,而且栈上对象的尺寸在编译阶段就已经写入可执行文件了,执行效率更高!性能至上的Golang语言就是按照这个逻辑设计的,即使你用new关键字分配了堆内存,但编译器如果认为在栈中分配不影响功能语义时,会自动改为在栈中分配。
|
||||
|
||||
当然,在栈中分配内存也有缺点,它有功能上的限制。一是, 栈内存生命周期有限,它会随着函数调用结束后自动释放,在堆中分配的内存,并不随着分配时所在函数调用的结束而释放,它的生命周期足够使用。二是,栈的容量有限,如CentOS 7中是8MB字节,如果你申请的内存超过限制会造成栈溢出错误(比如,递归函数调用很容易造成这种问题),而堆则没有容量限制。
|
||||
|
||||
**所以,当我们分配内存时,如果在满足功能的情况下,可以在栈中分配的话,就选择栈。**
|
||||
|
||||
## 小结
|
||||
|
||||
最后我们对这一讲做个小结。
|
||||
|
||||
进程申请内存的速度,以及总内存空间都受到内存池的影响。知道这些隐藏内存池的存在,是提升分配内存效率的前提。
|
||||
|
||||
隐藏着的C库内存池,对进程的内存开销有很大的影响。当进程的占用空间超出预期时,你需要清楚你正在使用的是什么内存池,它对每个线程预分配了多大的空间。
|
||||
|
||||
不同的C库内存池,都有它们最适合的应用场景,例如TCMalloc对多线程下的小内存分配特别友好,而Ptmalloc2则对各类尺寸的内存申请都有稳定的表现,更加通用。
|
||||
|
||||
内存池管理着堆内存,它的分配速度比不上在栈中分配内存。只是栈中分配的内存受到生命周期和容量大小的限制,应用场景更为有限。然而,如果有可能的话,尽量在栈中分配内存,它比内存池中的堆内存分配速度快很多!
|
||||
|
||||
OK,今天我们从内存分配的角度聊了分布式系统性能提升的内容,希望学习过今天的内容后,你知道如何最快速地申请到内存,了解你正在使用的内存池,并清楚它对进程最终内存大小的影响。即使对第三方组件,我们也可以通过LD_PRELOAD环境变量,在程序启动时更换最适合的C库内存池(Linux中通过LD_PRELOAD修改动态库来更换内存池,参见[示例代码](https://github.com/russelltao/geektime_distrib_perf/tree/master/2-memory/benchmark))。
|
||||
|
||||
内存分配时间虽然不起眼,但时刻用最快的方法申请内存,正是高手与初学者的区别,相似算法的性能差距就体现在这些编码细节上,希望你能够重视它。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一个思考题。分配对象时,除了分配内存,还需要初始化对象的数据结构。内存池对于初始化对象有什么帮助吗?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
127
极客时间专栏/系统性能调优必知必会/基础设施优化/03 | 索引:如何用哈希表管理亿级对象?.md
Normal file
127
极客时间专栏/系统性能调优必知必会/基础设施优化/03 | 索引:如何用哈希表管理亿级对象?.md
Normal file
@@ -0,0 +1,127 @@
|
||||
<audio id="audio" title="03 | 索引:如何用哈希表管理亿级对象?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/66/18/66f9041f6fc31d73e3d6c5783a852718.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲我们谈到,Ptmalloc2为子线程预分配了64MB内存池,虽然增大了内存消耗,但却加快了分配速度,这就是**以空间换时间**的思想。
|
||||
|
||||
在内存有限的单片机上运行嵌入式程序时,我们会压缩数据的空间占用,**以时间换空间**;但在面向海量用户的分布式服务中,**使用更多的空间建立索引,换取更短的查询时间**,则是我们管理大数据的常用手段。
|
||||
|
||||
比如现在需要管理数亿条数据,每条数据上有许多状态,有些请求在查询这些状态,有些请求则会根据业务规则有条件地更新状态,有些请求会新增数据,每条数据几十到几百字节。如果需要提供微秒级的访问速度,该怎么实现?(注意,以上非功能性约束并不苛刻,对于低ARPU,即每用户平均收入低的应用,使用更少的资源实现同等功能非常重要。)
|
||||
|
||||
这种情况你会面对大量数据,显然,遍历全部数据去匹配查询关键字,会非常耗时。如果使用额外的空间为这些数据创建索引,就可以基于索引实现快速查找,这是常用的解决方案。比如,我们用标准库里提供的字典类容器存放对象,就是在数据前增加了索引,其本质就是以空间换时间。
|
||||
|
||||
当然,索引有很多,哈希表、红黑树、B树都可以在内存中使用,如果我们需要数据规模上亿后还能提供微秒级的访问速度,**那么作为最快的索引,哈希表是第一选择。**
|
||||
|
||||
## 为什么选择哈希表?
|
||||
|
||||
为什么说哈希表是最快的索引呢?我们怎么**定量评价**索引快慢呢?
|
||||
|
||||
实地运行程序统计时间不是个好主意,因为它不只受数据特性、数据规模的影响,而且难以跨环境比较。巴菲特说过:“**近似的正确好过精确的错误。**”用**近似的时间复杂度**描述运行时间,好过实地运行得出的精确时间。
|
||||
|
||||
“时间复杂度”经过了详细的数学运算,它的运算过程我就不详细展开讲了。时间复杂度可以很好地反映运行时间随数据规模的变化趋势,就如下图中,横轴是数据规模,纵轴是运行时间,随着数据规模的增长,水平直线1不随之变化,也就是说,运行时间不变,是最好的曲线。用大O表示法描述时间复杂度,哈希表就是常量级的O(1),数据规模增长不影响它的运行时间,所以Memcached、Redis都在用哈希表管理数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/c8/e5e07bd2abe9f0f15df1b43fdf25f9c8.jpg" alt="" title="图片来自英文wiki">
|
||||
|
||||
为什么哈希表能做到O(1)时间复杂度呢?
|
||||
|
||||
首先,哈希表基于数组实现,而数组可以根据下标随机访问任意元素。数组之所以可以随机访问,是因为它**由连续内存承载**,且**每个数组元素的大小都相等**。于是,当我们知道下标后,把下标乘以元素大小,再加上数组的首地址,就可以获得目标访问地址,直接获取数据。
|
||||
|
||||
其次,哈希函数直接把查询关键字转换为数组下标,再通过数组的随机访问特性获取数据。比如,如果关键字是字符串,我们使用BKDR哈希算法将其转换为自然数,再以哈希数组大小为除数,对它进行求余,就得到了数组下标。如下图所示,字符串abc经过哈希函数的运算,得到了下标39,于是数据就存放在数组的第39个元素上。(注意,这是个**很糟糕**的哈希函数,它使用的基数是256,即2的8次方,下文我们会解释它为什么糟糕。)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/59/419bc11f032ebcefaa6a3eb5c1a39759.jpg" alt="">
|
||||
|
||||
这样,**哈希函数的执行时间是常量,数组的随机访问也是常量,时间复杂度就是O(1)。**
|
||||
|
||||
实际上并非只有哈希表的时间复杂度是O(1),另一种索引“位图”,它的时间复杂度也是O(1)。不过本质上,它是哈希表的变种,限制每个哈希桶只有1个比特位,所以,虽然它消耗的空间更少,但仅用于辅助数据的主索引,快速判断对象是否存在。
|
||||
|
||||
位图常用于解决缓存穿透的问题,也常用于查找数组中的可用对象,比如下图中通过批量判断位图数组的比特位(对CPU缓存也很友好),找到数据数组中的对应元素。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/f5/bf2e4f574be8af06c285b3fc78d7b0f5.jpg" alt="">
|
||||
|
||||
当然,logN也是不错的曲线,随着数据规模的增长,运行时间的增长是急剧放缓的。红黑树的时间复杂度就是O(logN)。如果需求中需要做范围查询、遍历,由于哈希表没办法找到关键字相邻的下一个元素,所以哈希表不支持这类操作,我们可以选择红黑树作为索引。采用二分法的红黑树,检索1万条数据需要做14次运算,1亿条也只需要27次而已。
|
||||
|
||||
如果红黑树过大,内存中放不下时,可以改用B树,将部分索引存放在磁盘上。磁盘访问速度要比内存慢很多,但B树充分考虑了机械磁盘寻址慢、顺序读写快的特点,通过多分支降低了树高,减少了磁盘读写次数。
|
||||
|
||||
综合来看,不考虑范围查询与遍历操作,在追求最快速度的条件下,哈希表是最好的选择。
|
||||
|
||||
然而,在生产环境用哈希表管理如此多的数据,必然面临以下问题:
|
||||
|
||||
- 首先,面对上亿条数据,为了保证可靠性,需要做灾备恢复,我们可以结合快照+oplog方式恢复数据,但内存中的哈希表如何快速地序列化为快照文件?
|
||||
- 其次,简单的使用标准库提供的哈希表处理如此规模的数据,会导致内存消耗过大,因为每多使用一个8字节的指针(或者叫引用)都会被放大亿万倍,此时该如何实现更节约内存的个性化哈希表?
|
||||
- 再次,哈希表频繁发生冲突时,速度会急剧降低,我们该通过哪些手段减少冲突概率?
|
||||
|
||||
接下来,我们就来看看,如何解决以上问题,用哈希表有效地管理亿级数据。
|
||||
|
||||
## 内存结构与序列化方案
|
||||
|
||||
事实上**对于动态(元素是变化的)哈希表,我们无法避免哈希冲突。**比如上例中,“abc”与“cba”这两个字符串哈希后都会落到下标39中,这就产生了冲突。有两种方法解决哈希冲突:
|
||||
|
||||
1. **链接法**,落到数组同一个位置中的多个数据,通过链表串在一起。使用哈希函数查找到这个位置后,再使用链表遍历的方式查找数据。Java标准库中的哈希表就使用链接法解决冲突。
|
||||
1. **开放寻址法**,插入时若发现对应的位置已经占用,或者查询时发现该位置上的数据与查询关键字不同,开放寻址法会按既定规则变换哈希函数(例如哈希函数设为H(key,i),顺序地把参数i加1),计算出下一个数组下标,继续在哈希表中探查正确的位置。
|
||||
|
||||
我们该选择哪种方法呢?
|
||||
|
||||
由于生产级存放大量对象的哈希表是需要容灾的,比如每隔一天把哈希表数据定期备份到另一台服务器上。当服务器宕机而启动备用服务器时,首先可以用备份数据把哈希表恢复到1天前的状态,再通过操作日志oplog把1天内的数据载入哈希表,这样就可以最快速的恢复哈希表。所以,为了能够传输,首先必须把哈希表序列化。
|
||||
|
||||
链接法虽然实现简单,还允许**存放元素个数大于数组的大小**(也叫装载因子大于1),但链接法序列化数据的代价很大,因为使用了指针后,内存是不连续的。
|
||||
|
||||
**开放寻址法**确保所有对象都在数组里,就可以把数组用到的这段连续内存原地映射到文件中(参考Linux中的mmap,Java等语言都有类似的封装),再通过备份文件的方式备份哈希表。虽然操作系统会自动同步内存中变更的数据至文件,但备份前还是需要主动刷新内存(参考Linux中的msync,它可以按地址及长度来分段刷新,以减少msync的耗时),以确定备份数据的精确时间点。而新的进程启动时,可以通过映射磁盘中的文件到内存,快速重建哈希表提供服务。
|
||||
|
||||
**如果能将数据完整的放进数组,那么开放寻址法已经解决了序列化问题,所以我们应该选择开放寻址法**。
|
||||
|
||||
但是,有两个因素使得我们必须把数据放在哈希桶之外:
|
||||
|
||||
1. 每条数据有上百字节;
|
||||
1. 哈希表中一定会有很多空桶(没有存放数据)。空桶的比例越高(装载因子越小),冲突概率也会越低,但如果每个空桶都占用上百字节,亿级规模会轻松把浪费的内存放大许多倍。
|
||||
|
||||
**所以,我们要把数据从哈希表中分离出来,提升哈希表的灵活性(灵活调整装载因子)**。此时,该如何序列化哈希表以外的数据呢?最快速的序列化方案,还是像开放寻址法的散列表一样,使用定长数组存放对象,通过原地映射文件的方式序列化数据。由于数据未必是定长的,所以又分为两种情况。
|
||||
|
||||
**一、数据的长度是固定的。**可以用另一个数组D存放数据,其中D的大小是待存放元素的最大数量,注意,D可以远小于哈希数组的大小。如果哈希表是动态的,支持新建与删除元素操作,还需要把数组D中空闲的位置构建一个单链表,新建时从链表头取元素,删除时将元素归还至链表头部。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7e/e8/7e0636fc6d9a70d6d4de07da678da6e8.jpg" alt="">
|
||||
|
||||
**二、数据的长度并不固定。**此时,可以采用有限个定长数组存放数据,用以空间换时间的思想,加快访问速度。如下图中,D1数组存放长度小于L1的数据,D2数组存放长度在L1和L2之间的数据,以此类推。而哈希表数组H中,每个桶用i位存放该数据在哪个数组中,用j位存放数组下标。查找数据时,前i位寻找数组,后j位作为数组下标直接访问数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/82/17f3f4e9e949a49a4ce7a50bbf1d4f82.jpg" alt="">
|
||||
|
||||
在这两种情况里,哈希桶不需要存放8字节64位的地址。因为,或许数组D的大小不到1亿,也就是说,你最多只需要寻址1亿条数据,这样30位足够使用。要知道,减少哈希桶的尺寸,就意味着同等内存下可以扩大哈希数组,从而降低装载因子。
|
||||
|
||||
## 降低哈希表的冲突概率
|
||||
|
||||
虽然哈希冲突有解决方案,但若是所有元素都发生了冲突,哈希表的时间复杂度就退化成了O(N),即每查找一次都要遍历所有数据。所以,为了获得与数据规模无关的常量级时间,我们必须减少冲突的概率,而减少冲突概率有两个办法,**第一个办法是调优哈希函数,第二个办法就是扩容。**
|
||||
|
||||
我们先看调优哈希函数。什么是好的哈希函数呢?首先它的计算量不能大,其次应尽量降低冲突概率。回到开头的那个哈希函数:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/59/419bc11f032ebcefaa6a3eb5c1a39759.jpg" alt="">
|
||||
|
||||
这个哈希函数使得“abc”和“cba”两个关键字都落在了下标39上,造成了哈希冲突,是因为它**丢失了字母的位置信息**。BKDR是优秀的哈希算法,但它不能以2<sup>8</sup> 作为基数,这会导致字符串分布不均匀。事实上,我们应当找一个合适的**素数作为基数**,比如31,Java标准库的BKDR哈希算法就以它为基数,它的计算量也很小:n*31可以通过先把n左移5位,再减去n的方式替换(n*31 == n<<5 - n)。
|
||||
|
||||
一次位移加一次减法,要比一次乘法快得多。当然,图中的哈希函数之所以会丢失位置信息,是因为以2<sup>8</sup> 作为基数的同时,又把2<sup>8</sup>-1作为除数所致,数学较好的同学可以试着推导证明,这里只需要记住,**基数必须是素数**就可以了。
|
||||
|
||||
当哈希函数把高信息量的关键字压缩成更小的数组下标时,**一定会丢失信息**。我们希望只丢失一些无关紧要的信息,尽量多地保留区分度高的信息。这需要分析关键字的特点、分布规律。比如,对于11位手机号,前3位接入号区分度最差,中间4位表示地域的数字信息量有所增强,最后4位个人号信息量最高。如果哈希桶只有1万个,那么通过phonenum%10000,最大化保留后4位信息就是个不错的选择。
|
||||
|
||||
再比如,QQ 号似乎不像手机号的数字分布那么有特点,然而,如果静态的统计存量QQ号,就会发现最后1位为0的号码特别多(数字更讨人欢喜),区分度很低。这样,哈希函数应当主动降低最后1位的信息量,减少它对哈希表位置的影响。比如,QQ号%100就放大了最后1位的信息,增大了哈希冲突,而用QQ号%101(**101是素数,效果更好****)**作为哈希函数,就降低了最后1位的影响。
|
||||
|
||||
**接下来我们看看减少哈希冲突概率的第二个办法,扩容。**装载因子越接近于1,冲突概率就会越大。我们不能改变元素的数量,只能通过扩容提升哈希桶的数量,减少冲突。
|
||||
|
||||
由于哈希函数必须确保计算出的下标落在数组范围中,而扩容会增加数组的大小,进而影响哈希函数,因此,扩容前存放在哈希表中的所有元素,它们在扩容后的数组中位置都发生了变化。所以,扩容需要新老哈希表同时存在,通过遍历全部数据,用新的哈希函数把关键字放到合适的新哈希桶中。可见,扩容是一个极其耗时的操作,尤其在元素以亿计的情况下。
|
||||
|
||||
那么,在耗时以小时计的扩容过程中,如何持续提供正常服务呢?其实,只要把一次性的迁移过程,分为多次后台迁移,且提供服务时能够根据迁移情况选择新老哈希表即可。如果单机内存可以存放下新老两张哈希表,那么动态扩容不需要跨主机。反之,扩容过程将涉及新老哈希表所在的两台服务器,实现更为复杂,但原理是相同的。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们介绍了如何用哈希表管理上亿条数据。为什么选择哈希表?因为哈希表的运行时间不随着业务规模增长而变化。位图本质上是哈希表的变种,不过它常用于配合主索引,快速判断数据的状态。因为哈希表本身没办法找到关键字相邻的下一个元素,所以哈希表不支持范围查询与遍历。如果业务需要支持范围查询时,我们需要考虑红黑树、B树等索引,它们其实并不慢。当索引太大,必须将一部分从内存中移到硬盘时,B树就是一个很好的选择。
|
||||
|
||||
使用哈希表,你要注意几个关键问题。
|
||||
|
||||
1. 生产环境一定要考虑容灾,而把哈希表原地序列化为文件是一个解决方案,它能保证新进程快速恢复哈希表。解决哈希冲突有链接法和开放寻址法,而后者更擅长序列化数据,因此成为我们的首选 。
|
||||
1. 亿级数据下,我们必须注重内存的节约使用。数亿条数据会放大节约下的点滴内存,再把它们用于提升哈希数组的大小,就可以通过降低装载因子来减少哈希冲突,提升速度。
|
||||
1. 优化哈希函数也是降低哈希冲突的重要手段,我们需要研究关键字的特征与分布,设计出快速、使关键字均匀分布的哈希函数。在课程的第四部分,集群的负载均衡也用到了哈希函数及其设计思想,只不过,哈希桶从一段内存变成了一台服务器。
|
||||
|
||||
再延伸说一点,哈希表、红黑树等这些索引都使用了以空间换时间的思想。判断它们的时间消耗,我们都需要依赖时间复杂度这个工具。当然,索引在某些场景下也会降低性能。例如添加、删除元素时,更新索引消耗的时间就是新增的。但相对于整体的收益,这些消耗是微不足道的。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后留给大家一个思考题,你用过哪些其他类型的索引?基于怎样的应用场景和约束,才选择使用这些索引的?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
127
极客时间专栏/系统性能调优必知必会/基础设施优化/04 | 零拷贝:如何高效地传输文件?.md
Normal file
127
极客时间专栏/系统性能调优必知必会/基础设施优化/04 | 零拷贝:如何高效地传输文件?.md
Normal file
@@ -0,0 +1,127 @@
|
||||
<audio id="audio" title="04 | 零拷贝:如何高效地传输文件?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/3d/f674dd9b550efc1310ee509bd656693d.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲我们谈到,当索引的大小超过内存时,就会用磁盘存放索引。磁盘的读写速度远慢于内存,所以才针对磁盘设计了减少读写次数的B树索引。
|
||||
|
||||
**磁盘是主机中最慢的硬件之一,常常是性能瓶颈,所以优化它能获得立竿见影的效果。**
|
||||
|
||||
因此,针对磁盘的优化技术层出不穷,比如零拷贝、直接IO、异步IO等等。这些优化技术为了降低操作时延、提升系统的吞吐量,围绕着内核中的磁盘高速缓存(也叫PageCache),去减少CPU和磁盘设备的工作量。
|
||||
|
||||
这些磁盘优化技术和策略虽然很有效,但是理解它们并不容易。只有搞懂内核操作磁盘的流程,灵活正确地使用,才能有效地优化磁盘性能。
|
||||
|
||||
这一讲,我们就通过解决“如何高效地传输文件”这个问题,来分析下磁盘是如何工作的,并且通过优化传输文件的性能,带你学习现在热门的零拷贝、异步IO与直接IO这些磁盘优化技术。
|
||||
|
||||
## 你会如何实现文件传输?
|
||||
|
||||
服务器提供文件传输功能,需要将磁盘上的文件读取出来,通过网络协议发送到客户端。如果需要你自己编码实现这个文件传输功能,你会怎么实现呢?
|
||||
|
||||
通常,你会选择最直接的方法:从网络请求中找出文件在磁盘中的路径后,如果这个文件比较大,假设有320MB,可以在内存中分配32KB的缓冲区,再把文件分成一万份,每份只有32KB,这样,从文件的起始位置读入32KB到缓冲区,再通过网络API把这32KB发送到客户端。接着重复一万次,直到把完整的文件都发送完毕。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/ee/6593f66902b337ec666551fe2c6f5bee.jpg" alt="">
|
||||
|
||||
不过这个方案性能并不好,主要有两个原因。
|
||||
|
||||
首先,它至少**经历了4万次用户态与内核态的上下文切换。**因为每处理32KB的消息,就需要一次read调用和一次write调用,每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。可见,每处理32KB,就有4次上下文切换,重复1万次后就有4万次切换。
|
||||
|
||||
上下文切换的成本并不小,虽然一次切换仅消耗几十纳秒到几微秒,但高并发服务会放大这类时间的消耗。
|
||||
|
||||
其次,这个方案做了**4万次内存拷贝,对320MB文件拷贝的字节数也翻了4倍,到了1280MB。**很显然,过多的内存拷贝无谓地消耗了CPU资源,降低了系统的并发处理能力。
|
||||
|
||||
所以要想提升传输文件的性能,需要从**降低上下文切换的频率和内存拷贝次数**两个方向入手。
|
||||
|
||||
## 零拷贝如何提升文件传输性能?
|
||||
|
||||
首先,我们来看如何降低上下文切换的频率。
|
||||
|
||||
为什么读取磁盘文件时,一定要做上下文切换呢?这是因为,读取磁盘或者操作网卡都由操作系统内核完成。内核负责管理系统上的所有进程,它的权限最高,工作环境与用户进程完全不同。只要我们的代码执行read或者write这样的系统调用,一定会发生2次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。
|
||||
|
||||
因此,如果想减少上下文切换次数,就一定要减少系统调用的次数。解决方案就是把read、write两次系统调用合并成一次,在内核中完成磁盘与网卡的数据交换。
|
||||
|
||||
其次,我们应该考虑如何减少内存拷贝次数。
|
||||
|
||||
每周期中的4次内存拷贝,其中与物理设备相关的2次拷贝是必不可少的,包括:把磁盘内容拷贝到内存,以及把内存拷贝到网卡。但另外2次与用户缓冲区相关的拷贝动作都不是必需的,因为在把磁盘文件发到网络的场景中,**用户缓冲区没有必须存在的理由**。
|
||||
|
||||
如果内核在读取文件后,直接把PageCache中的内容拷贝到Socket缓冲区,待到网卡发送完毕后,再通知进程,这样就只有2次上下文切换,和3次内存拷贝。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/a1/bf80b6f858d5cb49f600a28f853e89a1.jpg" alt="">
|
||||
|
||||
如果网卡支持SG-DMA(The Scatter-Gather Direct Memory Access)技术,还可以再去除Socket缓冲区的拷贝,这样一共只有2次内存拷贝。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/77/0afb2003d8aebaee763d22dda691ca77.jpg" alt="">
|
||||
|
||||
**实际上,这就是零拷贝技术。**
|
||||
|
||||
它是操作系统提供的新函数,同时接收文件描述符和TCP socket作为输入参数,这样执行时就可以完全在内核态完成内存拷贝,既减少了内存拷贝次数,也降低了上下文切换次数。
|
||||
|
||||
而且,零拷贝取消了用户缓冲区后,不只降低了用户内存的消耗,还通过最大化利用socket缓冲区中的内存,间接地再一次减少了系统调用的次数,从而带来了大幅减少上下文切换次数的机会!
|
||||
|
||||
你可以回忆下,没用零拷贝时,为了传输320MB的文件,在用户缓冲区分配了32KB的内存,把文件分成1万份传送,然而,**这32KB是怎么来的?**为什么不是32MB或者32字节呢?这是因为,在没有零拷贝的情况下,我们希望内存的利用率最高。如果用户缓冲区过大,它就无法一次性把消息全拷贝给socket缓冲区;如果用户缓冲区过小,则会导致过多的read/write系统调用。
|
||||
|
||||
那用户缓冲区为什么不与socket缓冲区大小一致呢?这是因为,**socket缓冲区的可用空间是动态变化的**,它既用于TCP滑动窗口,也用于应用缓冲区,还受到整个系统内存的影响(我在《Web协议详解与抓包实战》第5部分课程对此有详细介绍,这里不再赘述)。尤其在长肥网络中,它的变化范围特别大。
|
||||
|
||||
**零拷贝使我们不必关心socket缓冲区的大小。**比如,调用零拷贝发送方法时,尽可以把发送字节数设为文件的所有未发送字节数,例如320MB,也许此时socket缓冲区大小为1.4MB,那么一次性就会发送1.4MB到客户端,而不是只有32KB。这意味着对于1.4MB的1次零拷贝,仅带来2次上下文切换,而不使用零拷贝且用户缓冲区为32KB时,经历了176次(4 * 1.4MB/32KB)上下文切换。
|
||||
|
||||
综合上述各种优点,**零拷贝可以把性能提升至少一倍以上!**对文章开头提到的320MB文件的传输,当socket缓冲区在1.4MB左右时,只需要4百多次上下文切换,以及4百多次内存拷贝,拷贝的数据量也仅有640MB,这样,不只请求时延会降低,处理每个请求消耗的CPU资源也会更少,从而支持更多的并发请求。
|
||||
|
||||
此外,零拷贝还使用了PageCache技术,通过它,零拷贝可以进一步提升性能,我们接下来看看PageCache是如何做到这一点的。
|
||||
|
||||
## PageCache,磁盘高速缓存
|
||||
|
||||
回顾上文中的几张图,你会发现,读取文件时,是先把磁盘文件拷贝到PageCache上,再拷贝到进程中。为什么这样做呢?有两个原因所致。
|
||||
|
||||
第一,由于磁盘比内存的速度慢许多,所以我们应该想办法把读写磁盘替换成读写内存,比如把磁盘中的数据复制到内存中,就可以用读内存替换读磁盘。但是,内存空间远比磁盘要小,内存中注定只能复制一小部分磁盘中的数据。
|
||||
|
||||
选择哪些数据复制到内存呢?通常,刚被访问的数据在短时间内再次被访问的概率很高(这也叫“时间局部性”原理),用PageCache缓存最近访问的数据,当空间不足时淘汰最久未被访问的缓存(即LRU算法)。读磁盘时优先到PageCache中找一找,如果数据存在便直接返回,这便大大提升了读磁盘的性能。
|
||||
|
||||
第二,读取磁盘数据时,需要先找到数据所在的位置,对于机械磁盘来说,就是旋转磁头到数据所在的扇区,再开始顺序读取数据。其中,旋转磁头耗时很长,为了降低它的影响,PageCache使用了**预读功能**。
|
||||
|
||||
也就是说,虽然read方法只读取了0-32KB的字节,但内核会把其后的32-64KB也读取到PageCache,这后32KB读取的成本很低。如果在32-64KB淘汰出PageCache前,进程读取到它了,收益就非常大。这一讲的传输文件场景中这是必然发生的。
|
||||
|
||||
从这两点可以看到PageCache的优点,它在90%以上场景下都会提升磁盘性能,**但在某些情况下,PageCache会不起作用,甚至由于多做了一次内存拷贝,造成性能的降低。**在这些场景中,使用了PageCache的零拷贝也会损失性能。
|
||||
|
||||
具体是什么场景呢?就是在传输大文件的时候。比如,你有很多GB级的文件需要传输,每当用户访问这些大文件时,内核就会把它们载入到PageCache中,这些大文件很快会把有限的PageCache占满。
|
||||
|
||||
然而,由于文件太大,文件中某一部分内容被再次访问到的概率其实非常低。这带来了2个问题:首先,由于PageCache长期被大文件占据,热点小文件就无法充分使用PageCache,它们读起来变慢了;其次,PageCache中的大文件没有享受到缓存的好处,但却耗费CPU(或者DMA)多拷贝到PageCache一次。
|
||||
|
||||
所以,高并发场景下,为了防止PageCache被大文件占满后不再对小文件产生作用,**大文件不应使用PageCache,进而也不应使用零拷贝技术处理。**
|
||||
|
||||
## 异步IO + 直接IO
|
||||
|
||||
高并发场景处理大文件时,应当使用异步IO和直接IO来替换零拷贝技术。
|
||||
|
||||
仍然回到本讲开头的例子,当调用read方法读取文件时,实际上read方法会在磁盘寻址过程中阻塞等待,导致进程无法并发地处理其他任务,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/4e/9ef6fcb7da58a007f8f4e3e67442df4e.jpg" alt="">
|
||||
|
||||
异步IO(异步IO既可以处理网络IO,也可以处理磁盘IO,这里我们只关注磁盘IO)可以解决阻塞问题。它把读操作分为两部分,前半部分向内核发起读请求,但**不等待数据就位就立刻返回**,此时进程可以并发地处理其他任务。当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据,这是异步IO的后半部分。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/f3/15d33cf599d11b3188253912b21e4ef3.jpg" alt="">
|
||||
|
||||
从图中可以看到,异步IO并没有拷贝到PageCache中,这其实是异步IO实现上的缺陷。经过PageCache的IO我们称为缓存IO,它与虚拟内存系统耦合太紧,导致异步IO从诞生起到现在都不支持缓存IO。
|
||||
|
||||
绕过PageCache的IO是个新物种,我们把它称为直接IO。对于磁盘,异步IO只支持直接IO。
|
||||
|
||||
直接IO的应用场景并不多,主要有两种:第一,应用程序已经实现了磁盘文件的缓存,不需要PageCache再次缓存,引发额外的性能消耗。比如MySQL等数据库就使用直接IO;第二,高并发下传输大文件,我们上文提到过,大文件难以命中PageCache缓存,又带来额外的内存拷贝,同时还挤占了小文件使用PageCache时需要的内存,因此,这时应该使用直接IO。
|
||||
|
||||
当然,直接IO也有一定的缺点。除了缓存外,内核(IO调度算法)会试图缓存尽量多的连续IO在PageCache中,最后**合并**成一个更大的IO再发给磁盘,这样可以减少磁盘的寻址操作;另外,内核也会**预读**后续的IO放在PageCache中,减少磁盘操作。直接IO绕过了PageCache,所以无法享受这些性能提升。
|
||||
|
||||
有了直接IO后,异步IO就可以无阻塞地读取文件了。现在,大文件由异步IO和直接IO处理,小文件则交由零拷贝处理,至于判断文件大小的阈值可以灵活配置(参见Nginx的directio指令)。
|
||||
|
||||
## 小结
|
||||
|
||||
基于用户缓冲区传输文件时,过多的内存拷贝与上下文切换次数会降低性能。零拷贝技术在内核中完成内存拷贝,天然降低了内存拷贝次数。它通过一次系统调用合并了磁盘读取与网络发送两个操作,降低了上下文切换次数。尤其是,由于拷贝在内核中完成,它可以最大化使用socket缓冲区的可用空间,从而提高了一次系统调用中处理的数据量,进一步降低了上下文切换次数。
|
||||
|
||||
零拷贝技术基于PageCache,而PageCache缓存了最近访问过的数据,提升了访问缓存数据的性能,同时,为了解决机械磁盘寻址慢的问题,它还协助IO调度算法实现了IO合并与预读(这也是顺序读比随机读性能好的原因),这进一步提升了零拷贝的性能。几乎所有操作系统都支持零拷贝,如果应用场景就是把文件发送到网络中,那么我们应当选择使用了零拷贝的解决方案。
|
||||
|
||||
不过,零拷贝有一个缺点,就是不允许进程对文件内容作一些加工再发送,比如数据压缩后再发送。另外,当PageCache引发负作用时,也不能使用零拷贝,此时可以用异步IO+直接IO替换。我们通常会设定一个文件大小阈值,针对大文件使用异步IO和直接IO,而对小文件使用零拷贝。
|
||||
|
||||
事实上PageCache对写操作也有很大的性能提升,因为write方法在写入内存中的PageCache后就会返回,速度非常快,由内核负责异步地把PageCache刷新到磁盘中,这里不再展开。
|
||||
|
||||
这一讲我们从零拷贝出发,看到了文件传输场景中内核在幕后所做的工作。这里面的性能优化技术,要么减少了磁盘的工作量(比如PageCache缓存),要么减少了CPU的工作量(比如直接IO),要么提高了内存的利用率(比如零拷贝)。你在学习其他磁盘IO优化技术时,可以延着这三个优化方向前进,看看究竟如何降低时延、提高并发能力。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一个思考题,异步IO一定不会阻塞进程吗?如果阻塞了进程,该如何解决呢?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
107
极客时间专栏/系统性能调优必知必会/基础设施优化/05 | 协程:如何快速地实现高并发服务?.md
Normal file
107
极客时间专栏/系统性能调优必知必会/基础设施优化/05 | 协程:如何快速地实现高并发服务?.md
Normal file
@@ -0,0 +1,107 @@
|
||||
<audio id="audio" title="05 | 协程:如何快速地实现高并发服务?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/da/49/da9581b5c37a49649cdcaeb7485dc649.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲谈到,零拷贝通过减少上下文切换次数,提升了文件传输的性能。事实上高并发服务也是通过降低切换成本实现的,这一讲我们来看看它是如何做到的。
|
||||
|
||||
如果你需要访问多个服务来完成一个请求的处理,比如实现文件上传功能时,首先访问Redis缓存,验证用户是否登陆,再接收HTTP消息中的body并保存在磁盘上,最后把文件路径等信息写入MySQL数据库中,你会怎么做?
|
||||
|
||||
用阻塞API写同步代码最简单,但一个线程同一时间只能处理一个请求,有限的线程数导致无法实现万级别的并发连接,过多的线程切换也抢走了CPU的时间,从而降低了每秒能够处理的请求数量。
|
||||
|
||||
为了达到高并发,你可能会选择一个异步框架,用非阻塞API把业务逻辑打乱到多个回调函数,通过多路复用实现高并发,然而,由于业务代码过度关注并发细节,需要维护很多中间状态,不但Bug率会很高,项目的开发速度也上不去,产品及时上线存在风险。
|
||||
|
||||
如果想兼顾开发效率,又能保证高并发,协程就是最好的选择。它可以在保持异步化运行机制的同时,用同步方式写代码,这在实现高并发的同时,缩短了开发周期,是高性能服务未来的发展方向。
|
||||
|
||||
你会发现,解决高并发问题的技术一直在变化,从多进程、多线程,到异步化、协程,面对不同的场景,它们都在用各自不同的方式解决问题。我们就来看看,高并发的解决方案是怎么演进的,协程到底解决了什么问题,它又该如何应用。
|
||||
|
||||
## 如何通过切换请求实现高并发?
|
||||
|
||||
我们知道,主机上资源有限,一颗CPU、一块磁盘、一张网卡,如何同时服务上百个请求呢?多进程模式是最初的解决方案。内核把CPU的执行时间切分成许多时间片(timeslice),比如1秒钟可以切分为100个10毫秒的时间片,每个时间片再分发给不同的进程,通常,每个进程需要多个时间片才能完成一个请求。
|
||||
|
||||
这样,虽然微观上,比如说就这10毫秒时间CPU只能执行一个进程,但宏观上1秒钟执行了100个时间片,于是每个时间片所属进程中的请求也得到了执行,**这就实现了请求的并发执行。**
|
||||
|
||||
不过,每个进程的内存空间都是独立的,这样用多进程实现并发就有两个缺点:一是内核的管理成本高,二是无法简单地通过内存同步数据,很不方便。于是,多线程模式就出现了,多线程模式通过共享内存地址空间,解决了这两个问题。
|
||||
|
||||
然而,共享地址空间虽然可以方便地共享对象,但这也导致一个问题,那就是任何一个线程出错时,进程中的所有线程会跟着一起崩溃。这也是如Nginx等强调稳定性的服务坚持使用多进程模式的原因。
|
||||
|
||||
事实上,无论基于多进程还是多线程,都难以实现高并发,这由两个原因所致。
|
||||
|
||||
首先,单个线程消耗的内存过多,比如,64位的Linux为每个线程的栈分配了8MB的内存,还预分配了64MB的内存作为堆内存池(你可以从[[第2讲]](https://time.geekbang.org/column/article/230221) 中找到Linux系统为什么这么做)。所以,我们没有足够的内存去开启几万个线程实现并发。
|
||||
|
||||
其次,切换请求是内核通过切换线程实现的,什么时候会切换线程呢?不只时间片用尽,**当调用阻塞方法时,内核为了让CPU充分工作,也会切换到其他线程执行。**一次上下文切换的成本在几十纳秒到几微秒间,当线程繁忙且数量众多时,这些切换会消耗绝大部分的CPU运算能力。
|
||||
|
||||
下图以上一讲介绍过的磁盘IO为例,描述了多线程中使用阻塞方法读磁盘,2个线程间的切换方式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/1e/a7729794e84cbb4a295454c6f2005c1e.jpg" alt="">
|
||||
|
||||
那么,怎么才能实现高并发呢?**把上图中本来由内核实现的请求切换工作,交由用户态的代码来完成就可以了**,异步化编程通过应用层代码实现了请求切换,降低了切换成本和内存占用空间。异步化依赖于IO多路复用机制,比如Linux的epoll或者Windows上的iocp,同时,必须把阻塞方法更改为非阻塞方法,才能避免内核切换带来的巨大消耗。Nginx、Redis等高性能服务都依赖异步化实现了百万量级的并发。
|
||||
|
||||
下图描述了异步IO的非阻塞读和异步框架结合后,是如何切换请求的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/8e/5f5ad4282571d8148d87416c8f8fa88e.jpg" alt="">
|
||||
|
||||
**然而,写异步化代码很容易出错。**因为所有阻塞函数,都需要通过非阻塞的系统调用拆分成两个函数。虽然这两个函数共同完成一个功能,但调用方式却不同。第一个函数由你显式调用,第二个函数则由多路复用机制调用。这种方式违反了软件工程的内聚性原则,函数间同步数据也更复杂。特别是条件分支众多、涉及大量系统调用时,异步化的改造工作会非常困难。
|
||||
|
||||
有没有办法既享受到异步化带来的高并发,又可以使用阻塞函数写同步化代码呢?
|
||||
|
||||
协程可以做到,**它在异步化之上包了一层外衣,兼顾了开发效率与运行效率。**
|
||||
|
||||
## 协程是如何实现高并发的?
|
||||
|
||||
协程与异步编程相似的地方在于,它们必须使用非阻塞的系统调用与内核交互,把切换请求的权力牢牢掌握在用户态的代码中。但不同的地方在于,协程把异步化中的两段函数,封装为一个阻塞的协程函数。这个函数执行时,会使调用它的协程无感知地放弃执行权,由协程框架切换到其他就绪的协程继续执行。当这个函数的结果满足后,协程框架再选择合适的时机,切换回它所在的协程继续执行。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e4/57/e47ec54ff370cbda4528e285e3378857.jpg" alt="">
|
||||
|
||||
看起来非常棒,然而,异步化是通过回调函数来完成请求切换的,业务逻辑与并发实现关联在一起,很容易出错。协程不需要什么“回调函数”,它允许用户调用“阻塞的”协程方法,用同步编程方式写业务逻辑。
|
||||
|
||||
那协程的切换是如何完成的呢?
|
||||
|
||||
实际上,**用户态的代码切换协程,与内核切换线程的原理是一样的。**内核通过管理CPU的寄存器来切换线程,我们以最重要的栈寄存器和指令寄存器为例,看看协程切换时如何切换程序指令与内存。
|
||||
|
||||
每个线程有独立的栈,而栈既保留了变量的值,也保留了函数的调用关系、参数和返回值,CPU中的栈寄存器SP指向了当前线程的栈,而指令寄存器IP保存着下一条要执行的指令地址。因此,从线程1切换到线程2时,首先要把SP、IP寄存器的值为线程1保存下来,再从内存中找出线程2上一次切换前保存好的寄存器值,写入CPU的寄存器,这样就完成了线程切换。(其他寄存器也需要管理、替换,原理与此相同,不再赘述。)
|
||||
|
||||
协程的切换与此相同,只是把内核的工作转移到协程框架实现而已,下图是协程切换前的状态:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/f7/a83d7e0f37f35353c6347aa76c8184f7.jpg" alt="">
|
||||
|
||||
从协程1切换到协程2后的状态如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/3f/25d2dcb8aa4569e5de741469f03aa73f.jpg" alt="">
|
||||
|
||||
创建协程时,会从进程的堆中(参见[[第2讲]](https://time.geekbang.org/column/article/230221))分配一段内存作为协程的栈。线程的栈有8MB,而协程栈的大小通常只有几十KB。而且,C库内存池也不会为协程预分配内存,它感知不到协程的存在。这样,更低的内存占用空间为高并发提供了保证,毕竟十万并发请求,就意味着10万个协程。当然,栈缩小后,就尽量不要使用递归函数,也不能在栈中申请过多的内存,这是实现高并发必须付出的代价。
|
||||
|
||||
由此可见,协程就是用户态的线程。然而,为了保证所有切换都在用户态进行,协程必须重新封装所有的阻塞系统调用,否则,一旦协程触发了线程切换,会导致这个线程进入休眠状态,进而其上的所有协程都得不到执行。比如,普通的sleep函数会让当前线程休眠,由内核来唤醒线程,而协程化改造后,sleep只会让当前协程休眠,由协程框架在指定时间后唤醒协程。再比如,线程间的互斥锁是使用信号量实现的,而信号量也会导致线程休眠,协程化改造互斥锁后,同样由框架来协调、同步各协程的执行。
|
||||
|
||||
**所以,协程的高性能,建立在切换必须由用户态代码完成之上,这要求协程生态是完整的,要尽量覆盖常见的组件。**比如MySQL官方提供的客户端SDK,它使用了阻塞socket做网络访问,会导致线程休眠,必须用非阻塞socket把SDK改造为协程函数后,才能在协程中使用。
|
||||
|
||||
当然,并不是所有的函数都能用协程改造。比如[[第4讲]](https://time.geekbang.org/column/article/232676) 提到的异步IO,它虽然是非阻塞的,但无法使用PageCache,降低了系统吞吐量。如果使用缓存IO读文件,在没有命中PageCache时是可能发生阻塞的。
|
||||
|
||||
这种时候,如果对性能有更高的要求,就需要把线程与协程结合起来用,把可能阻塞的操作放在线程中执行,通过生产者/消费者模型与协程配合工作。
|
||||
|
||||
实际上,面对多核系统,也需要协程与线程配合工作。因为协程的载体是线程,而一个线程同一时间只能使用一颗CPU,所以通过开启更多的线程,将所有协程分布在这些线程中,就能充分使用CPU资源。
|
||||
|
||||
除此之外,为了让协程获得更多的CPU时间,还可以设置所在线程的优先级,比如Linux下把线程的优先级设置到-20,就可以每次获得更长的时间片。另外,[[第1讲]](https://time.geekbang.org/column/article/230194) 曾谈到CPU缓存对程序性能的影响,为了减少CPU缓存失效的比例,还可以把线程绑定到某个CPU上,增加协程执行时命中CPU缓存的机率。
|
||||
|
||||
虽然这一讲中谈到协程框架在调度协程,然而,你会发现,很多协程库只提供了创建、挂起、恢复执行等基本方法,并没有协程框架的存在,需要业务代码自行调度协程。这是因为,这些通用的协程库并不是专为服务器设计的。服务器中可以由客户端网络连接的建立,驱动着创建出协程,同时伴随着请求的结束而终止。在协程的运行条件不满足时,多路复用框架会将它挂起,并根据优先级策略选择另一个协程执行。
|
||||
|
||||
因此,使用协程实现服务器端的高并发服务时,并不只是选择协程库,还要从其生态中找到结合IO多路复用的协程框架,这样可以加快开发速度。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲,我们从高并发的应用场景入手,分析了协程出现的背景和实现原理,以及它的应用范围。你会发现,协程融合了多线程与异步化编程的优点,既保证了开发效率,也提升了运行效率。
|
||||
|
||||
有限的硬件资源下,多线程通过微观上时间片的切换,实现了同时服务上百个用户的能力。多线程的开发成本虽然低,但内存消耗大,切换次数过多,无法实现高并发。
|
||||
|
||||
异步编程方式通过非阻塞系统调用和多路复用,把原本属于内核的请求切换能力,放在用户态的代码中执行。这样,不仅减少了每个请求的内存消耗,也降低了切换请求的成本,最终实现了高并发。然而,异步编程违反了代码的内聚性,还需要业务代码关注并发细节,开发成本很高。
|
||||
|
||||
协程参考内核通过CPU寄存器切换线程的方法,在用户态代码中实现了协程的切换,既降低了切换请求的成本,也使得协程中的业务代码不用关注自己何时被挂起,何时被执行。相比异步编程中要维护一堆数据结构表示中间状态,协程直接用代码表示状态,大大提升了开发效率。
|
||||
|
||||
在协程中调用的所有API,都需要做非阻塞的协程化改造。优秀的协程生态下,常用服务都有对应的协程SDK,方便业务代码使用。开发高并发服务时,与IO多路复用结合的协程框架可以与这些SDK配合,自动挂起、切换协程,进一步提升开发效率。
|
||||
|
||||
协程并不是完全与线程无关,首先线程可以帮助协程充分使用多核CPU的计算力,其次,遇到无法协程化、会导致内核切换的阻塞函数,或者计算太密集从而长时间占用CPU的任务,还是要放在独立的线程中执行,以防止它影响所有协程的执行。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一个思考题,你用过协程吗?觉得它还有什么优点?如果没有在生产环境中使用协程,原因是什么?欢迎你在留言区与我一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课有所收获,也欢迎把它分享给你的朋友。
|
||||
137
极客时间专栏/系统性能调优必知必会/基础设施优化/06 | 锁:如何根据业务场景选择合适的锁?.md
Normal file
137
极客时间专栏/系统性能调优必知必会/基础设施优化/06 | 锁:如何根据业务场景选择合适的锁?.md
Normal file
@@ -0,0 +1,137 @@
|
||||
<audio id="audio" title="06 | 锁:如何根据业务场景选择合适的锁?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5a/d0/5a3e4fb278572544e11504f5b9ecd7d0.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲我们谈到了实现高并发的不同方案,这一讲我们来谈谈如何根据业务场景选择合适的锁。
|
||||
|
||||
我们知道,多线程下为了确保数据不会出错,必须加锁后才能访问共享资源。我们最常用的是互斥锁,然而,还有很多种不同的锁,比如自旋锁、读写锁等等,它们分别适用于不同的场景。
|
||||
|
||||
比如高并发场景下,要求每个函数的执行时间必须都足够得短,这样所有请求才能及时得到响应,如果你选择了错误的锁,数万请求同时争抢下,很容易导致大量请求长期取不到锁而处理超时,系统吞吐量始终维持在很低的水平,用户体验非常差,最终“高并发”成了一句空谈。
|
||||
|
||||
怎样选择最合适的锁呢?首先我们必须清楚加锁的成本究竟有多大,其次我们要分析业务场景中访问共享资源的方式,最后则要预估并发访问时发生锁冲突的概率。这样,我们才能选对锁,同时实现高并发和高吞吐量这两个目标。
|
||||
|
||||
今天,我们就针对不同的应用场景,了解下锁的选择和使用,从而减少锁对高并发性能的影响。
|
||||
|
||||
## 互斥锁与自旋锁:休眠还是“忙等待”?
|
||||
|
||||
我们常见的各种锁是有层级的,最底层的两种锁就是互斥锁和自旋锁,其他锁都是基于它们实现的。互斥锁的加锁成本更高,但它在加锁失败时会释放CPU给其他线程;自旋锁则刚好相反。
|
||||
|
||||
**当你无法判断锁住的代码会执行多久时,应该首选互斥锁,互斥锁是一种独占锁。**什么意思呢?当A线程取到锁后,互斥锁将被A线程独自占有,当A没有释放这把锁时,其他线程的取锁代码都会被阻塞。
|
||||
|
||||
阻塞是怎样进行的呢?**对于99%的线程级互斥锁而言,阻塞都是由操作系统内核实现的**(比如Linux下它通常由内核提供的信号量实现)。当获取锁失败时,内核会将线程置为休眠状态,等到锁被释放后,内核会在合适的时机唤醒线程,而这个线程成功拿到锁后才能继续执行。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/8a/749fc674c55136bd455725b79c9e0c8a.jpg" alt="">
|
||||
|
||||
互斥锁通过内核帮忙切换线程,简化了业务代码使用锁的难度。
|
||||
|
||||
但是,线程获取锁失败时,增加了两次上下文切换的成本:从运行中切换为休眠,以及锁释放时从休眠状态切换为运行中。上下文切换耗时在几十纳秒到几微秒之间,或许这段时间比锁住的代码段执行时间还长。而且,线程主动进入休眠是高并发服务无法容忍的行为,这让其他异步请求都无法执行。
|
||||
|
||||
如果你能确定被锁住的代码执行时间很短,就应该用自旋锁取代互斥锁。
|
||||
|
||||
自旋锁比互斥锁快得多,因为它通过CPU提供的CAS函数(全称Compare And Swap),在用户态代码中完成加锁与解锁操作。
|
||||
|
||||
我们知道,加锁流程包括2个步骤:第1步查看锁的状态,如果锁是空闲的,第2步将锁设置为当前线程持有。
|
||||
|
||||
在没有CAS操作前,多个线程同时执行这2个步骤是会出错的。比如线程A执行第1步发现锁是空闲的,但它在执行第2步前,线程B也执行了第1步,B也发现锁是空闲的,于是线程A、B会同时认为它们获得了锁。
|
||||
|
||||
CAS函数把这2个步骤合并为一条硬件级指令。这样,第1步比较锁状态和第2步锁变量赋值,将变为不可分割的原子指令。于是,设锁为变量lock,整数0表示锁是空闲状态,整数pid表示线程ID,那么CAS(lock, 0, pid)就表示自旋锁的加锁操作,CAS(lock, pid, 0)则表示解锁操作。
|
||||
|
||||
多线程竞争锁的时候,加锁失败的线程会“忙等待”,直到它拿到锁。什么叫“忙等待”呢?它并不意味着一直执行CAS函数,生产级的自旋锁在“忙等待”时,会与CPU紧密配合 ,它通过CPU提供的PAUSE指令,减少循环等待时的耗电量;对于单核CPU,忙等待并没有意义,此时它会主动把线程休眠。
|
||||
|
||||
如果你对此感兴趣,可以阅读下面这段生产级的自旋锁,看看它是怎么执行“忙等待”的:
|
||||
|
||||
```
|
||||
while (true) {
|
||||
//因为判断lock变量的值比CAS操作更快,所以先判断lock再调用CAS效率更高
|
||||
if (lock == 0 && CAS(lock, 0, pid) == 1) return;
|
||||
|
||||
if (CPU_count > 1 ) { //如果是多核CPU,“忙等待”才有意义
|
||||
for (n = 1; n < 2048; n <<= 1) {//pause的时间,应当越来越长
|
||||
for (i = 0; i < n; i++) pause();//CPU专为自旋锁设计了pause指令
|
||||
if (lock == 0 && CAS(lock, 0, pid)) return;//pause后再尝试获取锁
|
||||
}
|
||||
}
|
||||
sched_yield();//单核CPU,或者长时间不能获取到锁,应主动休眠,让出CPU
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在使用层面上,自旋锁与互斥锁很相似,实现层面上它们又完全不同。自旋锁开销少,在多核系统下一般不会主动产生线程切换,很适合在用户态切换请求的编程方式,有助于高并发服务充分利用多颗CPU。但如果被锁住的代码执行时间过长,CPU资源将被其他线程在“忙等待”中长时间占用。
|
||||
|
||||
当取不到锁时,互斥锁用“线程切换”来面对,自旋锁则用“忙等待”来面对。**这是两种最基本的处理方式,更高级别的锁都会选择其中一种来实现,比如读写锁就既可以基于互斥锁实现,也可以基于自旋锁实现。**
|
||||
|
||||
下面我们来看一看读写锁能带来怎样的性能提升。
|
||||
|
||||
## 允许并发持有的读写锁
|
||||
|
||||
**如果你能够明确区分出读和写两种场景,可以选择读写锁。**
|
||||
|
||||
读写锁由读锁和写锁两部分构成,仅读取共享资源的代码段用读锁来加锁,会修改资源的代码段则用写锁来加锁。
|
||||
|
||||
读写锁的优势在于,当写锁未被持有时,多个线程能够并发地持有读锁,这提高了共享资源的使用率。多个读锁被同时持有时,读线程并不会修改共享资源,所以它们的并发执行不会产生数据错误。
|
||||
|
||||
而一旦写锁被持有后,不只读线程必须阻塞在获取读锁的环节,其他获取写锁的写线程也要被阻塞。写锁就像互斥锁和自旋锁一样,是一种独占锁;而读锁允许并发持有,则是一种共享锁。
|
||||
|
||||
**因此,读写锁真正发挥优势的场景,必然是读多写少的场景,否则读锁将很难并发持有。**
|
||||
|
||||
实际上,读写锁既可以倾向于读线程,又可以倾向于写线程。前者我们称为读优先锁,后者称为写优先锁。
|
||||
|
||||
读优先锁更强调效率,它期待锁能被更多的线程持有。简单看下它的工作特点:当线程A先持有读锁后,即使线程B在等待写锁,后续前来获取读锁的线程C仍然可以立刻加锁成功,因为这样就有A、C 这2个读线程在并发持有锁,效率更高。
|
||||
|
||||
我们再来看写优先的读写锁。同样的情况下,线程C获取读锁会失败,它将被阻塞在获取锁的代码中,这样,只要线程A释放读锁后,线程B马上就可以获取到写锁。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/c6/7b5f4e4bb3370b89b90c1bf83cb58fc6.jpg" alt="">
|
||||
|
||||
读优先锁并发性更好,但问题也很明显。如果读线程源源不断地获取读锁,写线程将永远获取不到写锁。写优先锁可以保证写线程不会饿死,但如果新的写线程源源不断地到来,读线程也可能被饿死。
|
||||
|
||||
那么,能否兼顾二者,避免读、写线程饿死呢?
|
||||
|
||||
**用队列把请求锁的线程排队,按照先来后到的顺序加锁即可,当然读线程仍然可以并发,只不过不能插队到写线程之前。**Java中的ReentrantReadWriteLock读写锁,就支持这种排队的公平读写锁。
|
||||
|
||||
如果不希望取锁时线程主动休眠,还可以用自旋锁实现读写锁。到底应该选择“线程切换”还是“忙等待”方式实现读写锁呢?除去读写场景外,这与选择互斥锁和自旋锁的方法相同,就是根据加锁代码执行时间的长短来选择,这里就不再赘述了。
|
||||
|
||||
## 乐观锁:不使用锁也能同步
|
||||
|
||||
事实上,无论互斥锁、自旋锁还是读写锁,都属于悲观锁。
|
||||
|
||||
什么叫悲观锁呢?它认为同时修改资源的概率很高,很容易出现冲突,所以访问共享资源前,先加上锁,总体效率会更优。然而,如果并发产生冲突的概率很低,就不必使用悲观锁,而是使用乐观锁。
|
||||
|
||||
所谓“乐观”,就是假定冲突的概率很低,所以它采用的“加锁”方式是,先修改完共享资源,再验证这段时间内有没有发生冲突。如果没有其他线程在修改资源,那么操作完成。如果发现其他线程已经修改了这个资源,就放弃本次操作。
|
||||
|
||||
至于放弃后如何重试,则与业务场景相关,虽然重试的成本很高,但出现冲突的概率足够低的话,还是可以接受的。可见,**乐观锁全程并没有加锁,所以它也叫无锁编程。**
|
||||
|
||||
无锁编程中,验证是否发生了冲突是关键。该怎么验证呢?这与具体的场景有关。
|
||||
|
||||
比如说在线文档。Web中的在线文档是怎么实现多人编辑的?用户A先在浏览器中编辑某个文档,之后用户B也打开了相同的页面开始编辑,可是,用户B最先编辑完成提交,这一过程用户A却不知道。当A提交他改完的内容时,A、B之间的并行修改引发了冲突。
|
||||
|
||||
Web服务是怎么解决这种冲突的呢?它并没有限制用户先拿到锁后才能编辑文档,这既因为冲突的概率非常低,也因为加解锁的代价很高。Web中的方案是这样的:让用户先改着,但需要浏览器记录下修改前的文档版本号,这通过下载文档时,返回的HTTP ETag头部实现。
|
||||
|
||||
当用户提交修改时,浏览器在请求中通过HTTP If-Match头部携带原版本号,服务器将它与文档的当前版本号比较,一致后新的修改才能生效,否则提交失败。如下图所示(如果你想了解这一过程的细节,可以阅读 [《Web协议详解与抓包实战》第28课](https://time.geekbang.org/course/detail/175-98914)):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/f0/1db3bb24d896fabeebf68359384214f0.jpg" alt="">
|
||||
|
||||
乐观锁除了应用在Web分布式场景,在数据库等单机上也有广泛的应用。只是面向多线程时,最后的验证步骤是通过CPU提供的CAS操作完成的。
|
||||
|
||||
乐观锁虽然去除了锁操作,但是一旦发生冲突,重试的成本非常高。所以,**只有在冲突概率非常低,且加锁成本较高时,才考虑使用乐观锁。**
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们介绍了高并发下同步资源时,如何根据应用场景选择合适的锁,来优化服务的性能。
|
||||
|
||||
互斥锁能够满足各类功能性要求,特别是被锁住的代码执行时间不可控时,它通过内核执行线程切换及时释放了资源,但它的性能消耗最大。需要注意的是,协程的互斥锁实现原理完全不同,它并不与内核打交道,虽然不能跨线程工作,但效率很高。(如果你希望进一步了解协程,可以阅读[[第5讲]](https://time.geekbang.org/column/article/233629)。)
|
||||
|
||||
如果能够确定被锁住的代码取到锁后很快就能释放,应该使用更高效的自旋锁,它特别适合基于异步编程实现的高并发服务。
|
||||
|
||||
如果能区分出读写操作,读写锁就是第一选择,它允许多个读线程同时持有读锁,提高了并发性。读写锁是有倾向性的,读优先锁很高效,但容易让写线程饿死,而写优先锁会优先服务写线程,但对读线程亲和性差一些。还有一种公平读写锁,它通过把等待锁的线程排队,以略微牺牲性能的方式,保证了某种线程不会饿死,通用性更佳。
|
||||
|
||||
另外,读写锁既可以使用互斥锁实现,也可以使用自旋锁实现,我们应根据场景来选择合适的实现。
|
||||
|
||||
当并发访问共享资源,冲突概率非常低的时候,可以选择无锁编程。它在Web和数据库中有广泛的应用。然而,一旦冲突概率上升,就不适合使用它,因为它解决冲突的重试成本非常高。
|
||||
|
||||
总之,不管使用哪种锁,锁范围内的代码都应尽量的少,执行速度要快。在此之上,选择更合适的锁能够大幅提升高并发服务的性能!
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一道思考题,上一讲我们提到协程中也有各种锁,你觉得协程中可以用自旋锁或者互斥锁吗?如果不可以,那协程中的锁是怎么实现的?欢迎你在留言区与我探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
129
极客时间专栏/系统性能调优必知必会/应用层编解码优化/14 | 优化TLS|SSL性能该从何下手?.md
Normal file
129
极客时间专栏/系统性能调优必知必会/应用层编解码优化/14 | 优化TLS|SSL性能该从何下手?.md
Normal 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))。
|
||||
|
||||
现代对称加密算法的特点是,即使把加密流程向全社会公开,攻击者也从公网上截获到密文,但只要他没有拿到密钥,就无法从密文中反推出原始明文。如何同步密钥我们稍后在谈,先来看如何优化对称加密算法。
|
||||
|
||||
目前主流的对称加密算法叫做AES(Advanced Encryption Standard),它在性能和安全上表现都很优秀。而且,它不只在访问网站时最为常用,甚至你日常使用的WINRAR等压缩软件也在使用AES算法(见[官方FAQ](https://www.win-rar.com/encryption-faq.html?&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-xor),CPU执行起来更快。**
|
||||
|
||||
说完对称加密算法的优化,我们再来看加密时的密钥是如何传递的。
|
||||
|
||||
## 如何更快地协商出密钥?
|
||||
|
||||
无论对称加密算法有多么安全,一旦密钥被泄露,信息安全就是一纸空谈。所以,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通讯密文都会被破解。解决前向保密的是[DH(Diffie–Hellman)密钥协商算法](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的性能呢?欢迎你在留言区与我探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
132
极客时间专栏/系统性能调优必知必会/应用层编解码优化/15 | 如何提升HTTP|1.1性能?.md
Normal file
132
极客时间专栏/系统性能调优必知必会/应用层编解码优化/15 | 如何提升HTTP|1.1性能?.md
Normal 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性能的方法吗?欢迎你在留言区与我一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
133
极客时间专栏/系统性能调优必知必会/应用层编解码优化/16 | HTTP|2是怎样提升性能的?.md
Normal file
133
极客时间专栏/系统性能调优必知必会/应用层编解码优化/16 | HTTP|2是怎样提升性能的?.md
Normal 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中默认限制为8K(http2_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编码不是更有效率吗?欢迎你在留言区与我一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
110
极客时间专栏/系统性能调优必知必会/应用层编解码优化/17 | Protobuf是如何进一步提高编码效率的?.md
Normal file
110
极客时间专栏/系统性能调优必知必会/应用层编解码优化/17 | Protobuf是如何进一步提高编码效率的?.md
Normal 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其实都是多余的,因为客户端与服务器的处理代码都清楚字段的含义。
|
||||
|
||||
```
|
||||
{"name":"John","id":1234,"sex":"MALE"}
|
||||
|
||||
```
|
||||
|
||||
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="">
|
||||
|
||||
这里需要注意,字符串长度的编码逻辑与字段名相同,当长度小于128(2<sup>7</sup>)时,1个字节就可以表示长度。若长度从128到16384(2<sup>14</sup>),则需要2个字节,以此类推。
|
||||
|
||||
由于字符串编码时未做压缩,所以并不会节约空间,但胜在速度快。**如果你的消息中含有大量字符串,那么使用Huffman等算法压缩后再编码效果更好。**
|
||||
|
||||
我们再来看id:1234这个数字是如何编码的。其实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为什么能提升消息的解码性能?欢迎你在留言区与我一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
147
极客时间专栏/系统性能调优必知必会/应用层编解码优化/18 | 如何通过gRPC实现高效远程过程调用?.md
Normal file
147
极客时间专栏/系统性能调优必知必会/应用层编解码优化/18 | 如何通过gRPC实现高效远程过程调用?.md
Normal 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("localhost:23333") as channel:
|
||||
stub = demo_pb2_grpc.GRPCDemoStub(channel)
|
||||
request = demo_pb2.Request(client_id=1,
|
||||
request_data="called by Python client")
|
||||
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="Python server SimpleMethod Ok!!!!")
|
||||
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消息有何不同,欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
51
极客时间专栏/系统性能调优必知必会/开篇词/开篇词 | 万变不离其宗,性能优化也有章可循.md
Normal file
51
极客时间专栏/系统性能调优必知必会/开篇词/开篇词 | 万变不离其宗,性能优化也有章可循.md
Normal file
@@ -0,0 +1,51 @@
|
||||
<audio id="audio" title="开篇词 | 万变不离其宗,性能优化也有章可循" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ff/28/ff7e40ee58c5bdbcd660049c17625228.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。欢迎你和我一起学习“系统性能优化”。
|
||||
|
||||
从2004年毕业到现在,无论我在华为、思科、腾讯、阿里巴巴等等哪家公司,我的工作总是与“性能”相伴,从底层、应用层到前端,我一直都很关心系统如何能够服务更多的用户,提供更快的体验。换句话说啊,**性能优化最根本的目的,还是要跟上业务的发展脚步。**
|
||||
|
||||
在我看来,程序员所做的工作,就是把现实世界中的问题,用数据、模型来抽象,再用计算机的计算能力把问题解决掉,虽然IT设备的运行速度日新月异,但业务要处理的数据在现实世界中近乎是无限的,而我们的计算能力相对则极为有限。所以,我们需要提升计算的效率,需要更苛刻地使用计算机和网络设备,做性价比更高的事儿。
|
||||
|
||||
而从十几年的性能优化工作中我发现,性能不只对产品的攻城掠地至关重要,它也是程序员价值的重要体现,特别是它在工作面试、技术等级晋升上也扮演着核心角色。
|
||||
|
||||
比如,在大多数拥有技术职级晋升体系的公司里,为了保障公平性,一般都是由跨部门的专家组成评委会的,而**其他部门的高级专家在不熟悉候选人业务的情况下,只能去考察底层的硬核知识,而性能问题又是最有区分度的问题。**如果你始终埋头在业务中,不关心更通用的性能优化方法论,将在技术等级晋升上非常吃亏。
|
||||
|
||||
再比如,你在面试互联网大厂时,面试官总会问许多超出工作范围的性能问题,为什么会这样呢?当然你可以感慨甚至抱怨,这不就是“面试造火箭,入职拧螺丝”嘛,但你也可以从面试官的角度来看这个问题,你会发现**性能就是最好的面试题,它从算法到架构,既能考察候选人的潜力,也能考察候选人的工程能力。**如果候选人具备系统的性能优化方法论,那么无论在架构设计还是应用模块开发上,他的代码可扩展性都会更好,消耗的计算力、带宽、磁盘等IT资源也更少!
|
||||
|
||||
这么看来,不论是为了满足业务发展的需求,还是在面试、晋升场景中有更好的表现,如果你希望成为高薪高效的10X程序员,那么,系统地学习性能优化就是一门必修课。
|
||||
|
||||
那具体应该从哪里入手呢?当然是看需求。当下的后端几乎都是**分布式系统**,那么对应的,我们面对的课题也就是**如何全面提升复杂集群的性能**。然而,如果你在Google上搜索如何优化分布式系统的性能,你只能找到孤零零的几篇文章,而谈到分布式系统的多数书籍也都在讨论容错、事务、流控等概念的实现,很少有文章介绍如何优化整个系统的性能。这恰恰就是我想做这门课的初衷。
|
||||
|
||||
我希望把自己这些年来在分布式性能领域所遇到的问题和解决方案,归纳总结、抽离萃取,梳理出一条**系统化的性能学习路径**交付给你,告诉你我眼中的性能问题本质是怎样的。在我看来啊,性能优化的本质就是最大化整个系统的综合效率,为了达到这个目标,我们需要从空间、时间维度上,不断地优化基础资源间的协作方式。
|
||||
|
||||
文稿中我给你总结了一份系统性能优化核心关注点的知识脑图,你可以同步看一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e7/3d/e7aef5a7653c2ae6c1060e290a7a343d.jpg" alt="">
|
||||
|
||||
通过这份脑图,你会发现,我是从4个方面来梳理的,这其实就是我们在提升一个新系统的性能时,可以入手的4个层次。
|
||||
|
||||
首先,你可以从提升单机进程的性能入手,包括高效地使用主机的CPU、内存、磁盘等硬件,通过并发编程提升吞吐量,根据业务特性选择合适的算法。
|
||||
|
||||
其次,分布式系统是由各个组件通过网络连接在一起,所以优化传输层网络可以让所有组件同时受益。具体优化时,你可以从降低请求的时延,提升总体吞吐量两个方向入手。
|
||||
|
||||
再次呢,你要对业务消息采用更高效的编码方式,这既包括协议头、包体的优化,也包括TLS安全层的性能提升。具体优化时,既要深入静态编码,也要从动态的增量编码上优化。同时,调整消息的交互方式也能提升性能。
|
||||
|
||||
最后,我们再从集群整体上进行架构层面的优化。基于ACP、AKF、NWR等分布式理论,我们的优化方向仍然是降低时延和提升吞吐量,但实现方式则要运用分而治之的思想,调度集群中的所有结点协作配合,完成性能优化目标。
|
||||
|
||||
我这么介绍完吧,我猜你心里可能会有一些问题,好像这些内容都挺熟悉呀?是的,咱们这门课的知识点并不是非常新的前沿知识,而是我们每天coding时都在面对的日常问题。这里的关键是我们得**系统地**掌握这些知识点,在心中构建出性能优化树状知识图谱,然后我们才能更有底气地优化整个系统的性能。
|
||||
|
||||
另外,你可能也会感觉到,哎,好像性能优化也没那么复杂吧,感觉知识点不多呀!如果你是这么想的,那我要先夸夸你,我们就是要有这样的信心,先不给自己设限,不能还没开始就怕了。不过有了信心和动力后呢,也还是要再听我多说几句。
|
||||
|
||||
性能问题其实是计算机体系的底层问题,它涉及到的知识面非常广,我们的课程不可能覆盖全部领域。我最希望给到你的,是基于自己的经历和经验,对知识做一次筛选和过滤,把我已经构建起来的性能优化体系给到你,但同时,我能保证我们在解决性能优化中一些典型问题的同时,可以关联到绝大部分领域,对于任何一个领域,如果你需要进一步深入学习,你也能够知道自己的目标和路径。
|
||||
|
||||
好了,课程我就介绍到这里,你准备好了吗?那在正式开始之前,我们先来定下学习目标吧。
|
||||
|
||||
如果你需要从架构层面优化整个系统,那么这门课可以拓展你的知识面,告诉你如何优化架构才能让整体服务获得最大性能。那你的学习目标就要聚焦在知识体系上,你需要阶段性地做总结分享。
|
||||
|
||||
如果你刚开始接触性能优化,这门课则可以给你打牢基础,告诉你影响性能的底层因素,在实践中优化你的程序,看到立竿见影的效果。那我对你的要求就是每节课都要认真完成课后思考题,学习过程中有任何问题都要及时提问,不积压不懂的地方。
|
||||
|
||||
当然了,我的“要求”主要是供你参考的,欢迎你在留言区写写自己的学习计划。最后,我还特别希望听你说说,你目前的工作状态,是否意识到了性能问题的重要性,是否已经在解决很多性能相关的棘手问题,是否自己也总结了一些性能优化思路,分享出来,我们一起交流。
|
||||
|
||||
讲到最后,我都有点激动了,我一直认为性能优化并非是架构师的专属技能,只要我们有清晰的路径,积硅步成千里,我们都可以用更好的体验、更低的成本来服务更多的用户,还能轻松应对大厂面试,完成公司技术体系内的晋升,拿到更高的薪资。
|
||||
|
||||
那还等啥,从现在开始,我们就一起解锁分布式系统的性能优化吧!
|
||||
35
极客时间专栏/系统性能调优必知必会/期中考试周/加餐1|特别福利:陶辉视频课精选.md
Normal file
35
极客时间专栏/系统性能调优必知必会/期中考试周/加餐1|特别福利:陶辉视频课精选.md
Normal file
@@ -0,0 +1,35 @@
|
||||
<audio id="audio" title="加餐1|特别福利:陶辉视频课精选" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/44/fb/446617da0682a4f27db0147f366059fb.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
不知道你的期中考试成绩如何呢?是否发现了一些新的问题?成绩好的同学请继续努力了,咱们还有一半的课程未学,希望你能一如既往地跟着我深挖系统性能,这门课的内容设计是由浅入深的,第三、四模块会重点往集群方向转了,内容相对会更加烧脑。成绩略差的同学也不要气馁,就像我上节课说的,问题驱动是一种很好的学习方式,查漏补缺才是这场期中考试的目的。
|
||||
|
||||
聊完考试,我就来兑现承诺了,精选3节我的视频课作为福利送给你,它们来自[《Web协议详解与抓包实战》](https://time.geekbang.org/course/detail/100026801-93596),这3节课都是与咱们专栏有强关联的。
|
||||
|
||||
我还记得在开篇词中有位同学问我,学习咱们专栏需要哪些基础?我是这么回复的:
|
||||
|
||||
1. 对操作系统的原理有简单的了解,比如,文件是怎么读取的,HTTP请求是怎么发送和接收的;
|
||||
1. 对网络协议要有所了解,毕竟分布式系统就是靠网络将操作系统上的进程连接在一起。这块《Web协议详解与抓包实战》可以先学下。
|
||||
1. 大致知道什么是分布式系统,这块网上的文章有很多。
|
||||
|
||||
其中第1点和第3点,我想还是很容易的,所以我就在第2点上为你加码了。以下就是3节视频课,你可以直接点击观看。
|
||||
|
||||
## 第1节
|
||||
|
||||
与[第7课](https://time.geekbang.org/column/article/235302)有联系的[《Web协议详解与抓包实战》第117课](https://time.geekbang.org/course/detail/175-134405)。我在第7课讲解组播时,有推荐你如果想要进一步了解组播的细节,可以观看以下视频课。
|
||||
|
||||
<video poster="https://media001.geekbang.org/359a2787ee244329b9adcfb4f38fd412/snapshots/608274439cdf483488b53feb2b2f4484-00005.jpg" preload="none" controls=""><source src="https://media001.geekbang.org/customerTrans/fe4a99b62946f2c31c2095c167b26f9c/1025bb60-16d1595a668-0000-0000-01d-dbacd.mp4" type="video/mp4"><source src="https://media001.geekbang.org/92a7232de62042c09b0192682f2061e0/684692ecfc454b199a21406d5b8df606-9a4d4abb9d5d9dc2132014b8cbb6c37c-sd.m3u8" type="application/x-mpegURL"></video>
|
||||
|
||||
## 第2节
|
||||
|
||||
与[第14课](https://time.geekbang.org/column/article/241632)有联系的[《Web协议详解与抓包实战》第51课](https://time.geekbang.org/course/detail/175-104932)。我在第14课讲解用 Chrome 浏览器配合 Wireshark 解密消息,可以帮助你分析 TLS 协议的细节时,具体的操作方法有推荐你观看以下视频课。
|
||||
|
||||
<video poster="https://media001.geekbang.org/5a1316079a344db5ae9798c970997767/snapshots/dd8e0c6373844445a4c5990446e11009-00005.jpg" preload="none" controls=""><source src="https://media001.geekbang.org/customerTrans/fe4a99b62946f2c31c2095c167b26f9c/498cd722-16ce7fc8c98-0000-0000-01d-dbacd.mp4" type="video/mp4"><source src="https://media001.geekbang.org/ed16e26efdcc4ba4a3710cf85a9dde0b/df98d3463dbe413d831f65320139d9ff-678b414d8e306ddfff2f96407f7708fa-sd.m3u8" type="application/x-mpegURL"></video>
|
||||
|
||||
## 第3节
|
||||
|
||||
与[第15课](https://time.geekbang.org/column/article/242667)有联系的[《Web协议详解与抓包实战》第9课](https://time.geekbang.org/course/detail/175-93594)。第15课我们介绍了 Chrome 浏览器开发者工具的 Network 面板,通过它可以快速判断各站点使用了哪些HTTP性能优化技术,有关 Network 面板的详细用法,我推荐你观看以下视频课。
|
||||
|
||||
<video poster="https://media001.geekbang.org/75c859f0bfca445089a915bac4bd6177/snapshots/dbb7b8e460b5464baf8abee0e321dc3c-00005.jpg" preload="none" controls=""><source src="https://media001.geekbang.org/customerTrans/fe4a99b62946f2c31c2095c167b26f9c/324bfc16-16ce90f3288-0000-0000-01d-dbacd.mp4" type="video/mp4"><source src="https://media001.geekbang.org/f1e2b9b587064da1b6f6474c49ac5e77/bb389aca12374babb1ecb9138780dc25-d423ec6794e8818946dc711c6d62cfce-sd.m3u8" type="application/x-mpegURL"></video>
|
||||
|
||||
今天的特别福利就到这里,这3节视频课对于你学习咱们专栏有一定的辅助作用,希望你能把它作为拓展认真学完。如果你有更多想要了解的内容和期待的学习资料,欢迎在留言区中提出。
|
||||
115
极客时间专栏/系统性能调优必知必会/期中考试周/加餐2 |答疑精选:这些问题你都清楚吗?.md
Normal file
115
极客时间专栏/系统性能调优必知必会/期中考试周/加餐2 |答疑精选:这些问题你都清楚吗?.md
Normal file
@@ -0,0 +1,115 @@
|
||||
<audio id="audio" title="加餐2 |答疑精选:这些问题你都清楚吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ed/f6/edc0559bbff8e9f79c70143c52615bf6.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。今天是期中周的第2篇加餐,按照约定,这节课我从1~15课的留言区精选出了15个问题,这里一部分是与内容强相关的,还有一部分是属于拓展型的问题,选择标准就是是否存在增量信息以及问题价值,希望你能从别人的疑问中进行一次自检,引发更多的思考。
|
||||
|
||||
## 第1课
|
||||
|
||||
鲤鲤鱼:我们集群有一个问题,某一台物理机的CPU会被Hadoop yarn的查询任务打满,并且占用最多的pid在不停的变化,我查看了TIME_WAIT的个数好像也不是很多,在顶峰的时候还没达到一万,能够持续一两个小时。这个问题您有没有什么思路呢?
|
||||
|
||||
作者:解决性能问题,一般有两种方法:经验派和“理论”派。前者就是基于自己的经验概率,将能想到的优化方法都试一遍,这种方式通常又有效又快速,但无法解决复杂的问题。而所谓理论派,就是沿着固定的思路,使用二分法,从高至低慢慢下沉到细节。
|
||||
|
||||
具体到你的问题,我建议你先看看,CPU占用的是用户态还是系统态,用户态的话就要分析代码了,系统态还要进一步分析。火焰图通常是个很好的办法,虽然搭能画火焰图的环境很麻烦,但这种底层方法很有效(第19课会具体讲到火焰图的用法)。
|
||||
|
||||
## 第2课
|
||||
|
||||
alan:老师好,这节课真好,第一次了解到内存池也是有层次的。我遇到一个问题想请教一下:我有一个和数据库交互的Groovy程序,运行起来后会占用很大内存,启动时,将Xmx设置为多少,该程序的内存占用就不会超过Xmx指定的上限。比如,Xmx=10g,程序就稳定占10g内存,但如果不限制的话,最高见过占用30G左右。这个您觉得有什么可能的原因吗?
|
||||
|
||||
作者:每种Java虚拟机都有自己独特的垃圾回收机制,有时为了时间更快就会牺牲更多的内存空间,这是正常的。我建议在服务器上长时间运行的Java进程,一定要通过Xmx去明确内存占用,否则内存不可控会很麻烦。
|
||||
|
||||
## 第3课
|
||||
|
||||
杨文宇:链表的内存地址不连续是如何影响序列化的?老师能具体说一下吗?
|
||||
|
||||
作者:当数组外还有链表中的元素时,序列化就必须遍历所有元素,比如,至少要做1次循环,把每1个遍历到的元素的值,序列化写入至另一段内存中。而使用闭散列时,可以直接将这个数组占用的内存作为序列化后的数据来直接落地或使用。
|
||||
|
||||
## 第4课
|
||||
|
||||
helloworld:“第二,读取磁盘数据时,需要先找到数据所在的位置,对于机械磁盘来说,就是旋转磁头到数据所在的扇区,再开始顺序读取数据。其中,旋转磁头耗时很长,为了降低它的影响,PageCache 使用了预读功能。”那是不是使用SSD这类固态硬盘(不用旋转磁头),PageCache就没有很大的影响?
|
||||
|
||||
作者:对的!其实,当下的操作系统对SSD磁盘的支持还不够,当SSD广泛应用时,文件系统还需要跟上,得获得很大的性能提升才可以。
|
||||
|
||||
## 第5课
|
||||
|
||||
Robust:“然而,共享地址空间虽然可以方便地共享对象,但这也导致一个问题,那就是任何一个线程出错时,进程中的所有线程会跟着一起崩溃。”这里的出错应该表示一些特殊的错误吧,或者是说和共享内存有关的错误,比如申请不到内存等。老师,我理解得没错吧?
|
||||
|
||||
作者:这里指无法恢复的错误,不仅是内存申请错误,比如访问已经释放的资源,且没有捕获异常或者无法捕获异常,进而操作系统只能杀死线程时,进程里的其它线程也会被杀死。
|
||||
|
||||
## 第6课
|
||||
|
||||
范闲:用户态的协程不能用互斥或者自旋,会进入内核态与其设计初衷相悖,Python里面用的yield。
|
||||
|
||||
作者:是的,用户态协程需要用户态的代码将锁重新实现一遍,其中实现时不能用到内核提供的系统调用。
|
||||
|
||||
## 第7课
|
||||
|
||||
重返归途:广播功能属于双工吗?当多个客户机向主机响应时,会有性能瓶颈吗?
|
||||
|
||||
作者:广播不是双工,因为广播是由网络设备实现的,所以服务器无法感知到每个客户端的响应,因此客户端对服务器的响应,与本次广播消息链路无关,它必须是另一个通道。
|
||||
|
||||
## 第8课
|
||||
|
||||
龙龙:“因此,哪怕有 1 千万并发连接,也能保证 1 万 RPS 的处理能力,这就是 epoll 能在 C10M 下实现高吞吐量的原因。”老师,这段话我不太理解,1千万的并发连接,只有1万的RPS这能算高吞吐量吗?相当于每秒只有1000个人中的1个人得到响应。还是我理解错了,您表述的是另一层意思?
|
||||
|
||||
作者:这里有2层意思,都是服务于epoll的设计思想这一个目的。
|
||||
|
||||
1. 这段话的上下文,是指单次获取网络事件时,你可以理解为调用epoll_wait系统调用,它的速度与并发连接总数无关,相对于之前的select/poll系统调用(它们都与并发连接总数相关),因此epoll_wait速度很快,这是实现高吞吐量的关键。
|
||||
1. 有些应用会长时间保持TCP长连接,但并没有消息通讯(比如GPS等IoT设备与服务器之间的通讯),此时1千万并发连接下,如果能够维持1万RPS,这也是只有epoll才能做到的,poll/select是不可能做到的。
|
||||
|
||||
## 第9课
|
||||
|
||||
Geek_007:看评论区,很多同学都说是长连接,普通的HTTP keep-alive会不会有坑,三大运营商或者中间网络设备都会将超过一定时间的链接drop掉。如果没有H2这种ping保活的机制,有可能客户端长链接莫名其妙的就被drop掉,客户端只能依赖超时来感知异常,反倒是影响性能了。
|
||||
|
||||
作者:是的,不只网络设备,一些代理服务器为了减轻自己的负担,也会把长连接断掉,比如Nginx默认关闭75秒没有数据交互的keep-alive长连接。
|
||||
|
||||
## 第10课
|
||||
|
||||
安排:TTL每一跳减少1,这些怎么和MSL对应起来呢?每一跳减少的1相当于1秒?
|
||||
|
||||
作者:不是,这是一个预估值,所谓每一跳,是指每经过一个路由器网络设备,将IP头部中的TTL字段减少1,并不等于1秒,通常推荐的TTL的初始值是64。
|
||||
|
||||
## 第11课
|
||||
|
||||
Trident:带宽时延积如何衡量呢?网络时延不是固定的,是要多次取样计算平均网络时延,然后估算出这个时延积吗?
|
||||
|
||||
作者:是的,需要多次取样做估算,再乘以带宽,得到带宽时延积。
|
||||
|
||||
## 第12课
|
||||
|
||||
妥协:为什么报文5之后的ack都是ack6呀?
|
||||
|
||||
作者:TCP是有序的字符流,因此接收方收完报文5后,只能接收报文6,但现在却接收到了报文7、8、9、10,此时接收方该怎么办呢?
|
||||
|
||||
当然,它可以当做不知道,什么也不做,坐等报文6的到来。报文6什么时候会到呢?RTO时间超时后,发送方会重发报文6,因为发送方一直没收到ACK7!但是,RTO是很长的时间,接收方直接反复地传递ACK6,这样发送方就能明白,报文6丢了,它可以提前重发报文6。这叫做快速重传!
|
||||
|
||||
## 第13课
|
||||
|
||||
有铭:“寻找宕机服务时,只要看队列首部最老的心跳包,距现在是否超过 5 秒,如果超过 5 秒就认定宕机。”这里的逻辑无法理解。如果要用这种方式检测心跳,那么肯定要不停地把队列首部的心跳包移除,让新的心跳包从尾部加入,那么如果这个加入的过程卡一点,岂不是就会误判?
|
||||
|
||||
作者:这种设计下,还必须限制每次移除心跳包的数量(分多次执行),以防止加入过程长时间得不到执行。而且,在这种极限场景下,必须监控CPU的使用率,如果长期维持在高占用率(可能你的集群规模已经超大,要每秒处理数百万心跳包),那么应当通过扩容更多的CPU核、分布式系统等其它方案来解决,这已经不是单台机器能解决的了。
|
||||
|
||||
## 第14课
|
||||
|
||||
东郭:请问老师,我在Nginx配置中,不管ssl_certificate和ssl_certificate_key是否配置ecc证书,抓包查看服务器的server hello响应中的Cipher Suite字段都是TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,这是正常的吗?
|
||||
|
||||
作者:这是正常的,TLS握手阶段Nginx的Cipher Suite,要通过ssl_ciphers指令来配合支持的suites,并可通过ssl_prefer_server_ciphers指定优先选用的算法。证书只是包含了密钥、身份等信息,它们与密钥协商方式、对称加密算法并无关系。
|
||||
|
||||
## 第15课
|
||||
|
||||
Geek_007:FB也搞了一套压缩算法ZSTD,对比起来也比gzip性能强很多,不清楚这些压缩算法的原理是啥?怎么对比?另外普通的JSON和PB有不同适合的压缩算法吗?怎么比较呢?
|
||||
|
||||
作者:
|
||||
|
||||
<li>
|
||||
压缩算法的原理都是基于香农的信息论,将高频出现的信息用更少的比特编码。虽然原理是一致的,但实现上却有很大的差别,比如Huffman通过建立Huffman树来生成编码,而LZ77却是通过滑动窗口,这就造成了压缩比、压缩速度都很不相同。
|
||||
</li>
|
||||
<li>
|
||||
比较它们的优劣,主要看3个指标:
|
||||
</li>
|
||||
|
||||
- 压缩比,比如Brotli的压缩比好于ZSTD;
|
||||
- 压缩与解压的速度,比如ZSTD比gzip速度快;
|
||||
- 浏览器等中间件的支持程度,现在几乎所有浏览器都支持Brotli(即br),但ZSTD少有支持。
|
||||
|
||||
1. 普通的JSON和PB没有最适合的算法,还是要针对具体场景,比较方法参见我刚刚说的那3个指标。
|
||||
|
||||
今天的答疑精选就到这里,期待大家能一如既往的在留言区进行交流,如果有更多问题,也欢迎一并提出。
|
||||
19
极客时间专栏/系统性能调优必知必会/期中考试周/期中考试|行至半程,你的收获如何呢?.md
Normal file
19
极客时间专栏/系统性能调优必知必会/期中考试周/期中考试|行至半程,你的收获如何呢?.md
Normal file
@@ -0,0 +1,19 @@
|
||||
<audio id="audio" title="期中考试|行至半程,你的收获如何呢?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/20/a949d9f1c8356954ae36645160619b20.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
时间过得真快,从4月27日课程上线,转眼已经一月有余了,不知道你的收获如何呢?在这期间,我收到了很多同学的反馈,很感谢大家的认可,也非常开心能与你一起交流技术。
|
||||
|
||||
那从我个人而言呢,这已经是我在极客时间开的第三门课了,前两门都是视频课。那切换到文字专栏的话,其实是完全不同的感受,视频课可以通过演示把问题讲清楚;而文字专栏则要反复打磨内容,并且和写书的方式还不一样,从每节课的内容设计、讲述方式到大纲以及具体细节的编辑,真真是掉了不少头发。
|
||||
|
||||
目前专栏已经更新一半了,前两个模块我们已经学完了,包括单主机如何提升性能,以及到了瓶颈后开始使用网络编程会出现的一些问题,第三模块我们也接触了一点点。学到这里,相信你应该感觉到了,系统性能问题的涉及面就是很广很深,往往需要在多个环境中反复验证分析才可以。那每清楚一个问题,你的实力其实就会有一定的沉淀,直到跳跃式的进步。
|
||||
|
||||
这其实也是我本人在学习性能优化过程中一个很深的体会,即问题驱动。特别是我在腾讯、阿里时,不断增长的业务流量导致系统需要持续地进行优化和扩容,再然后就自然地进行系统化总结,我那时候就发现大学里的《数据结构》课白学了,所以我又重头开始学习《算法导论》,然后发现《网络原理》课白学了,又开始学习《TCP/IP协议详解》,等等(当然,这里不是为了吐槽,而是想告诉你,学习时选对教材真的很重要)。**这就是一个螺旋上升的过程。**
|
||||
|
||||
所以,阶段性的验证和总结其实还是非常重要的,这里我特别设置了这场期中考试,精选了20个问题,知识范围就是咱们专栏的1~15课,希望你能认真完成,发现问题及时解决。
|
||||
|
||||
**这里特别说明一下:**我们的期中考试为期一周,从6月3日开始到6月9日结束,这期间我们会暂停更新正文内容,也就是说我们会从6月10日开始更新第16课。你可以好好利用这一周的时间,去回顾一下前15课的知识,做一个巩固。另外,在这一周中,我还会提供两篇加餐给你,一篇是福利加餐,我会从我的视频课中精选出3节和本专栏具有强关联的课程送给你;另一篇是答疑精选,期待你能借由大家的问题和增量信息,去引发更多的思考。
|
||||
|
||||
有标准才有追求,有追求才有动力,有动力才有进步,真心希望性能优化能成为你职业生涯的重武器。最后,来挑战一下吧,开启你的期中考试之旅。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=170&exam_id=396)
|
||||
8
极客时间专栏/系统性能调优必知必会/期末测试/期末测试|对于性能优化,你掌握了多少呢?.md
Normal file
8
极客时间专栏/系统性能调优必知必会/期末测试/期末测试|对于性能优化,你掌握了多少呢?.md
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
《系统性能调优必知必会》这个专栏已经结课有段时间了,很感谢大家的支持,也非常开心能与各位交流技术。为认真学习的你点赞!
|
||||
|
||||
为了让你更好地检测自己的学习成果,我特意做了一套期末测试题。题目共有20道,均为多选,满分100分,快来挑战一下吧!
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=206&exam_id=574)
|
||||
119
极客时间专栏/系统性能调优必知必会/系统层网络优化/07 | 性能好,效率高的一对多通讯该如何实现?.md
Normal file
119
极客时间专栏/系统性能调优必知必会/系统层网络优化/07 | 性能好,效率高的一对多通讯该如何实现?.md
Normal file
@@ -0,0 +1,119 @@
|
||||
<audio id="audio" title="07 | 性能好,效率高的一对多通讯该如何实现?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b9/99/b9c73eae64c2fc4965eb3496960b5699.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。从这一讲开始,我们将从单机进入网络层面的性能优化。
|
||||
|
||||
我们接触过的绝大多数通讯方式,无论是面向连接的HTTP协议,还是无连接的DNS协议,都是一对一收发消息的。其实,除了一对一,还有一对多的通讯方式,它在网络资源的利用上效率要比一对一高得多。这种一对多的通讯方式,在局域网中有很广泛的应用,常见的ARP欺骗、泛洪攻击等,都是通过一对多通讯进行的。
|
||||
|
||||
当应用场景中用一对多代替一对一通讯时,发送方性能会获得很大的提升,整个局域网的效率也会提高。比如,源主机的带宽只有1Gbps,如果采用一对一的方式向100个客户端发送流媒体,这100台主机的带宽之和不会超过1Gbps。但采用一对多传输时,总带宽就可以达到100Gbps。
|
||||
|
||||
除了能提升性能以外,由于一对多通讯可同时向所有主机发送消息,这就在功能层面上可以替换许多人工操作。比如分布式系统的服务发现,使用人工配置既容易出错,速度也慢,而用广播就可以轻松实现自动化服务发现。
|
||||
|
||||
一对多通讯协议一直在发展,在运营商的IPTV网络的视频直播中,它就得到了广泛的应用。即使你暂时不会用到一对多这种方式,也应当了解下它是怎么工作的,熟悉它的工作原理后,还能更深入地理解一对一通讯协议。
|
||||
|
||||
这一讲,我们就来学习如何实现一对多通讯。
|
||||
|
||||
## 广播是怎么实现的?
|
||||
|
||||
一对多通讯分为两种:对局域网内所有主机发送消息的叫做**广播**,而对部分主机发送消息的,则叫做**组播**。我们先来看一下广播是怎么实现的。
|
||||
|
||||
使用广播要改用UDP协议。可能你会问,为什么不能使用最熟悉的TCP协议呢?这要从TCP协议的分层说起。
|
||||
|
||||
1978年在TCP协议迭代了3个版本后,才被Jon Postel(IANA创始人)提出违反了网络分层原则,网络层和传输层耦合在一起很难扩展。于是在TCP的第4个迭代版本中把协议一分为二,包括网络层IP协议和传输层TCP协议(这也是今天的IP协议被称为IPv4的原因)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/02/95d8fe24b6343fef34579f240696dd02.jpg" alt="">
|
||||
|
||||
当你访问Internet站点时,IP协议会将数据通过网络设备穿越多个卫星、光纤等网络,才能送到服务器。而网络设备天然就拥有广播能力,当它在一个网络端口上收到主机发来的报文时,可以向其他端口上的所有主机重发一遍,这就是广播,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/f0/ce45d52f5c87bcae9c4adae8056c21f0.jpg" alt="">
|
||||
|
||||
**虽然IP协议已经具有广播功能,但实际编程中并不会直接使用IP协议发送广播,因为它很难与进程关联起来。**
|
||||
|
||||
根据网络分层模型,上层协议可以使用下层协议的功能,所以传输层协议拥有IP协议的广播能力。同时,传输层通过端口号把网络报文和进程关联在了一起,就像TCP的80端口,把HTTP消息与Nginx等Web Server关联在一起。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/ac/13d968cbacd229f242764557957d3bac.jpg" alt="">
|
||||
|
||||
然而,传输层的TCP协议为了保证可靠性,建立了逻辑上的连接概念,由于一个连接上只能有两方,所以TCP无法进行一对多通讯。而传输层的UDP协议无需建立连接,所以我们常用UDP协议发送广播。
|
||||
|
||||
广播的性能高有两个原因:首先,交换机直接转发给接收方,要比从发送方到接收方的传输路径更短。其次,原本需要发送方复制多份报文再逐一发送至各个接受者的工作,被交换机完成了,这既分担了发送方的负载,也充分使用了整个网络的带宽。
|
||||
|
||||
那么,交换机收到消息后,怎么知道这是广播报文并转发给整个网络呢?我们知道,以太网中的数据链路层,通过硬件的MAC地址来传播消息,交换机就通过报文的MAC地址来确定是否需要广播。**当交换机收到目标MAC地址是ff:ff:ff:ff:ff:ff的报文时,便知道这是一个广播报文,**才会将它转发给局域网中的所有主机,否则只会转发给MAC地址对应端口上的主机。
|
||||
|
||||
不过,我们写代码时无法控制底层的MAC地址,只能填写目标IP地址。什么样的目标IP地址,会生成广播MAC地址呢?**如果只是对所在子网进行广播,那么使用受限广播地址255.255.255.255就可以了;如果局域网划分了多个子网,主机需要向其他子网广播,则需要正确地设置直接广播地址(路由器需要打开直接广播功能)。**
|
||||
|
||||
## 如何正确地设置直接广播IP地址?
|
||||
|
||||
怎么设置直接广播的IP地址呢?我们首先得了解IP地址的构成。
|
||||
|
||||
由于IP协议需要跨越多个网络工作,所以IP地址被一分为二,包括前边的网络ID和后边的主机ID,其中,**网络ID用于不同网络间的寻址,而主机ID则用于在本地局域网内通讯。**
|
||||
|
||||
举个例子,如果你的局域网IP地址是192.168.0.101,那么网络ID就是192.168.0,而主机ID则是101(这里假定网络管理员没有继续划分C类子网)。
|
||||
|
||||
这是因为,以192.168打头的IP地址,被称为C类地址,而C类地址中,最后1个十进制数字代表主机ID。如果IP地址是172.16.20.227,这就是B类地址,此时,172.16是网络ID,而20.227才是主机ID。
|
||||
|
||||
所以,IP地址的前缀数字不同,主机ID的划分位置也不同。事实上,IP地址一共被划分为A、B、C、D、E,5个类别,它的划分依据正是IP地址转换为二进制后,用前4个比特位作为依据的。如果第1个比特位为0,这就是A类地址,它的网络ID是IP地址的第1到第8比特位,而主机ID则是随后的24个比特位。如果局域网IP地址第1个数字是10,这就是A类私有地址(局域网中的地址不能在公网中使用,统称为私有地址或者内网地址)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/e1/4f9c58b60404e2f874802b6a2ec207e1.jpg" alt="">
|
||||
|
||||
类似的,若前2个比特位是10,则是B类地址,它的主机ID是后16位;如果前3个比特位为110,这就是C类地址,它的主机ID是后8位。显然,以192打头的地址,前3位正是110,所以这是C类地址。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/5e/d67b7a52601f80027576c866cf80e25e.jpg" alt="">
|
||||
|
||||
此外还有D类组播地址和E类预留实验地址。
|
||||
|
||||
清楚了划分方法后,再来看如何通过修改主机ID将IP地址改为直接广播地址。如果你留心观察IP地址,会发现主机ID不会出现全0和全1这两种情况,这是因为全0和全1有特殊用途,其中全0特指它自己(所以0.0.0.0可以指代本机IP),而全1表示全部主机。
|
||||
|
||||
**所以,主机ID的比特位全部设为1后就是广播地址。**比如,192.168.0.101是C类地址,把主机ID从101改为255后,就可以用192.168.0.255发送广播了。
|
||||
|
||||
然而,事情到这并没有完。一个A类网络可以容纳千万台主机,B类网络则只能容纳6万多台主机,C类网络则最多容纳254台主机。仅有的三类网络中主机数量差距太大,而世界上存在各种规模的企业,它们所需网络中的主机规模千差万别,上述划分方式太过单一,无法满足各类企业的需求,**于是诞生了CIDR这种新的划分方式,它通过子网掩码(或者叫Netmask),可以在任意的位置将IP地址拆分为网络ID和主机ID,扩展了A、B、C三类网络的用法。**
|
||||
|
||||
当你查看主机的IP地址时,就会看到其后跟着一个类似IP地址的子网掩码。子网掩码必须把它展开成二进制才能使用,这样,掩码前N位为1时,就表示IP地址的前N位是网络ID,而掩码后面剩余的位全是0,表示IP地址对应的位是主机ID。
|
||||
|
||||
比如,若192.168.0.101的子网掩码是255.255.255.192,就表示IP地址的前26位是网络ID,后6位是主机ID,将主机ID置为全1后,就得到了它的广播地址192.168.0.127,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/08/6586d4ec875f63b19993b78c7a11e808.jpg" alt="">
|
||||
|
||||
到这里,我们设置好IP地址后,再把socket句柄设置SO_BROADCAST属性,就可以发送广播了。广播虽然有很多优点,可是一旦被滥用,很容易产生网络风暴,所以路由器默认是不转发广播报文的。
|
||||
|
||||
## 用更精准的组播来做服务发现
|
||||
|
||||
当你用UDP广播来做分布式系统的服务发现,会遇到这样一个问题:若并非网络内的所有主机都属于分布式系统,那么,当指定了端口的UDP广播报文到达其他主机时,会怎么样呢?这些广播报文在这3个步骤后会被丢弃:
|
||||
|
||||
- 第1步,网卡设备收到报文后,查看报文中的目标MAC地址是否与本机的MAC地址匹配,如果不匹配就会丢弃。广播MAC地址默认匹配,继续交由上层的IP协议栈处理;
|
||||
- 第2步,IP协议栈查看目标IP地址是否为本机IP地址,不匹配也会丢弃报文。上文介绍过的广播IP地址同样默认匹配,交由传输层协议继续处理。
|
||||
- 第3步,传输层检查目标端口是否有进程在监听,如果没有则丢弃报文,反之则交付给进程处理。不属于集群的主机自然不会启动服务监听端口,在这一步才会丢弃广播报文。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1c/b3/1c8b6032474debdd2a4d4569a1752ab3.jpg" alt="">
|
||||
|
||||
可见,对于不属于分布式集群的主机而言,广播报文既占用了它们的带宽,这3步协议栈的操作也消耗了CPU的计算力。有什么办法能缩小广播的范围,消除它加在无关主机上的负载呢?
|
||||
|
||||
组播可以做到。组播是一种“定向广播”,它设定了一个虚拟组,用组播IP来标识。这个虚拟组中可以包含多个主机的IP,当向对应的组播IP发送消息时,仅在这个组内的主机才能收到消息。
|
||||
|
||||
组播IP与常见的单播IP不同,它是前文介绍过5类IP地址中的D类地址,32位IP地址的前4位必须是1110,因此组播IP地址的范围是从224.0.0.0到239.255.255.255。
|
||||
|
||||
当设置好组播IP地址后,还要通过管理组播地址的IGMP协议(Internet Group Management Protocol),将主机IP地址添加进虚拟组中。编程语言提供的setsockopt函数,就可以操作IGMP协议管理组播地址。比如,使用参数IP_ADD_MEMBERSHIP就能够向虚拟组中增加IP,而IP_DROP_MEMBERSHIP则可以从组中去除某个主机的IP。
|
||||
|
||||
[这里](https://github.com/russelltao/geektime-webprotocol/tree/master/python%E7%A4%BA%E4%BE%8B%E4%BB%A3%E7%A0%81)有一个可运行的python测试代码,供你参考。如果你想进一步了解组播的细节,可以观看[《Web协议详解与抓包实战》第117课](https://time.geekbang.org/course/detail/175-134405)。
|
||||
|
||||
组播相对于广播而言 ,除了能够更精准的管理组播范围,还能够跨越多个网络工作。当然,如果将多个网络中的IP加入同一虚拟组时,需要涉及到的路由器都可以正确地处理这些IP地址,且都能支持IGMP协议。
|
||||
|
||||
## 小结
|
||||
|
||||
最后我们对这一讲做一个总结。
|
||||
|
||||
由于一对多通讯能够充分利用整体网络的性能,而且通过交换机能够同时向许多主机发送消息,所以在局域网内有广泛的应用。
|
||||
|
||||
在TCP协议分层后,IP协议天然就支持一对多通讯方式。TCP协议面向连接的特性使它放弃了一对多的通讯方式,而UDP协议则继承了IP协议的这一功能。所以,在一对多通讯场景中,我们会选择UDP协议。
|
||||
|
||||
正确输入广播地址的前提,是理解IP地址如何划分为网络ID和主机ID。当主机ID所有的比特位改为全1时,IP地址就表示该网络下的所有主机,这就是广播地址。当向广播地址发送UDP消息时,网络中的所有主机都会收到。广播在局域网中有广泛的应用,转换IP地址与MAC地址的ARP协议就是用广播实现的。
|
||||
|
||||
广播对无关的主机增加了不必要的负担,而组播可以更精准地“定向”广播。组播地址也被称为D类地址,它描述的虚拟组要通过IGMP协议管理。网络API中的setsockopt函数可以通过IGMP协议,向虚拟组中添加或者删除IP地址。当路由器支持IGMP协议时,组播就可以跨越多个网络实现更广泛的一对多通讯。
|
||||
|
||||
广播和组播能够充分地使用全网带宽,也通过交换机等网络设备分散了发送主机的负载。但它很难对每台接收主机提供定制化服务,这样可靠传输就很难实现。这使得它们在更关注及时性、对丢包不敏感的流媒体直播中更有应用前景。
|
||||
|
||||
这一讲我们介绍了许多网络概念,这些也是理解后续内容的基础。从下一讲开始,我们将进入更复杂的一对一通讯协议。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,请你思考下,你使用或者了解过哪些一对多的通讯协议?它们的优缺点,以及未来的发展方向又是什么?欢迎你留言与我探讨。
|
||||
|
||||
感谢阅读,如果你觉得今天学习的内容对你有帮助,也欢迎把它分享给你的朋友。
|
||||
102
极客时间专栏/系统性能调优必知必会/系统层网络优化/08 | 事件驱动:C10M是如何实现的?.md
Normal file
102
极客时间专栏/系统性能调优必知必会/系统层网络优化/08 | 事件驱动:C10M是如何实现的?.md
Normal file
@@ -0,0 +1,102 @@
|
||||
<audio id="audio" title="08 | 事件驱动:C10M是如何实现的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/05/6a/0550ddef1be2a819b2704e790c3e916a.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲介绍了广播与组播这种一对多通讯方式,从这一讲开始,我们回到主流的一对一通讯方式。
|
||||
|
||||
早些年我们谈到高并发,总是会提到C10K,这是指服务器同时处理1万个TCP连接。随着服务器性能的提升,近来我们更希望单台服务器的并发能力可以达到C10M,也就是同时可以处理1千万个TCP连接。从C10K到C10M,实现技术并没有本质变化,都是用事件驱动和异步开发实现的。[[第5讲]](https://time.geekbang.org/column/article/233629) 介绍过的协程,也是依赖这二者实现高并发的。
|
||||
|
||||
做过异步开发的同学都知道,处理基于TCP的应用层协议时,一个请求的处理代码必须被拆分到多个回调函数中,由异步框架在相应的事件生成时调用它们。这就是事件驱动方式,它通过减少上下文切换次数,实现了C10M级别的高并发。
|
||||
|
||||
不过,做应用开发的同学往往不清楚什么叫做“事件”,不了解处理HTTP请求的回调函数与事件间的关系。这样,在高并发下,当多个HTTP请求争抢执行时,涉及资源分配、释放等重要工作的回调函数,就可能在错误的时间被调用,进而引发一系列问题。比如,不同的回调函数对应不同的事件,如果某个函数执行时间过长,就会影响其他请求,可能导致大量请求出现超时而处理失败。
|
||||
|
||||
这一讲我们就来介绍一下,事件是怎样产生的?它是如何驱动请求执行的?多路复用技术是怎样协助实现异步开发的?理解了这些,你也就明白了这种事件驱动的解决方案,知道了怎么样实现C10M。
|
||||
|
||||
## 事件是怎么产生的?
|
||||
|
||||
要了解“事件驱动”的运作机制,首先就要搞清楚到底什么是事件。这就需要你对网络原理有深入的理解了。
|
||||
|
||||
简单来说,从网络中接收到一个报文,就可能产生一个事件。如上一讲介绍过的UDP请求就是最简单的例子,一个UDP请求通常仅由一个网络报文组成,所以,当收到一个UDP报文,就意味着收到一个请求,它会生成一个事件,进而触发回调函数执行。
|
||||
|
||||
不过,常见的HTTP等协议都是基于TCP实现的。由于TCP是一种面向字节流的协议,HTTP请求的大小并不受限制,当一个HTTP请求的大小超过TCP报文的最大长度时,请求会被拆分到多个报文中运输,在接收端的缓冲区中重组、排序。因此,并不是每个到达的TCP报文都能生成事件的。
|
||||
|
||||
如果不理解事件和TCP报文的关系,就没法准确地掌握处理HTTP请求的函数何时被调用。当然,作为应用开发工程师,我们无须在意实现细节,只要了解TCP连接建立、关闭,以及消息的发送和接收这四个场景中,报文与事件间的关系就可以了。
|
||||
|
||||
事件并没有你想象中那么复杂,它只有两种类型:读事件与写事件,其中,读事件表示有到达的消息需要处理,而写事件表示可以发送消息(TCP连接的写缓冲区中有可用空间)。我们先从三次握手建立连接说起,这一过程会产生一读、一写两个事件。
|
||||
|
||||
由于TCP允许双向传输,所以**建立连接时,会依次在连接的两个方向上建立通道。**主动发起连接的一方叫做客户端,被动监听端口等待连接的一方叫做服务器。
|
||||
|
||||
客户端首先发送SYN报文给服务器,而服务器收到后回复ACK和SYN(这里我们只需要知道产生事件的过程即可,下一讲会详细介绍这两个报文的含义),**当它们到达客户端时,双向连接中由客户端到服务器的通道就建立好了,此时客户端就已经可以发送请求了,因此客户端会产生写事件。**接着,**客户端发送ACK报文,到达服务器后,服务器上会产生读事件**,因为进程原本在监听80等端口,此时有新连接建立成功,应当调用accept函数读取这个连接,所以这是一个读事件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/73/98/73b9d890c7087531b51180ada6e65f98.png" alt="">
|
||||
|
||||
在建立好的TCP连接上收发消息时,读事件对应着接收到对方的消息,这很好理解。写事件则稍微复杂些,我们举个例子加以说明。假设要发送一个2MB的请求,**当调用write函数发送时,会先把内存中的数据拷贝到写缓冲区中后,再发送到网卡上。**
|
||||
|
||||
为何要多此一举呢?这是因为在对方没有明确表示收到前,TCP会通过定时器重发写缓冲区中的数据,保证消息能够到达对方。写缓冲区是有大小限制的,我在[第10讲]中会详细介绍。这里假设写缓冲区只有1MB,所以调用write发送2MB数据时,write函数的返回值只有1MB,表示写缓冲区已用尽。当收到对方发来的ACK报文后,缓冲区中的数据才能释放,就会产生写事件通知进程发送剩余的那1MB数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c5/7a/c524965bee6407bd716c7dc33bdd437a.png" alt="">
|
||||
|
||||
如同建立连接需要双向建立一样,关闭连接也需要双方各自关闭每个方向的通道。主动关闭的一方发送FIN报文,到达被动方后,内核自动回复ACK报文,这表示从主动方到被动方的通道已经关闭。**但被动方到主动方的通道也需要关闭,所以此时被动方会产生读事件,提醒被动方调用close函数关闭连接。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/96/b73164fd504cc2574066f526ebee7596.png" alt="">
|
||||
|
||||
这样,我们就清楚了TCP报文如何产生事件,也明白回调函数何时执行了。然而,同步代码拆分成多个异步函数成本并不低,咱们手里拿着事件驱动这个锤子,可不能看到什么都像是钉子。
|
||||
|
||||
什么样的代码值得基于事件来做拆分呢?还得回到高性能这个最终目标上来。我们知道,做性能优化一定要找出性能瓶颈,针对瓶颈做优化性价比才最高。对于服务器来说,对最慢的操作做异步化改造,才能值回开发效率的损失。而服务里对资源的操作速度由快到慢,依次是CPU、内存、磁盘和网络。CPU和内存的执行速度都是纳秒级的,无须考虑事件驱动,而磁盘和网络都可以采用事件驱动的异步方式处理。
|
||||
|
||||
相对而言,网络不只速度慢,而且波动很大,既受制于连接对端的性能,也受制于网络传输路径。把操作网络的同步API改为事件驱动的异步API收益最大。而磁盘(特别是机械硬盘)访问速度虽然不快,但它最慢时也不过几十毫秒,是可控的。而且目前磁盘异步IO技术(参见[[第4讲]](https://time.geekbang.org/column/article/232676))还不成熟,它绕过了PageCache性能损失很大。所以当下的事件驱动,主要就是指网络事件。
|
||||
|
||||
## 该怎样处理网络事件?
|
||||
|
||||
有了网络事件的概念后,我们再来看用户态代码如何处理事件。
|
||||
|
||||
网络事件是由内核产生的,进程该怎样获取到它们呢?如epoll这样的多路复用技术可以帮我们做到。多路复用是通讯领域的词汇,有些抽象但原理确很简单。
|
||||
|
||||
比如,一条高速的光纤上,允许多个用户用较低的网速同时通讯,这就是多路复用。同样道理,一个进程虽然任一时刻只能处理一个请求,但处理每个请求产生的事件时,若耗时控制在1毫秒以内,这样1秒钟就可以处理数千个请求,从更长的时间维度上看,多个请求复用了一个进程,也叫做多路复用(或者叫做时分多路复用)。我们熟知的epoll,就是内核提供给用户态的多路复用接口,进程可以通过它从内核中获取事件。
|
||||
|
||||
epoll是如何获取网络事件的呢?最简单的方法,就是在获取事件时,把所有并发连接传给内核,再由内核返回产生了事件的连接,再处理这些连接对应的请求即可。epoll前的select等多路复用函数就是这么干的。
|
||||
|
||||
然而,C10M意味着有一千万个连接,若每个socket是4字节,那么1千万连接就是40M字节。这样,每收集一次事件,就需要从用户态复制40M字节到内核态。而且,高性能Server必须及时地处理网络事件,所以每隔几十毫秒就要收集一次事件,性能消耗巨大。
|
||||
|
||||
epoll为了降低性能消耗,把获取事件拆分成两步。
|
||||
|
||||
- 第一步把需要监控的socket传给内核(epoll_ctl函数),它仅在连接建立等有限的时机调用;
|
||||
- 第二步收集事件(epoll_wait函数)便不用传递socket了,这样就把socket的重复传递改为了一次传递,降低了性能损耗。
|
||||
|
||||
由于网卡的处理能力有限,千兆网卡下,每秒只能接收100MB左右的数据,如果每个请求约10KB,那么每秒大概有1万个请求到达、10万个事件需要处理。这样,即使每隔100毫秒收集一次事件(调用epoll_wait),每次也不过只有1万个事件(100000 Event/s * 0.1s = 10000 Event/s)需要处理,只要保证处理一个事件的平均时间小于10微秒(多核处理器可以做到),100毫秒内就可以处理完这些事件(100ms = 10us * 10000)。 因此,哪怕有1千万并发连接,也能保证1万RPS的处理能力,这就是epoll能在C10M下实现高吞吐量的原因。
|
||||
|
||||
进程获取到产生事件的socket后,又该如何处理它呢?这里的核心约束是,处理任何一个事件的耗时都应该是微秒级或者毫秒级,否则就会延误其他事件的处理,不只降低了用户的体验,而且会形成恶性循环。
|
||||
|
||||
我们知道,为了应对网络的不确定性,每个参与网络通讯的进程都会为请求设置超时时间。一旦某个socket上的事件迟迟不被处理,当客户端的超时定时器触发时,客户端往往会关闭连接并重发请求,这会让服务器雪上加霜。
|
||||
|
||||
怎样保证处理一个事件的时间不会太长呢? 我们把处理事件的代码分为三类来看。
|
||||
|
||||
第一类是计算任务,虽然内存、CPU的速度很快,然而循环执行也可能耗时达到秒级。所以,如果一定要引入需要密集计算才能完成的请求,为了不阻碍其他事件的处理,要么把这样的请求放在独立的线程中完成,要么把请求的处理过程拆分成多段,确保每段能够快速执行完,同时每段执行完都要均等地处理其他事件,这样通过放慢该请求的处理时间,就保障了其他请求的及时处理。
|
||||
|
||||
第二类会读写磁盘,由于磁盘的写入操作使用了PageCache的延迟写特性,当write函数返回时只是复制到了内存中,所以写入操作很快。磁盘的读取操作就比较慢了,这时,通常要把大文件的读取,拆分成许多份,每份仅有几十KB,降低单次操作的耗时。
|
||||
|
||||
第三类是通过网络访问上游服务。与处理客户端请求相似,我们必须使用非阻塞socket,用事件驱动方式处理请求。需要注意的是,许多网络服务提供的SDK,都是基于阻塞socket实现的,使用前必须先做完非阻塞改造。比如Memcached的官方SDK是用阻塞socket实现的,Nginx如果直接使用该SDK访问它,性能就会一落千丈。正确的访问方式,是使用第三方提供的ngx_http_memcached_module模块,它用非阻塞socket重新封装了SDK。
|
||||
|
||||
总之,网络报文到达后,内核就产生了读、写事件,而epoll函数使得进程可以高效地收集到这些事件。接下来,要确保在进程中处理每个事件的时间足够短,才能及时地处理所有请求,这个过程中既要避免阻塞socket的使用,也要把耗时过长的操作拆成多份执行。最终,通过快速、及时、均等地执行所有事件,异步Server实现了高并发。
|
||||
|
||||
## 小结
|
||||
|
||||
最后我们对这一讲做个小结。异步服务改为从事件层面处理请求,在epoll这样的多路复用机制协助下,最终实现了C10M级别的高并发服务。
|
||||
|
||||
事件有很多种,网络消息的传输既慢又不可控,所以用网络事件驱动请求的性价比最高。这样,就需要你了解TCP报文是如何产生事件的。
|
||||
|
||||
TCP连接建立时,会在客户端产生写事件,在服务器端产生读事件。连接关闭时,则会在被动关闭端产生读事件。在连接上收发消息时,也会产生事件,其中发送消息前的写事件与内核分配的缓冲区有关。
|
||||
|
||||
清楚了事件与TCP报文的关系后,可以用多路复用技术获取事件,其中epoll是佼佼者,它取消了收集事件时重复传递的大量socket参数,给C10M的实现提供了基础。
|
||||
|
||||
你需要注意的是,处理epoll收集到的事件时,必须保证处理一个事件的平均时间在毫秒级以内。传统的阻塞socket是做不到的,所以必须用非阻塞socket替换阻塞socket。如果事件的回调函数耗时过长,也得拆分为多个耗时短的函数,用多次事件(比如定时器事件)的触发来替代。
|
||||
|
||||
虽然我们有了上述的事件驱动方案,但实现C10M还需要更谨慎地使用不过数百GB的服务器内存。关于如何降低内存的消耗,可以关注[[第2讲]](https://time.geekbang.org/column/article/230221) 提到的内存池,[第11讲] 还会介绍如何减少连接缓冲区的空间占用。
|
||||
|
||||
这一讲我们介绍了事件驱动的总体方案,但C10M需要高效的用心几乎所有服务器资源,所以,我们还得通过Linux更精细地控制TCP的行为,接下来的3讲我们将深入Linux,讨论如何优化TCP的性能。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一个思考题,需要CPU做密集计算的请求,该如何拆分到事件驱动框架中呢?欢迎你在留言区留言,与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
159
极客时间专栏/系统性能调优必知必会/系统层网络优化/09 | 如何提升TCP三次握手的性能?.md
Normal file
159
极客时间专栏/系统性能调优必知必会/系统层网络优化/09 | 如何提升TCP三次握手的性能?.md
Normal file
@@ -0,0 +1,159 @@
|
||||
<audio id="audio" title="09 | 如何提升TCP三次握手的性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/72/9e/726a6d37e4a50655c75344829823169e.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲我们提到TCP在三次握手建立连接、四次握手关闭连接时是怎样产生事件的,这两个过程中TCP连接经历了复杂的状态变化,既容易导致编程出错,也有很大的优化空间。这一讲我们看看在Linux操作系统下,如何优化TCP的三次握手流程,提升握手速度。
|
||||
|
||||
TCP是一个可以双向传输的全双工协议,所以需要经过三次握手才能建立连接。三次握手在一个HTTP请求中的平均时间占比在10%以上,在网络状况不佳、高并发或者遭遇SYN泛洪攻击等场景中,如果不能正确地调整三次握手中的参数,就会对性能有很大的影响。
|
||||
|
||||
TCP协议是由操作系统实现的,调整TCP必须通过操作系统提供的接口和工具,这就需要理解Linux是怎样把三次握手中的状态暴露给我们,以及通过哪些工具可以找到优化依据,并通过哪些接口修改参数。
|
||||
|
||||
因此,这一讲我们将介绍TCP握手过程中各状态的意义,并以状态变化作为主线,看看如何调整Linux参数才能提升握手的性能。
|
||||
|
||||
## 客户端的优化
|
||||
|
||||
客户端和服务器都可以针对三次握手优化性能。相对而言,主动发起连接的客户端优化相对简单一些,而服务器需要在监听端口上被动等待连接,并保存许多握手的中间状态,优化方法更为复杂一些。我们首先来看如何优化客户端。
|
||||
|
||||
三次握手建立连接的首要目的是同步序列号。只有同步了序列号才有可靠的传输,TCP协议的许多特性都是依赖序列号实现的,比如流量控制、消息丢失后的重发等等,这也是三次握手中的报文被称为SYN的原因,因为SYN的全称就叫做Synchronize Sequence Numbers。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c5/aa/c51d9f1604690ab1b69e7c4feb2f31aa.jpg" alt="">
|
||||
|
||||
三次握手虽然由操作系统实现,但它通过连接状态把这一过程暴露给了我们,我们来细看下过程中出现的3种状态的意义。客户端发送SYN开启了三次握手,此时在客户端上用netstat命令(后续查看连接状态都使用该命令)可以看到**连接的状态是SYN_SENT**(顾名思义,就是把刚SYN发送出去)。
|
||||
|
||||
```
|
||||
tcp 0 1 172.16.20.227:39198 129.28.56.36:81 SYN_SENT
|
||||
|
||||
```
|
||||
|
||||
客户端在等待服务器回复的ACK报文。正常情况下,服务器会在几毫秒内返回ACK,但如果客户端迟迟没有收到ACK会怎么样呢?客户端会重发SYN,**重试的次数由tcp_syn_retries参数控制**,默认是6次:
|
||||
|
||||
```
|
||||
net.ipv4.tcp_syn_retries = 6
|
||||
|
||||
```
|
||||
|
||||
第1次重试发生在1秒钟后,接着会以翻倍的方式在第2、4、8、16、32秒共做6次重试,最后一次重试会等待64秒,如果仍然没有返回ACK,才会终止三次握手。所以,总耗时是1+2+4+8+16+32+64=127秒,超过2分钟。
|
||||
|
||||
如果这是一台有明确任务的服务器,你可以根据网络的稳定性和目标服务器的繁忙程度修改重试次数,调整客户端的三次握手时间上限。比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/8f/a3c5e77a228478da2a6e707054043c8f.png" alt="">
|
||||
|
||||
## 服务器端的优化
|
||||
|
||||
当服务器收到SYN报文后,服务器会立刻回复SYN+ACK报文,既确认了客户端的序列号,也把自己的序列号发给了对方。此时,服务器端出现了新连接,状态是SYN_RCV(RCV是received的缩写)。这个状态下,服务器必须建立一个SYN半连接队列来维护未完成的握手信息,当这个队列溢出后,服务器将无法再建立新连接。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/82/c361e672526ee5bb87d5f6b7ad169982.png" alt="">
|
||||
|
||||
新连接建立失败的原因有很多,怎样获得由于队列已满而引发的失败次数呢?netstat -s命令给出的统计结果中可以得到。
|
||||
|
||||
```
|
||||
# netstat -s | grep "SYNs to LISTEN"
|
||||
1192450 SYNs to LISTEN sockets dropped
|
||||
|
||||
```
|
||||
|
||||
这里给出的是队列溢出导致SYN被丢弃的个数。注意这是一个累计值,如果数值在持续增加,则应该调大SYN半连接队列。**修改队列大小的方法,是设置Linux的tcp_max_syn_backlog 参数:**
|
||||
|
||||
```
|
||||
net.ipv4.tcp_max_syn_backlog = 1024
|
||||
|
||||
```
|
||||
|
||||
如果SYN半连接队列已满,只能丢弃连接吗?并不是这样,**开启syncookies功能就可以在不使用SYN队列的情况下成功建立连接。**syncookies是这么做的:服务器根据当前状态计算出一个值,放在己方发出的SYN+ACK报文中发出,当客户端返回ACK报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/c0/0d963557347c149a6270d8102d83e0c0.png" alt="">
|
||||
|
||||
Linux下怎样开启syncookies功能呢?修改tcp_syncookies参数即可,其中值为0时表示关闭该功能,2表示无条件开启功能,而1则表示仅当SYN半连接队列放不下时,再启用它。由于syncookie仅用于应对SYN泛洪攻击(攻击者恶意构造大量的SYN报文发送给服务器,造成SYN半连接队列溢出,导致正常客户端的连接无法建立),这种方式建立的连接,许多TCP特性都无法使用。所以,应当把tcp_syncookies设置为1,仅在队列满时再启用。
|
||||
|
||||
```
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
|
||||
```
|
||||
|
||||
当客户端接收到服务器发来的SYN+ACK报文后,就会回复ACK去通知服务器,同时己方连接状态从SYN_SENT转换为ESTABLISHED,表示连接建立成功。服务器端连接成功建立的时间还要再往后,到它收到ACK后状态才变为ESTABLISHED。
|
||||
|
||||
如果服务器没有收到ACK,就会一直重发SYN+ACK报文。当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。反之则可以调小重发次数。**修改重发次数的方法是,调整tcp_synack_retries参数:**
|
||||
|
||||
```
|
||||
net.ipv4.tcp_synack_retries = 5
|
||||
|
||||
```
|
||||
|
||||
tcp_synack_retries 的默认重试次数是5次,与客户端重发SYN类似,它的重试会经历1、2、4、8、16秒,最后一次重试后等待32秒,若仍然没有收到ACK,才会关闭连接,故共需要等待63秒。
|
||||
|
||||
服务器收到ACK后连接建立成功,此时,内核会把连接从SYN半连接队列中移出,再移入accept队列,等待进程调用accept函数时把连接取出来。如果进程不能及时地调用accept函数,就会造成accept队列溢出,最终导致建立好的TCP连接被丢弃。
|
||||
|
||||
实际上,丢弃连接只是Linux的默认行为,我们还可以选择向客户端发送RST复位报文,告诉客户端连接已经建立失败。打开这一功能需要将tcp_abort_on_overflow参数设置为1。
|
||||
|
||||
```
|
||||
net.ipv4.tcp_abort_on_overflow = 0
|
||||
|
||||
```
|
||||
|
||||
**通常情况下,应当把tcp_abort_on_overflow设置为0,因为这样更有利于应对突发流量。**举个例子,当accept队列满导致服务器丢掉了ACK,与此同时,客户端的连接状态却是ESTABLISHED,进程就在建立好的连接上发送请求。只要服务器没有为请求回复ACK,请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成accept队列满,那么当accept队列有空位时,再次接收到的请求报文由于含有ACK,仍然会触发服务器端成功建立连接。所以,**tcp_abort_on_overflow设为0可以提高连接建立的成功率,只有你非常肯定accept队列会长期溢出时,才能设置为1以尽快通知客户端。**
|
||||
|
||||
那么,怎样调整accept队列的长度呢?**listen函数的backlog参数就可以设置accept队列的大小。事实上,backlog参数还受限于Linux系统级的队列长度上限,当然这个上限阈值也可以通过somaxconn参数修改。**
|
||||
|
||||
```
|
||||
net.core.somaxconn = 128
|
||||
|
||||
```
|
||||
|
||||
当下各监听端口上的accept队列长度可以通过ss -ltn命令查看,但accept队列长度是否需要调整该怎么判断呢?还是通过netstat -s命令给出的统计结果,可以看到究竟有多少个连接因为队列溢出而被丢弃。
|
||||
|
||||
```
|
||||
# netstat -s | grep "listen queue"
|
||||
14 times the listen queue of a socket overflowed
|
||||
|
||||
```
|
||||
|
||||
如果持续不断地有连接因为accept队列溢出被丢弃,就应该调大backlog以及somaxconn参数。
|
||||
|
||||
## TFO技术如何绕过三次握手?
|
||||
|
||||
以上我们只是在对三次握手的过程进行优化。接下来我们看看如何绕过三次握手发送数据。
|
||||
|
||||
三次握手建立连接造成的后果就是,HTTP请求必须在一次RTT(Round Trip Time,从客户端到服务器一个往返的时间)后才能发送,Google对此做的统计显示,三次握手消耗的时间,在HTTP请求完成的时间占比在10%到30%之间。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/a8/1b9d8f49d5a716470481657b07ae77a8.png" alt="">
|
||||
|
||||
因此,Google提出了TCP fast open方案(简称[TFO](https://tools.ietf.org/html/rfc7413)),客户端可以在首个SYN报文中就携带请求,这节省了1个RTT的时间。
|
||||
|
||||
接下来我们就来看看,TFO具体是怎么实现的。
|
||||
|
||||
**为了让客户端在SYN报文中携带请求数据,必须解决服务器的信任问题。**因为此时服务器的SYN报文还没有发给客户端,客户端是否能够正常建立连接还未可知,但此时服务器需要假定连接已经建立成功,并把请求交付给进程去处理,所以服务器必须能够信任这个客户端。
|
||||
|
||||
TFO到底怎样达成这一目的呢?它把通讯分为两个阶段,第一阶段为首次建立连接,这时走正常的三次握手,但在客户端的SYN报文会明确地告诉服务器它想使用TFO功能,这样服务器会把客户端IP地址用只有自己知道的密钥加密(比如AES加密算法),作为Cookie携带在返回的SYN+ACK报文中,客户端收到后会将Cookie缓存在本地。
|
||||
|
||||
之后,如果客户端再次向服务器建立连接,就可以在第一个SYN报文中携带请求数据,同时还要附带缓存的Cookie。很显然,这种通讯方式下不能再采用经典的“先connect再write请求”这种编程方法,而要改用sendto或者sendmsg函数才能实现。
|
||||
|
||||
服务器收到后,会用自己的密钥验证Cookie是否合法,验证通过后连接才算建立成功,再把请求交给进程处理,同时给客户端返回SYN+ACK。虽然客户端收到后还会返回ACK,但服务器不等收到ACK就可以发送HTTP响应了,这就减少了握手带来的1个RTT的时间消耗。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/c3/7ac29766ba8515eea5bb331fce6dc2c3.png" alt="">
|
||||
|
||||
当然,为了防止SYN泛洪攻击,服务器的TFO实现必须能够自动化地定时更新密钥。
|
||||
|
||||
Linux下怎么打开TFO功能呢?这要通过tcp_fastopen参数。由于只有客户端和服务器同时支持时,TFO功能才能使用,**所以tcp_fastopen参数是按比特位控制的。其中,第1个比特位为1时,表示作为客户端时支持TFO;第2个比特位为1时,表示作为服务器时支持TFO**,所以当tcp_fastopen的值为3时(比特为0x11)就表示完全支持TFO功能。
|
||||
|
||||
```
|
||||
net.ipv4.tcp_fastopen = 3
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲,我们沿着三次握手的流程,介绍了Linux系统的优化方法。
|
||||
|
||||
当客户端通过发送SYN发起握手时,可以通过tcp_syn_retries控制重发次数。当服务器的SYN半连接队列溢出后,SYN报文会丢失从而导致连接建立失败。我们可以通过netstat -s给出的统计结果判断队列长度是否合适,进而通过tcp_max_syn_backlog参数调整队列的长度。服务器回复SYN+ACK报文的重试次数由tcp_synack_retries参数控制,网络稳定时可以调小它。为了应对SYN泛洪攻击,应将tcp_syncookies参数设置为1,它仅在SYN队列满后开启syncookie功能,保证连接成功建立。
|
||||
|
||||
服务器收到客户端返回的ACK后,会把连接移入accept队列,等待进程调用accept函数取出连接。如果accept队列溢出,默认系统会丢弃ACK,也可以通过tcp_abort_on_overflow参数用RST通知客户端连接建立失败。如果netstat统计信息显示,大量的ACK被丢弃后,可以通过listen函数的backlog参数和somaxconn系统参数提高队列上限。
|
||||
|
||||
TFO技术绕过三次握手,使得HTTP请求减少了1个RTT的时间。Linux下可以通过tcp_fastopen参数开启该功能。
|
||||
|
||||
从这一讲可以看出,虽然TCP是由操作系统实现的,但Linux通过多种方式提供了修改TCP功能的接口,供我们优化TCP的性能。下一讲我们再来探讨四次握手关闭连接时,Linux怎样帮助我们优化其性能。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一个思考题,关于三次握手建立连接,你做过哪些优化?效果如何?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
167
极客时间专栏/系统性能调优必知必会/系统层网络优化/10 | 如何提升TCP四次挥手的性能?.md
Normal file
167
极客时间专栏/系统性能调优必知必会/系统层网络优化/10 | 如何提升TCP四次挥手的性能?.md
Normal file
@@ -0,0 +1,167 @@
|
||||
<audio id="audio" title="10 | 如何提升TCP四次挥手的性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/0e/0fd9ccef046f64c2131adb2e84135b0e.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一节课,我们介绍了建立连接时的优化方法,这一节课再来看四次挥手关闭连接时,如何优化性能。
|
||||
|
||||
close和shutdown函数都可以关闭连接,但这两种方式关闭的连接,不只功能上有差异,控制它们的Linux参数也不相同。close函数会让连接变为孤儿连接,shutdown函数则允许在半关闭的连接上长时间传输数据。TCP之所以具备这个功能,是因为它是全双工协议,但这也造成四次挥手非常复杂。
|
||||
|
||||
四次挥手中你可以用netstat命令观察到6种状态。其中,你多半看到过TIME_WAIT状态。网上有许多文章介绍怎样减少TIME_WAIT状态连接的数量,也有文章说TIME_WAIT状态是必不可少、不能优化掉的。这两种看似自相矛盾的观点之所以存在,就在于优化连接关闭时,不能仅基于主机端的视角,还必须站在整个网络的层次上,才能给出正确的解决方案。
|
||||
|
||||
Linux为四次挥手提供了很多控制参数,有些参数的名称与含义并不相符。例如tcp_orphan_retries参数中有orphan孤儿,却同时对非孤儿连接也生效。而且,错误地配置这些参数,不只无法针对高并发场景提升性能,还会降低资源的使用效率,甚至引发数据错误。
|
||||
|
||||
这一讲,我们将基于四次挥手的流程,介绍Linux下的优化方法。
|
||||
|
||||
## 四次挥手的流程
|
||||
|
||||
你想没想过,为什么建立连接是三次握手,而关闭连接需要四次挥手呢?
|
||||
|
||||
这是因为TCP不允许连接处于半打开状态时就单向传输数据,所以在三次握手建立连接时,服务器会把ACK和SYN放在一起发给客户端,其中,ACK用来打开客户端的发送通道,SYN用来打开服务器的发送通道。这样,原本的四次握手就降为三次握手了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/51/74ac4e70ef719f19270c08201fb53a51.png" alt="">
|
||||
|
||||
但是当连接处于半关闭状态时,TCP是允许单向传输数据的。为便于下文描述,**接下来我们把先关闭连接的一方叫做主动方,后关闭连接的一方叫做被动方。**当主动方关闭连接时,被动方仍然可以在不调用close函数的状态下,长时间发送数据,此时连接处于半关闭状态。这一特性是TCP的双向通道互相独立所致,却也使得关闭连接必须通过四次挥手才能做到。
|
||||
|
||||
**互联网中往往服务器才是主动关闭连接的一方。**这是因为,HTTP消息是单向传输协议,服务器接收完请求才能生成响应,发送完响应后就会立刻关闭TCP连接,这样及时释放了资源,能够为更多的用户服务。
|
||||
|
||||
这就使得服务器的优化策略变得复杂起来。一方面,由于被动方有多种应对策略,从而增加了主动方的处理分支。另一方面,服务器同时为成千上万个用户服务,任何错误都会被庞大的用户数放大。所以对主动方的关闭连接参数调整时,需要格外小心。
|
||||
|
||||
了解了这一点之后,我们再来看四次挥手的流程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/b7/e2ef1347b3b4590da431dc236d9239b7.png" alt="">
|
||||
|
||||
**其实四次挥手只涉及两种报文:FIN和ACK。**FIN就是Finish结束连接的意思,谁发出FIN报文,就表示它将不再发送任何数据,关闭这一方向的传输通道。ACK是Acknowledge确认的意思,它用来通知对方:你方的发送通道已经关闭。
|
||||
|
||||
当主动方关闭连接时,会发送FIN报文,此时主动方的连接状态由ESTABLISHED变为FIN_WAIT1。当被动方收到FIN报文后,内核自动回复ACK报文,连接状态由ESTABLISHED变为CLOSE_WAIT,顾名思义,它在等待进程调用close函数关闭连接。当主动方接收到这个ACK报文后,连接状态由FIN_WAIT1变为FIN_WAIT2,主动方的发送通道就关闭了。
|
||||
|
||||
再来看被动方的发送通道是如何关闭的。当被动方进入CLOSE_WAIT状态时,进程的read函数会返回0,这样开发人员就会有针对性地调用close函数,进而触发内核发送FIN报文,此时被动方连接的状态变为LAST_ACK。当主动方收到这个FIN报文时,内核会自动回复ACK,同时连接的状态由FIN_WAIT2变为TIME_WAIT,Linux系统下大约1分钟后TIME_WAIT状态的连接才会彻底关闭。而被动方收到ACK报文后,连接就会关闭。
|
||||
|
||||
## 主动方的优化
|
||||
|
||||
关闭连接有多种方式,比如进程异常退出时,针对它打开的连接,内核就会发送RST报文来关闭。RST的全称是Reset复位的意思,它可以不走四次挥手强行关闭连接,但当报文延迟或者重复传输时,这种方式会导致数据错乱,所以这是不得已而为之的关闭连接方案。
|
||||
|
||||
安全关闭连接的方式必须通过四次挥手,它由进程调用close或者shutdown函数发起,这二者都会向对方发送FIN报文(shutdown参数须传入SHUT_WR或者SHUT_RDWR才会发送FIN),区别在于close调用后,哪怕对方在半关闭状态下发送的数据到达主动方,进程也无法接收。
|
||||
|
||||
**此时,这个连接叫做孤儿连接,如果你用netstat -p命令,会发现连接对应的进程名为空。而shutdown函数调用后,即使连接进入了FIN_WAIT1或者FIN_WAIT2状态,它也不是孤儿连接,进程仍然可以继续接收数据。**关于孤儿连接的概念,下文调优参数时还会用到。
|
||||
|
||||
主动方发送FIN报文后,连接就处于FIN_WAIT1状态下,该状态通常应在数十毫秒内转为FIN_WAIT2。只有迟迟收不到对方返回的ACK时,才能用netstat命令观察到FIN_WAIT1状态。此时,**内核会定时重发FIN报文,其中重发次数由tcp_orphan_retries参数控制**(注意,orphan虽然是孤儿的意思,该参数却不只对孤儿连接有效,事实上,它对所有FIN_WAIT1状态下的连接都有效),默认值是0,特指8次:
|
||||
|
||||
```
|
||||
net.ipv4.tcp_orphan_retries = 0
|
||||
|
||||
```
|
||||
|
||||
如果FIN_WAIT1状态连接有很多,你就需要考虑降低tcp_orphan_retries的值。当重试次数达到tcp_orphan_retries时,连接就会直接关闭掉。
|
||||
|
||||
**对于正常情况来说,调低tcp_orphan_retries已经够用,但如果遇到恶意攻击,FIN报文根本无法发送出去。**这是由TCP的2个特性导致的。
|
||||
|
||||
- 首先,TCP必须保证报文是有序发送的,FIN报文也不例外,当发送缓冲区还有数据没发送时,FIN报文也不能提前发送。
|
||||
- 其次,TCP有流控功能,当接收方将接收窗口设为0时,发送方就不能再发送数据。所以,当攻击者下载大文件时,就可以通过将接收窗口设为0,导致FIN报文无法发送,进而导致连接一直处于FIN_WAIT1状态。
|
||||
|
||||
解决这种问题的方案是调整tcp_max_orphans参数:
|
||||
|
||||
```
|
||||
net.ipv4.tcp_max_orphans = 16384
|
||||
|
||||
```
|
||||
|
||||
顾名思义,**tcp_max_orphans 定义了孤儿连接的最大数量。**当进程调用close函数关闭连接后,无论该连接是在FIN_WAIT1状态,还是确实关闭了,这个连接都与该进程无关了,它变成了孤儿连接。Linux系统为防止孤儿连接过多,导致系统资源长期被占用,就提供了tcp_max_orphans参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送RST复位报文强制关闭。
|
||||
|
||||
当连接收到ACK进入FIN_WAIT2状态后,就表示主动方的发送通道已经关闭,接下来将等待对方发送FIN报文,关闭对方的发送通道。这时,**如果连接是用shutdown函数关闭的,连接可以一直处于FIN_WAIT2状态。但对于close函数关闭的孤儿连接,这个状态不可以持续太久,而tcp_fin_timeout控制了这个状态下连接的持续时长。**
|
||||
|
||||
```
|
||||
net.ipv4.tcp_fin_timeout = 60
|
||||
|
||||
```
|
||||
|
||||
它的默认值是60秒。这意味着对于孤儿连接,如果60秒后还没有收到FIN报文,连接就会直接关闭。这个60秒并不是拍脑袋决定的,它与接下来介绍的TIME_WAIT状态的持续时间是相同的,我们稍后再来回答60秒的由来。
|
||||
|
||||
TIME_WAIT是主动方四次挥手的最后一个状态。当收到被动方发来的FIN报文时,主动方回复ACK,表示确认对方的发送通道已经关闭,连接随之进入TIME_WAIT状态,等待60秒后关闭,为什么呢?我们必须站在整个网络的角度上,才能回答这个问题。
|
||||
|
||||
TIME_WAIT状态的连接,在主动方看来确实已经关闭了。然而,被动方没有收到ACK报文前,连接还处于LAST_ACK状态。如果这个ACK报文没有到达被动方,被动方就会重发FIN报文。重发次数仍然由前面介绍过的tcp_orphan_retries参数控制。
|
||||
|
||||
如果主动方不保留TIME_WAIT状态,会发生什么呢?此时连接的端口恢复了自由身,可以复用于新连接了。然而,被动方的FIN报文可能再次到达,这既可能是网络中的路由器重复发送,也有可能是被动方没收到ACK时基于tcp_orphan_retries参数重发。这样,**正常通讯的新连接就可能被重复发送的FIN报文误关闭。**保留TIME_WAIT状态,就可以应付重发的FIN报文,当然,其他数据报文也有可能重发,所以TIME_WAIT状态还能避免数据错乱。
|
||||
|
||||
我们再回过头来看看,为什么TIME_WAIT状态要保持60秒呢?这与孤儿连接FIN_WAIT2状态默认保留60秒的原理是一样的,**因为这两个状态都需要保持2MSL时长。MSL全称是Maximum Segment Lifetime,它定义了一个报文在网络中的最长生存时间**(报文每经过一次路由器的转发,IP头部的TTL字段就会减1,减到0时报文就被丢弃,这就限制了报文的最长存活时间)。
|
||||
|
||||
为什么是2 MSL的时长呢?这其实是相当于至少允许报文丢失一次。比如,若ACK在一个MSL内丢失,这样被动方重发的FIN会在第2个MSL内到达,TIME_WAIT状态的连接可以应对。为什么不是4或者8 MSL的时长呢?你可以想象一个丢包率达到百分之一的糟糕网络,连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比。
|
||||
|
||||
**因此,TIME_WAIT和FIN_WAIT2状态的最大时长都是2 MSL,由于在Linux系统中,MSL的值固定为30秒,所以它们都是60秒。**
|
||||
|
||||
虽然TIME_WAIT状态的存在是有必要的,但它毕竟在消耗系统资源,比如TIME_WAIT状态的端口就无法供新连接使用。怎样解决这个问题呢?
|
||||
|
||||
**Linux提供了tcp_max_tw_buckets 参数,当TIME_WAIT的连接数量超过该参数时,新关闭的连接就不再经历TIME_WAIT而直接关闭。**
|
||||
|
||||
```
|
||||
net.ipv4.tcp_max_tw_buckets = 5000
|
||||
|
||||
```
|
||||
|
||||
当服务器的并发连接增多时,相应地,同时处于TIME_WAIT状态的连接数量也会变多,此时就应当调大tcp_max_tw_buckets参数,减少不同连接间数据错乱的概率。
|
||||
|
||||
当然,tcp_max_tw_buckets也不是越大越好,毕竟内存和端口号都是有限的。有没有办法让新连接复用TIME_WAIT状态的端口呢?如果服务器会主动向上游服务器发起连接的话,就可以把tcp_tw_reuse参数设置为1,它允许作为客户端的新连接,在安全条件下使用TIME_WAIT状态下的端口。
|
||||
|
||||
```
|
||||
net.ipv4.tcp_tw_reuse = 1
|
||||
|
||||
```
|
||||
|
||||
当然,要想使tcp_tw_reuse生效,还得把timestamps参数设置为1,它满足安全复用的先决条件(对方也要打开tcp_timestamps ):
|
||||
|
||||
```
|
||||
net.ipv4.tcp_timestamps = 1
|
||||
|
||||
```
|
||||
|
||||
老版本的Linux还提供了tcp_tw_recycle参数,它并不要求TIME_WAIT状态存在60秒,很容易导致数据错乱,不建议设置为1。
|
||||
|
||||
```
|
||||
net.ipv4.tcp_tw_recycle = 0
|
||||
|
||||
```
|
||||
|
||||
所以在Linux 4.12版本后,直接取消了这一参数。
|
||||
|
||||
## 被动方的优化
|
||||
|
||||
当被动方收到FIN报文时,就开启了被动方的四次挥手流程。内核自动回复ACK报文后,连接就进入CLOSE_WAIT状态,顾名思义,它表示等待进程调用close函数关闭连接。
|
||||
|
||||
内核没有权力替代进程去关闭连接,因为若主动方是通过shutdown关闭连接,那么它就是想在半关闭连接上接收数据。**因此,Linux并没有限制CLOSE_WAIT状态的持续时间。**
|
||||
|
||||
当然,大多数应用程序并不使用shutdown函数关闭连接,所以,当你用netstat命令发现大量CLOSE_WAIT状态时,要么是程序出现了Bug,read函数返回0时忘记调用close函数关闭连接,要么就是程序负载太高,close函数所在的回调函数被延迟执行了。此时,我们应当在应用代码层面解决问题。
|
||||
|
||||
由于CLOSE_WAIT状态下,连接已经处于半关闭状态,所以此时进程若要关闭连接,只能调用close函数(再调用shutdown关闭单向通道就没有意义了),内核就会发出FIN报文关闭发送通道,同时连接进入LAST_ACK状态,等待主动方返回ACK来确认连接关闭。
|
||||
|
||||
如果迟迟等不到ACK,内核就会重发FIN报文,重发次数仍然由tcp_orphan_retries参数控制,这与主动方重发FIN报文的优化策略一致。
|
||||
|
||||
至此,由一方主动发起四次挥手的流程就介绍完了。需要你注意的是,**如果被动方迅速调用close函数,那么被动方的ACK和FIN有可能在一个报文中发送,这样看起来,四次挥手会变成三次挥手,这只是一种特殊情况,不用在意。**
|
||||
|
||||
我们再来看一种特例,如果连接双方同时关闭连接,会怎么样?
|
||||
|
||||
此时,上面介绍过的优化策略仍然适用。两方发送FIN报文时,都认为自己是主动方,所以都进入了FIN_WAIT1状态,FIN报文的重发次数仍由tcp_orphan_retries参数控制。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/52/043752a3957d36f4e3c82cd83d472452.png" alt="">
|
||||
|
||||
接下来,双方在等待ACK报文的过程中,都等来了FIN报文。这是一种新情况,所以连接会进入一种叫做CLOSING的新状态,它替代了FIN_WAIT2状态。此时,内核回复ACK确认对方发送通道的关闭,仅己方的FIN报文对应的ACK还没有收到。所以,CLOSING状态与LAST_ACK状态下的连接很相似,它会在适时重发FIN报文的情况下最终关闭。
|
||||
|
||||
## 小结
|
||||
|
||||
我们对这一讲的内容做个小结。
|
||||
|
||||
今天我们讲了四次挥手的流程,你需要根据主动方与被动方的连接状态变化来调整系统参数,使它在特定网络条件下更及时地释放资源。
|
||||
|
||||
四次挥手的主动方,为了应对丢包,允许在tcp_orphan_retries次数内重发FIN报文。当收到ACK报文,连接就进入了FIN_WAIT2状态,此时系统的行为依赖这是否为孤儿连接。
|
||||
|
||||
如果这是close函数关闭的孤儿连接,那么在tcp_fin_timeout秒内没有收到对方的FIN报文,连接就直接关闭,反之shutdown函数关闭的连接则不受此限制。毕竟孤儿连接可能在重发次数内存在数分钟之久,为了应对孤儿连接占用太多的资源,tcp_max_orphans定义了最大孤儿连接的数量,超过时连接就会直接释放。
|
||||
|
||||
当接收到FIN报文,并返回ACK后,主动方的连接进入TIME_WAIT状态。这一状态会持续1分钟,为了防止TIME_WAIT状态占用太多的资源,tcp_max_tw_buckets定义了最大数量,超过时连接也会直接释放。当TIME_WAIT状态过多时,还可以通过设置tcp_tw_reuse和tcp_timestamps为1 ,将TIME_WAIT状态的端口复用于作为客户端的新连接。
|
||||
|
||||
被动关闭的连接方应对非常简单,它在回复ACK后就进入了CLOSE_WAIT状态,等待进程调用close函数关闭连接。因此,出现大量CLOSE_WAIT状态的连接时,应当从应用程序中找问题。当被动方发送FIN报文后,连接就进入LAST_ACK状态,在未等来ACK时,会在tcp_orphan_retries参数的控制下重发FIN报文。
|
||||
|
||||
至此,TCP连接建立、关闭时的性能优化就介绍完了。下一讲,我们将专注在TCP上传输数据时,如何优化内存的使用效率。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,给你留一个思考题。你知道关闭连接时的SO_LINGER选项吗?它希望用四次挥手替代RST关闭连接的方式,防止浏览器没有接收到完整的HTTP响应。请你思考一下,SO_LINGER会怎么影响主动方连接的状态变化?SO_LINGER上的超时时间,是怎样与系统配置参数协作的?欢迎你在留言区与我一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
132
极客时间专栏/系统性能调优必知必会/系统层网络优化/11 | 如何修改TCP缓冲区才能兼顾并发数量与传输速度?.md
Normal file
132
极客时间专栏/系统性能调优必知必会/系统层网络优化/11 | 如何修改TCP缓冲区才能兼顾并发数量与传输速度?.md
Normal file
@@ -0,0 +1,132 @@
|
||||
<audio id="audio" title="11 | 如何修改TCP缓冲区才能兼顾并发数量与传输速度?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a8/aa/a84702c6b4bca2042ce0b5d5ca0f68aa.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
我们在[[第8课]](https://time.geekbang.org/column/article/236921) 中讲了如何从C10K进一步到C10M,不过,这也意味着TCP占用的内存翻了一千倍,服务器的内存资源会非常紧张。
|
||||
|
||||
如果你在Linux系统中用free命令查看内存占用情况,会发现一栏叫做buff/cache,它是系统内存,似乎与应用进程无关。但每当进程新建一个TCP连接,buff/cache中的内存都会上升4K左右。而且,当连接传输数据时,就远不止增加4K内存了。这样,几十万并发连接,就在进程内存外又增加了GB级别的系统内存消耗。
|
||||
|
||||
这是因为TCP连接是由内核维护的,内核为每个连接建立的内存缓冲区,既要为网络传输服务,也要充当进程与网络间的缓冲桥梁。如果连接的内存配置过小,就无法充分使用网络带宽,TCP传输速度就会很慢;如果连接的内存配置过大,那么服务器内存会很快用尽,新连接就无法建立成功。因此,只有深入理解Linux下TCP内存的用途,才能正确地配置内存大小。
|
||||
|
||||
这一讲,我们就来看看,Linux下的TCP缓冲区该如何修改,才能在高并发下维持TCP的高速传输。
|
||||
|
||||
## 滑动窗口是怎样影响传输速度的?
|
||||
|
||||
我们知道,TCP必须保证每一个报文都能够到达对方,它采用的机制就是:报文发出后,必须收到接收方返回的ACK确认报文(Acknowledge确认的意思)。如果在一段时间内(称为RTO,retransmission timeout)没有收到,这个报文还得重新发送,直到收到ACK为止。
|
||||
|
||||
**可见,TCP报文发出去后,并不能立刻从内存中删除,因为重发时还需要用到它。**由于TCP是由内核实现的,所以报文存放在内核缓冲区中,这也是高并发下buff/cache内存增加很多的原因。
|
||||
|
||||
事实上,确认报文被收到的机制非常复杂,它受制于很多因素。我们先来看第一个因素,**速度**。
|
||||
|
||||
如果我们发送一个报文,收到ACK确认后,再发送下一个报文,会有什么问题?显然,发送每个报文都需要经历一个RTT时延(RTT的值可以用ping命令得到)。要知道,因为网络设备限制了报文的字节数,所以每个报文的体积有限。
|
||||
|
||||
比如,以太网报文最大只有1500字节,而发送主机到接收主机间,要经历多个广域网、局域网,其中最小的设备决定了网络报文的最大字节数,在TCP中,这个值叫做MSS(Maximum Segment Size),它通常在1KB左右。如果RTT时延是10ms,那么它们的传送速度最多只有1KB/10ms=100KB/s!可见,这种确认报文方式太影响传输速度了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/a8/8c97985f1ed742b458d0c00c3155aba8.png" alt="">
|
||||
|
||||
**提速的方式很简单,并行地批量发送报文,再批量确认报文即可。**比如,发送一个100MB的文件,如果MSS值为1KB,那么需要发送约10万个报文。发送方大可以同时发送这10万个报文,再等待它们的ACK确认。这样,发送速度瞬间就达到100MB/10ms=10GB/s。
|
||||
|
||||
然而,这引出了另一个问题,接收方有那么强的处理能力吗?**接收方的处理能力**,这是影响确认机制的第二个因素(网络也没有这么强的处理能力,下一讲会介绍应对网络瓶颈的拥塞控制技术)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f9/85/f9e14ba29407da48bf55a7c24c7af585.png" alt="">
|
||||
|
||||
当接收方硬件不如发送方,或者系统繁忙、资源紧张时,是无法瞬间处理这么多报文的。于是,这些报文只能被丢掉,网络效率非常低。怎么限制发送方的速度呢?
|
||||
|
||||
**接收方把它的处理能力告诉发送方,使其限制发送速度即可,这就是滑动窗口的由来。**接收方根据它的缓冲区,可以计算出后续能够接收多少字节的报文,这个数字叫做接收窗口。当内核接收到报文时,必须用缓冲区存放它们,这样剩余缓冲区空间变小,接收窗口也就变小了;当进程调用read函数后,数据被读入了用户空间,内核缓冲区就被清空,这意味着主机可以接收更多的报文,接收窗口就会变大。
|
||||
|
||||
因此,接收窗口并不是恒定不变的,那么怎么把时刻变化的窗口通知给发送方呢?TCP报文头部中的窗口字段,就可以起到通知的作用。
|
||||
|
||||
当发送方从报文中得到接收方的窗口大小时,就明白了最多能发送多少字节的报文,这个数字被称为发送方的发送窗口。如果不考虑下一讲将要介绍的拥塞控制,发送方的发送窗口就是接收方的接收窗口(由于报文有传输时延,t1时刻的接收窗口在t2时刻才能到达发送端,因此这两个窗口并不完全等价)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/f1/9a9385d4e5343285201e0242809c16f1.jpg" alt="">
|
||||
|
||||
从上图中可以看到,窗口字段只有2个字节,因此它最多能表达2<sup>16</sup> 即65535字节大小的窗口(之所以不是65536,是因为窗口可以为0,此时叫做窗口关闭,上一讲提到的关闭连接时让FIN报文发不出去,以致于服务器的连接都处于FIN_WAIT1状态,就是通过窗口关闭技术实现的),这在RTT为10ms的网络中也只能到达6MB/s的最大速度,在当今的高速网络中显然并不够用。
|
||||
|
||||
[RFC1323](https://tools.ietf.org/html/rfc1323) 定义了扩充窗口的方法,但Linux中打开这一功能,需要把tcp_window_scaling配置设为1,此时窗口的最大值可以达到1GB(2<sup>30</sup>)。
|
||||
|
||||
```
|
||||
net.ipv4.tcp_window_scaling = 1
|
||||
|
||||
```
|
||||
|
||||
这样看来,只要进程能及时地调用read函数读取数据,并且接收缓冲区配置得足够大,那么接收窗口就可以无限地放大,发送方也就无限地提升发送速度。很显然,这是不可能的,因为网络的传输能力是有限的,当发送方依据发送窗口,发送超过网络处理能力的报文时,路由器会直接丢弃这些报文。因此,缓冲区的内存并不是越大越好。
|
||||
|
||||
## 带宽时延积如何确定最大传输速度?
|
||||
|
||||
缓冲区到底该设置为多大呢?我们知道,TCP的传输速度,受制于发送窗口与接收窗口,以及网络传输能力。其中,两个窗口由缓冲区大小决定(进程调用read函数是否及时也会影响它)。如果缓冲区大小与网络传输能力匹配,那么缓冲区的利用率就达到了最大值。
|
||||
|
||||
怎样计算出网络传输能力呢?带宽描述了网络传输能力,但它不能直接使用,因为它与窗口或者说缓冲区的计量单位不同。带宽是单位时间内的流量 ,它表达的是速度,比如你家里的宽带100MB/s,而窗口和缓冲区的单位是字节。当网络速度乘以时间才能得到字节数,差的这个时间,这就是网络时延。
|
||||
|
||||
当最大带宽是100MB/s、网络时延是10ms时,这意味着客户端到服务器间的网络一共可以存放100MB/s * 0.01s = 1MB的字节。这个1MB是带宽与时延的乘积,所以它就叫做带宽时延积(缩写为BDP,Bandwidth Delay Product)。这1MB字节存在于飞行中的TCP报文,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了1MB,就一定会让网络过载,最终导致丢包。
|
||||
|
||||
由于发送缓冲区决定了发送窗口的上限,而发送窗口又决定了已发送但未确认的飞行报文的上限,因此,发送缓冲区不能超过带宽时延积,因为超出的部分没有办法用于有效的网络传输,且飞行字节大于带宽时延积还会导致丢包;而且,缓冲区也不能小于带宽时延积,否则无法发挥出高速网络的价值。
|
||||
|
||||
## 怎样调整缓冲区去适配滑动窗口?
|
||||
|
||||
这么看来,我们只要把缓冲区设置为带宽时延积不就行了吗?**比如,当我们做socket网络编程时,通过设置socket的SO_SNDBUF属性,就可以设定缓冲区的大小。**
|
||||
|
||||
然而,这并不是个好主意,因为不是每一个请求都能够达到最大传输速度,比如请求的体积太小时,在**慢启动**(下一讲会谈到)的影响下,未达到最大速度时请求就处理完了。再比如网络本身也会有波动,未必可以一直保持最大速度。
|
||||
|
||||
**因此,时刻让缓冲区保持最大,太过浪费内存了。**
|
||||
|
||||
到底该如何设置缓冲区呢?
|
||||
|
||||
我们可以使用Linux的**缓冲区动态调节功能**解决上述问题。其中,缓冲区的调节范围是可以设置的。先来看发送缓冲区,它的范围通过tcp_wmem配置:
|
||||
|
||||
```
|
||||
net.ipv4.tcp_wmem = 4096 16384 4194304
|
||||
|
||||
```
|
||||
|
||||
其中,第1个数值是动态范围的下限,第3个数值是动态范围的上限。而中间第2个数值,则是初始默认值。
|
||||
|
||||
发送缓冲区完全根据需求自行调整。比如,一旦发送出的数据被确认,而且没有新的数据要发送,就可以把发送缓冲区的内存释放掉。而接收缓冲区的调整就要复杂一些,先来看设置接收缓冲区范围的tcp_rmem:
|
||||
|
||||
```
|
||||
net.ipv4.tcp_rmem = 4096 87380 6291456
|
||||
|
||||
```
|
||||
|
||||
它的数值与tcp_wmem类似,第1、3个值是范围的下限和上限,第2个值是初始默认值。发送缓冲区自动调节的依据是待发送的数据,接收缓冲区由于只能被动地等待接收数据,它该如何自动调整呢?
|
||||
|
||||
**可以依据空闲系统内存的数量来调节接收窗口。**如果系统的空闲内存很多,就可以把缓冲区增大一些,这样传给对方的接收窗口也会变大,因而对方的发送速度就会通过增加飞行报文来提升。反之,内存紧张时就会缩小缓冲区,这虽然会减慢速度,但可以保证更多的并发连接正常工作。
|
||||
|
||||
发送缓冲区的调节功能是自动开启的,而接收缓冲区则需要配置tcp_moderate_rcvbuf为1来开启调节功能:
|
||||
|
||||
```
|
||||
net.ipv4.tcp_moderate_rcvbuf = 1
|
||||
|
||||
```
|
||||
|
||||
接收缓冲区调节时,怎么判断空闲内存的多少呢?这是通过tcp_mem配置完成的:
|
||||
|
||||
```
|
||||
net.ipv4.tcp_mem = 88560 118080 177120
|
||||
|
||||
```
|
||||
|
||||
tcp_mem的3个值,是Linux判断系统内存是否紧张的依据。当TCP内存小于第1个值时,不需要进行自动调节;在第1和第2个值之间时,内核开始调节接收缓冲区的大小;大于第3个值时,内核不再为TCP分配新内存,此时新连接是无法建立的。
|
||||
|
||||
在高并发服务器中,为了兼顾网速与大量的并发连接,**我们应当保证缓冲区的动态调整上限达到带宽时延积,而下限保持默认的4K不变即可。而对于内存紧张的服务而言,调低默认值是提高并发的有效手段。**
|
||||
|
||||
同时,如果这是网络IO型服务器,那么,**调大tcp_mem的上限可以让TCP连接使用更多的系统内存,这有利于提升并发能力。**需要注意的是,tcp_wmem和tcp_rmem的单位是字节,而tcp_mem的单位是页面大小。而且,**千万不要在socket上直接设置SO_SNDBUF或者SO_RCVBUF,这样会关闭缓冲区的动态调整功能。**
|
||||
|
||||
## 小结
|
||||
|
||||
我们对这一讲的内容做个小结。
|
||||
|
||||
实现高并发服务时,由于必须把大部分内存用在网络传输上,所以除了关注应用内存的使用,还必须关注TCP内核缓冲区的内存使用情况。
|
||||
|
||||
TCP使用ACK确认报文实现了可靠性,又依赖滑动窗口既提升了发送速度也兼顾了接收方的处理能力。然而,默认的滑动窗口最大只能到65KB,要想提升发送速度必须提升滑动窗口的上限,在Linux下是通过设置tcp_window_scaling为1做到的。
|
||||
|
||||
滑动窗口定义了飞行报文的最大字节数,当它超过带宽时延积时,就会发生丢包。而当它小于带宽时延积时,就无法让TCP的传输速度达到网络允许的最大值。因此,滑动窗口的设计,必须参考带宽时延积。
|
||||
|
||||
内核缓冲区决定了滑动窗口的上限,但我们不能通过socket的SO_SNFBUF等选项直接把缓冲区大小设置为带宽时延积,因为TCP不会一直维持在最高速上,过大的缓冲区会减少并发连接数。Linux带来的缓冲区自动调节功能非常有效,我们应当把缓冲区的上限设置为带宽时延积。其中,发送缓冲区的调节功能是自动打开的,而接收缓冲区需要把tcp_moderate_rcvbuf设置为1来开启,其中调节的依据根据tcp_mem而定。
|
||||
|
||||
这样高效地配置内存后,既能够最大程度地保持并发性,也能让资源充裕时连接传输速度达到最大值。这一讲我们谈了内核缓冲区对传输速度的影响,下一讲我们再来看如何调节发送速度以匹配不同的网络能力。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,请你观察下Linux系统下,连接建立时、发送接收数据时,buff/cache内存的变动情况。用我们这一讲介绍的原理,解释系统内存的变化现象。欢迎你在留言区与我沟通互动。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
144
极客时间专栏/系统性能调优必知必会/系统层网络优化/12 | 如何调整TCP拥塞控制的性能?.md
Normal file
144
极客时间专栏/系统性能调优必知必会/系统层网络优化/12 | 如何调整TCP拥塞控制的性能?.md
Normal file
@@ -0,0 +1,144 @@
|
||||
<audio id="audio" title="12 | 如何调整TCP拥塞控制的性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/4c/b79a03a9a6ba059cfe329e3f4ccccb4c.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
上一讲我们谈到接收主机的处理能力不足时,是通过滑动窗口来减缓对方的发送速度。这一讲我们来看看,当网络处理能力不足时又该如何优化TCP的性能。
|
||||
|
||||
如果你阅读过TCP协议相关的书籍,一定看到过慢启动、拥塞控制等名词。这些概念似乎离应用开发者很远,然而,如果没有拥塞控制,整个网络将会锁死,所有消息都无法传输。
|
||||
|
||||
而且,如果你在开发分布式集群中的高并发服务,理解拥塞控制的工作原理,就可以在内核的TCP层,提升所有进程的网络性能。比如,你可能听过,2013年谷歌把初始拥塞窗口从3个MSS(最大报文长度)左右提升到10个MSS,将Web站点的网络性能提升了10%以上,而有些高速CDN站点,甚至把初始拥塞窗口提升到70个MSS。
|
||||
|
||||
特别是,近年来谷歌提出的BBR拥塞控制算法已经应用在高版本的Linux内核中,从它在YouTube上的应用可以看到,在高性能站点上网络时延有20%以上的降低,传输带宽也有提高。
|
||||
|
||||
Linux允许我们调整拥塞控制算法,但是,正确地设置参数,还需要深入理解拥塞控制对TCP连接的影响。这一讲我们将沿着网络如何影响发送速度这条线,看看如何调整Linux下的拥塞控制参数。
|
||||
|
||||
## 慢启动阶段如何调整初始拥塞窗口?
|
||||
|
||||
上一讲谈到,只要接收方的读缓冲区足够大,就可以通过报文中的接收窗口,要求对方更快地发送数据。然而,网络的传输速度是有限的,它会直接丢弃超过其处理能力的报文。而发送方只有在重传定时器超时后,才能发现超发的报文被网络丢弃了,发送速度提不上去。更为糟糕的是,如果网络中的每个连接都按照接收窗口尽可能地发送更多的报文时,就会形成恶性循环,最终超高的网络丢包率会使得每个连接都无法发送数据。
|
||||
|
||||
解决这一问题的方案叫做拥塞控制,它包括4个阶段,我们首先来看TCP连接刚建立时的慢启动阶段。由于TCP连接会穿越许多网络,所以最初并不知道网络的传输能力,为了避免发送超过网络负载的报文,TCP只能先调低发送窗口(关于发送窗口,你可以参考[[第11讲]](https://time.geekbang.org/column/article/239176)),减少飞行中的报文来让发送速度变慢,这也是“慢启动”名字的由来。
|
||||
|
||||
让发送速度变慢是通过引入拥塞窗口(全称为congestion window,缩写为CWnd,类似地,接收窗口叫做rwnd,发送窗口叫做swnd)实现的,它用于避免网络出现拥塞。上一讲我们说过,如果不考虑网络拥塞,发送窗口就等于对方的接收窗口,而考虑了网络拥塞后,发送窗口则应当是拥塞窗口与对方接收窗口的最小值:
|
||||
|
||||
```
|
||||
swnd = min(cwnd, rwnd)
|
||||
|
||||
```
|
||||
|
||||
这样,发送速度就综合考虑了接收方和网络的处理能力。
|
||||
|
||||
虽然窗口的计量单位是字节,但为了方便理解,通常我们用MSS作为描述窗口大小的单位,其中MSS是TCP报文的最大长度。
|
||||
|
||||
如果初始拥塞窗口只有1个MSS,当MSS是1KB,而RTT时延是100ms时,发送速度只有10KB/s。所以,当没有发生拥塞时,拥塞窗口必须快速扩大,才能提高互联网的传输速度。因此,慢启动阶段会以指数级扩大拥塞窗口(扩大规则是这样的:发送方每收到一个ACK确认报文,拥塞窗口就增加1个MSS),比如最初的初始拥塞窗口(也称为initcwnd)是1个MSS,经过4个RTT就会变成16个MSS。
|
||||
|
||||
虽然指数级提升发送速度很快,但互联网中的很多资源体积并不大,多数场景下,在传输速度没有达到最大时,资源就已经下载完了。下图是2010年Google对Web对象大小的CDF累积分布统计,大多数对象在10KB左右。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1a/23/1a93f6622d30ef5a138b37ce7f94e323.png" alt="" title="图片来源:《An Argument for Increasing TCP's Initial Contestion Window》">
|
||||
|
||||
这样,当MSS是1KB时,多数HTTP请求至少包含10个报文,即使以指数级增加拥塞窗口,也需要至少4个RTT才能传输完,参见下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/86/5c/865b14ad43c828fdf494b541bb810f5c.png" alt="">
|
||||
|
||||
因此,2013年TCP的初始拥塞窗口调整到了10个MSS(参见[RFC6928](https://tools.ietf.org/html/rfc6928)),这样1个RTT内就可以传输10KB的请求。然而,如果你需要传输的对象体积更大,BDP带宽时延积很大时,完全可以继续提高初始拥塞窗口的大小。下图是2014年、2017年全球主要CDN厂商初始拥塞窗口的变化,可见,随着网速的增加,初始拥塞窗口也变得更大了。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/20/d2/20bdd4477ed2d837d398a3b43020abd2.png" alt="" title="图片来源:https://blog.imaginea.com/look-at-tcp-initcwnd-cdns/">](https://blog.imaginea.com/look-at-tcp-initcwnd-cdns/)
|
||||
|
||||
因此,你可以根据网络状况和传输对象的大小,调整初始拥塞窗口的大小。调整前,先要清楚你的服务器现在的初始拥塞窗口是多大。你可以通过ss命令查看当前拥塞窗口:
|
||||
|
||||
```
|
||||
# ss -nli|fgrep cwnd
|
||||
cubic rto:1000 mss:536 cwnd:10 segs_in:10621866 lastsnd:1716864402 lastrcv:1716864402 lastack:1716864402
|
||||
|
||||
```
|
||||
|
||||
再通过ip route change命令修改初始拥塞窗口:
|
||||
|
||||
```
|
||||
# ip route | while read r; do
|
||||
ip route change $r initcwnd 10;
|
||||
done
|
||||
|
||||
```
|
||||
|
||||
当然,更大的初始拥塞窗口以及指数级的提速,连接很快就会遭遇网络拥塞,从而导致慢启动阶段的结束。
|
||||
|
||||
## 出现网络拥塞时该怎么办?
|
||||
|
||||
以下3种场景都会导致慢启动阶段结束:
|
||||
|
||||
1. 通过定时器明确探测到了丢包;
|
||||
1. 拥塞窗口的增长到达了慢启动阈值ssthresh(全称为slow start threshold),也就是之前发现网络拥塞时的窗口大小;
|
||||
1. 接收到重复的ACK报文,可能存在丢包。
|
||||
|
||||
我们先来看第1种场景,在规定时间内没有收到ACK报文,这说明报文丢失了,网络出现了严重的拥塞,必须先降低发送速度,再进入拥塞避免阶段。不同的拥塞控制算法降低速度的幅度并不相同,比如CUBIC算法会把拥塞窗口降为原先的0.8倍(也就是发送速度降到0.8倍)。此时,我们知道了多大的窗口会导致拥塞,因此可以把慢启动阈值设为发生拥塞前的窗口大小。
|
||||
|
||||
再看第2种场景,虽然还没有发生丢包,但发送方已经达到了曾经发生网络拥塞的速度(拥塞窗口达到了慢启动阈值),接下来发生拥塞的概率很高,所以进入**拥塞避免阶段,此时拥塞窗口不能再以指数方式增长,而是要以线性方式增长**。接下来,拥塞窗口会以每个RTT增加1个MSS的方式,代替慢启动阶段每收到1个ACK就增加1个MSS的方式。这里可能有同学会有疑问,在第1种场景发生前,慢启动阈值是多大呢?事实上,[RFC5681](https://tools.ietf.org/html/rfc5681#page-5) 建议最初的慢启动阈值尽可能的大,这样才能在第1、3种场景里快速发现网络瓶颈。
|
||||
|
||||
第3种场景最为复杂。我们知道,TCP传输的是字节流,而“流”是天然有序的。因此,当接收方收到不连续的报文时,就可能发生报文丢失或者延迟,等待发送方超时重发太花时间了,为了缩短重发时间,**快速重传算法便应运而生。**
|
||||
|
||||
当连续收到3个重复ACK时,发送方便得到了网络发生拥塞的明确信号,通过重复ACK报文的序号,我们知道丢失了哪个报文,这样,不等待定时器的触发,立刻重发丢失的报文,可以让发送速度下降得慢一些,这就是快速重传算法。
|
||||
|
||||
出现拥塞后,发送方会缩小拥塞窗口,再进入前面提到的拥塞避免阶段,用线性速度慢慢增加拥塞窗口。然而,**为了平滑地降低速度,发送方应当先进入快速恢复阶段,在失序报文到达接收方后,再进入拥塞避免阶段。**
|
||||
|
||||
那什么是快速恢复呢?我们不妨把网络看成一个容器(上一讲中说过它可以容纳BDP字节的报文),每当接收方从网络中取出一个报文,发送方就可以增加一个报文。当发送方接收到重复ACK时,可以推断有失序报文离开了网络,到达了接收方的缓冲区,因此可以再多发送一个报文。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/92/d9/92980476c93766887cc260f03c5d50d9.png" alt="">
|
||||
|
||||
这里你要注意:第6个报文在慢启动阶段丢失,接收方收到失序的第7个报文会触发快速重传算法,它必须立刻返回ACK6。而发送方接收到第1个重复ACK6报文时,就从慢启动进入了快速重传阶段,**此刻的重复ACK不会扩大拥塞窗口。**当连续收到3个ACK6时,发送方会重发报文6,并把慢启动阈值和拥塞窗口都降到之前的一半:3个MSS,再进入快速恢复阶段。按照规则,由于收到3个重复ACK,所以拥塞窗口会增加3个MSS。之后收到的2个ACK,让拥塞窗口增加到了8个MSS,直到收到期待的ACK12,发送方才会进入拥塞避免阶段。
|
||||
|
||||
慢启动、拥塞避免、快速重传、快速恢复,共同构成了拥塞控制算法。Linux上提供了更改拥塞控制算法的配置,你可以通过tcp_available_congestion_control配置查看内核支持的算法列表:
|
||||
|
||||
```
|
||||
net.ipv4.tcp_available_congestion_control = cubic reno
|
||||
|
||||
```
|
||||
|
||||
再通过tcp_congestion_control配置选择一个具体的拥塞控制算法:
|
||||
|
||||
```
|
||||
net.ipv4.tcp_congestion_control = cubic
|
||||
|
||||
```
|
||||
|
||||
但有件事你得清楚,拥塞控制是控制网络流量的算法,主机间会互相影响,在生产环境更改之前必须经过完善的测试。
|
||||
|
||||
## 基于测量的拥塞控制算法
|
||||
|
||||
上文介绍的是传统拥塞控制算法,它是以丢包作为判断拥塞的依据。然而,网络刚出现拥塞时并不会丢包,而真的出现丢包时,拥塞已经非常严重了。如下图所示,像路由器这样的网络设备,都会有缓冲队列应对突发的、超越处理能力的流量:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/85/4732f8f97aefcb26334f4e7d1d096185.png" alt="">
|
||||
|
||||
当缓冲队列为空时,传输速度最快。一旦队列开始积压,每个报文的传输时间需要增加排队时间,网速就变慢了。而当队列溢出时,才会出现丢包,基于丢包的拥塞控制算法在这个时间点进入拥塞避免阶段,显然太晚了。因为升高的网络时延降低了用户体验,而且从丢包到重发这段时间,带宽也会出现下降。
|
||||
|
||||
进行拥塞控制的最佳时间点,是缓冲队列刚出现积压的时刻,**此时,网络时延会增高,但带宽维持不变,这两个数值的变化可以给出明确的拥塞信号**,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/ca/2cbda9079294ed5da6617f0f0e83acca.png" alt="" title="图片来源网络:传输速度_RTT与飞行报文的关系">
|
||||
|
||||
这种以测量带宽、时延来确定拥塞的方法,在丢包率较高的网络中应用效果尤其好。2016年Google推出的BBR算法(全称Bottleneck Bandwidth and Round-trip propagation time),就是测量驱动的拥塞控制算法,它在YouTube站点上应用后使得网络时延下降了20%以上,传输带宽也有5%左右的提升。
|
||||
|
||||
当然,测量驱动的拥塞算法并没有那么简单,因为网络会波动,线路也会变化,算法必须及时地响应网络变化,这里不再展开算法细节,你可以在我的[这篇博客](https://www.taohui.pub/2019/08/07/%e4%b8%80%e6%96%87%e8%a7%a3%e9%87%8a%e6%b8%85%e6%a5%9agoogle-bbr%e6%8b%a5%e5%a1%9e%e6%8e%a7%e5%88%b6%e7%ae%97%e6%b3%95%e5%8e%9f%e7%90%86/)中找到BBR算法更详细的介绍。
|
||||
|
||||
Linux 4.9版本之后都支持BBR算法,开启BBR算法仍然使用tcp_congestion_control配置:
|
||||
|
||||
```
|
||||
net.ipv4.tcp_congestion_control=bbr
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
我们对这一讲的内容做个小结。
|
||||
|
||||
当TCP连接建立成功后,拥塞控制算法就会发生作用,首先进入慢启动阶段。决定连接此时网速的是初始拥塞窗口,Linux上可以通过route ip change命令修改它。通常,在带宽时延积较大的网络中,应当调高初始拥塞窗口。
|
||||
|
||||
丢包以及重复的ACK都是明确的拥塞信号,此时,发送方就会调低拥塞窗口减速,同时修正慢启动阈值。这样,将来再次到达这个速度时,就会自动进入拥塞避免阶段,用线性速度代替慢启动阶段的指数速度提升窗口大小。
|
||||
|
||||
当然,重复ACK意味着发送方可以提前重发丢失报文,快速重传算法定义了这一行为。同时,为了使得重发报文的过程中,发送速度不至于出现断崖式下降,TCP又定义了快速恢复算法,发送方在报文重新变得有序后,结束快速恢复进入拥塞避免阶段。
|
||||
|
||||
但以丢包作为网络拥塞的信号往往为时已晚,于是以BBR算法为代表的测量型拥塞控制算法应运而生。当飞行中报文数量不变,而网络时延升高时,就说明网络中的缓冲队列出现了积压,这是进行拥塞控制的最好时机。Linux高版本支持BBR算法,你可以通过tcp_congestion_control配置更改拥塞控制算法。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,请你思考下,快速恢复阶段的拥塞窗口,在报文变得有序后反而会缩小,这是为什么?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
133
极客时间专栏/系统性能调优必知必会/系统层网络优化/13 | 实战:单机如何实现管理百万主机的心跳服务?.md
Normal file
133
极客时间专栏/系统性能调优必知必会/系统层网络优化/13 | 实战:单机如何实现管理百万主机的心跳服务?.md
Normal file
@@ -0,0 +1,133 @@
|
||||
<audio id="audio" title="13 | 实战:单机如何实现管理百万主机的心跳服务?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5a/3b/5a3eeb1aa7620126176097d6fa78a23b.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
这一讲我们将结合前12讲,以一个可管理百万主机集群的心跳服务作为实战案例,看看所有高性能服务的设计思路。
|
||||
|
||||
首先解释下什么是心跳服务。集群中的主机如果宕机,那么管理服务必须及时发现,并做相应的容灾处理,比如将宕机主机的业务迁移到新的虚拟机上等等。怎么做到及时发现呢?可以要求每台主机定时上报心跳包,考虑到网络报文的延迟,如果管理服务在几个上报周期内未收到心跳,则认为主机宕机。当新主机加入集群后,心跳服务也可以及时识别并告知管理服务。
|
||||
|
||||
这就是心跳服务要解决的核心问题,虽然很简单,可是如果集群规模达到百万台虚拟机或者微服务进程,这就不再简单了。多核CPU、内存使用效率、网络带宽时延积等都必须纳入你的考虑,因为此时心跳包占用的网络带宽已经接近网卡上限,仅调动一颗CPU的计算力去处理就会大量丢包,百万级的对象、网络连接也很容易造成内存OOM。甚至判断宕机的算法都要重新设计,降低时间复杂度后才能够应对超大集群的心跳管理。
|
||||
|
||||
解决这种集群规模下的性能问题,需要深入掌握底层基础知识,用系统化的全局思维去解决问题,这也是程序员薪资差异的重要分水岭。接下来,我们先实现更高效的核心算法,再设计高并发服务的架构,最后再来看传输层协议的选择。
|
||||
|
||||
## 如何设计更快的宕机判断算法?
|
||||
|
||||
通过心跳包找到宕机的主机需要一套算法,比如用for循环做一次遍历,找到停止上报心跳的主机就可以实现,然而正如[[第 3 讲]](https://time.geekbang.org/column/article/232351) 所说,**当管理的对象数量级很大时,算法复杂度会严重影响程序性能**,遍历算法此时并不可取。我们先分析下这个算法的时间复杂度。
|
||||
|
||||
如果用红黑树(这里用红黑树是因为它既支持遍历,也可以实现对数时间复杂度的查询操作)存放主机及其最近一次上报时间,那么,新主机上报心跳被发现的流程,时间复杂度仅为O(logN),这是查询红黑树的成本。寻找宕机服务的流程,需要对红黑树做全量遍历,用当前时间去比较每个主机的上次心跳时间,时间复杂度就是O(N)!
|
||||
|
||||
如果业务对时间灵敏度要求很高,就意味着需要频繁地执行O(N)级的遍历,当N也就是主机数量很大时,耗时就很可观了。而且寻找宕机服务和接收心跳包是两个流程,如果它们都在单线程中执行,那么寻找宕机服务的那段时间就不能接收心跳包,会导致丢包!如果使用多线程并发执行,因为两个流程都需要操作红黑树,所以要使用到互斥锁,而当这两个流程争抢锁的频率很高时,性能也会急剧下降。
|
||||
|
||||
**其实这个算法的根本问题在于,判断宕机的流程做了大量的重复工作。**比如,主机每隔1秒上报一次心跳,而考虑到网络可能丢包,故5秒内失去心跳就认为宕机,这种情况下,如果主机A在第10秒时失去心跳,那么第11、12、13、14这4秒对主机A的遍历,都是多余的,只有第15秒对主机A的遍历才有意义。于是,每次遍历平均浪费了4/5的计算量。
|
||||
|
||||
如何设计快速的宕机判断算法呢?其实,这是一个从一堆主机中寻找宕机服务的信息题。**根据香农的理论,引入更多的信息,才能减少不确定性降低信息熵,从而减少计算量。就像心跳包间是有时间顺序的,上面的宕机判断算法显然忽略了接收到它们的顺序。**比如主机A的上次心跳包距现在4秒了,而主机B距现在只有1秒,显然不应同等对待。
|
||||
|
||||
于是,我们引入存放心跳包的先入先出队列,这就保存了心跳包的时序关系。新的心跳包进入队列尾部,而老的心跳包则从队列首部退出,这样,寻找宕机服务时,只要看队列首部最老的心跳包,距现在是否超过5秒,如果超过5秒就认定宕机,同时把它取出队列,否则证明队列中不存在宕机服务,维持队列不变。
|
||||
|
||||
当然,这里并没有解决如何发现新主机的问题。我们还需要一个能够执行高效查询的容器,存放所有主机及其状态。红黑树虽然不慢,但我们不再需要遍历容器,所以可以选择更快的、查询时间复杂度为O(1)的哈希表存放主机信息(非标哈希表的实现参见[[第 3 讲]](https://time.geekbang.org/column/article/232351))。如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/32/41/322fff00d232ebc1c85694babfb37541.png" alt="">
|
||||
|
||||
当然,队列中的心跳包并不是只能从队首删除,否则判断宕机流程的时间复杂度仍然是O(N)。实际上,每当收到心跳包时,如果对应主机的上一个心跳包还在队列中,那么可以直接把它从队列中删除。显然,计算在线主机何时宕机,只需要最新的心跳包,老的心跳包没有必要存在。因此,这个队列为每个主机仅保留最新的那个心跳包。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/97/94/97cae81094f213896989158cf9baf594.png" alt="">
|
||||
|
||||
这样,判断宕机的速度会非常快,它的计算量等于实际发生宕机的主机数量。同时,接收心跳包并发现新主机的流程,因为只需要做一次哈希表查询,时间复杂度也只有O(1)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/d9/6ba31e4046ba0f578659bf423e4553d9.png" alt="">
|
||||
|
||||
这样,新算法通过**以空间换时间**的思想,虽然使用了更加占用空间的哈希表,并新增了有序队列容器,但将宕机和新主机发现这两个流程都优化到了常量级的时间复杂度。尤其是宕机流程的计算量非常小,它仅与实际宕机服务的数量有关,这就允许我们将宕机判断流程插入到心跳包的处理流程中,以微观上的分时任务实现宏观上的并发,同时也避免了对哈希表的加锁。
|
||||
|
||||
## 如何设计高并发架构?
|
||||
|
||||
有了核心算法,还需要充分利用服务器资源的架构,才能实现高并发。
|
||||
|
||||
一颗1GHZ主频的CPU,意味着一秒钟只有10亿个时钟周期可以工作,如果心跳服务每秒接收到100万心跳包,就要求它必须在1000个时钟周期内处理完一个心跳包。这无法做到,因为每一个汇编指令的执行需要多个时钟周期(参见[CPI](https://en.wikipedia.org/wiki/Cycles_per_instruction)),一条高级语言的语句又由多条汇编指令构成,而中间件提供的反序列化等函数又需要很多条语句才能完成。另外,内核从网卡上读取报文,执行协议分析需要的时钟周期也要算到这1000个时钟周期里。
|
||||
|
||||
因此,选择只用一颗CPU为核心的单线程开发模式,一定会出现计算力不足,不能及时接收报文从而使得缓冲区溢出的问题,最终导致大量丢包。所以,我们必须选择多线程或者多进程开发模式。多进程之间干扰更小,但内存不是共享的,数据同步较为困难,因此案例中我们还是选择多线程开发模式。
|
||||
|
||||
使用多线程后我们需要解决3个问题。
|
||||
|
||||
第一是负载均衡,我们应当把心跳包尽量均匀分配到不同的工作线程上处理。比如,接收网络报文的线程基于主机名或者IP地址,用哈希算法将心跳包分发给工作线程处理,这样每个工作线程只处理特定主机的心跳,相互间不会互相干扰,从而可以无锁编程。
|
||||
|
||||
第二是多线程同步。分发线程与工作线程间可以采用生产者-消费者模型传递心跳包,然而多线程间传递数据要加锁,为了减少争抢锁对系统资源的消耗,需要做到以下两点:
|
||||
|
||||
- 由于工作线程多过分发线程(接收心跳包消耗的资源更少),所以每个工作线程都配发独立的缓冲队列及操作队列的互斥锁;
|
||||
- 为避免线程执行主动切换,必须使用自旋锁,关于锁的选择你可以看[[第 6 讲]](https://time.geekbang.org/column/article/234548)。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/76/2726b6c9b73325583f8491a822a22476.png" alt="">
|
||||
|
||||
第三要解决CPU亲和性问题。从[[第 1 讲]](https://time.geekbang.org/column/article/232351) 我们可以看到,CPU缓存对计算速度的影响很大,如果线程频繁地切换CPU会导致缓存命中率下降,降低性能,此时将线程绑定到特定的CPU就是一个解决方案(NUMA架构也会对CPU亲和性产生影响,这里略过)。
|
||||
|
||||
这样,通过上述的多线程架构就可以有效地使用CPU。当然,除了CPU,内存的使用效率也很重要。[[第2讲]](https://time.geekbang.org/column/article/230221) 中我们提到,TCMalloc相比Linux默认的PtMalloc2内存池,在多线程下分配小块内存的速度要快得多,所以对于心跳服务应当改用TCMalloc申请内存。而且,如果心跳包对象的格式已经固定,你还可以建立一个心跳包资源池,循环往复的使用,这进一步减少了构造、销毁心跳包对象所消耗的计算力。
|
||||
|
||||
由于服务重启后一个心跳周期内就可以获得所有心跳包,所以并不需要将数据持久化到磁盘上。如果你想进一步了解磁盘优化,可以再看下[[第 4 讲]](https://time.geekbang.org/column/article/232676)。
|
||||
|
||||
## 如何选择心跳包网络协议?
|
||||
|
||||
最后我们再来看看心跳包的协议该选择TCP还是UDP实现。
|
||||
|
||||
网络报文的长度是受限的,[MTU](https://zh.wikipedia.org/wiki/%E6%9C%80%E5%A4%A7%E4%BC%A0%E8%BE%93%E5%8D%95%E5%85%83)(Maximum Transmission Unit)定义了最大值。比如以太网中MTU是1500字节,如果TCP或者UDP试图传送大于1500字节的报文,IP协议就会把报文拆分后再发到网络中,并在接收方组装回原来的报文。然而,IP协议并不擅长做这件事,拆包组包的效率很低,因此TCP协议宁愿自己拆包(详见[[第 11 讲]](https://time.geekbang.org/column/article/239176))。
|
||||
|
||||
所以,如果心跳包长度小于MTU,那么UDP协议是最佳选择。如果心跳包长度大于MTU,那么最好选择TCP协议,面对复杂的TCP协议,还需要解决以下问题。
|
||||
|
||||
首先,一台服务器到底能同时建立多少TCP连接?要回答这个问题,得先从TCP四元组谈起,它唯一确定一个TCP连接。TCP四元组分别是<源IP、目的IP、源端口、目的端口>,其中前两者在IP头部中,后两者在TCP头部中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/2f/e05b4dcffa30fe5ec5a3a85511f0db2f.png" alt="">
|
||||
|
||||
由于IPv4地址为4个字节(参见[[第 7 讲]](https://time.geekbang.org/column/article/235302))、端口为2个字节,所以当服务器IP地址和监听端口固定时,并发连接数的上限则是2<sup>(32+16)</sup>。
|
||||
|
||||
当然,这么高的并发连接需要很多条件,其中之一就是增加单个进程允许打开的最大句柄数(包括操作系统允许的最大句柄数/proc/sys/fs/file-nr),因为Linux下每个连接都要用掉一个文件句柄。当然,作为客户端的主机如果想用足2<sup>16</sup> 端口,还得修改ip_local_port_range配置,扩大客户端的端口范围:
|
||||
|
||||
```
|
||||
net.ipv4.ip_local_port_range = 32768 60999
|
||||
|
||||
```
|
||||
|
||||
其次,基于TCP协议实现百万级别的高并发,必须使用基于事件驱动的全异步开发模式(参见[[第 8 讲]](https://time.geekbang.org/column/article/236921))。而且,TCP协议的默认配置并没有考虑高并发场景,所以我们还得在以下4个方面优化TCP协议:
|
||||
|
||||
1. 三次握手建立连接的过程需要优化,详见[[第 9 讲]](https://time.geekbang.org/column/article/237612);
|
||||
1. 四次挥手关闭连接的过程也需要优化,详见[[第 10 讲]](https://time.geekbang.org/column/article/238388);
|
||||
1. 依据网络带宽时延积重新设置TCP缓冲区,详见[[第 11讲]](https://time.geekbang.org/column/article/239176);
|
||||
1. 优化拥塞控制算法,详见[[第 12 讲]](https://time.geekbang.org/column/article/239621)。
|
||||
|
||||
最后还有一个问题需要我们考虑。网络中断时并没有任何信息通知服务器,此时该如何发现并清理服务器上的这些僵死连接呢?KeepAlive机制允许服务器定时向客户端探测连接是否存活。其中,每隔tcp_keepalive_time秒执行一次探测。
|
||||
|
||||
```
|
||||
net.ipv4.tcp_keepalive_time = 7200
|
||||
|
||||
```
|
||||
|
||||
每次探测的最大等待时间是tcp_keepalive_intvl 秒。
|
||||
|
||||
```
|
||||
net.ipv4.tcp_keepalive_intvl = 75
|
||||
|
||||
```
|
||||
|
||||
超时后,内核最多尝试tcp_keepalive_probes次,仍然没有反应就会及时关闭连接。
|
||||
|
||||
```
|
||||
net.ipv4.tcp_keepalive_probes = 9
|
||||
|
||||
```
|
||||
|
||||
当然,如果在应用层通过心跳能及时清理僵死TCP连接,效果会更好。
|
||||
|
||||
从上述优化方案可见,TCP协议的高并发优化方案还是比较复杂的,这也是享受TCP优势时我们必须要付出的代价。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲以我实践过的项目为案例,介绍了高并发服务的设计思路。
|
||||
|
||||
核心算法对性能的影响最大,为了设计出高效的算法,我们必须分析出时间复杂度,充分寻找、利用已知信息,减少算法的计算量。在心跳服务这个案例中,利用好心跳包的时序,就可以把计算宕机的时间复杂度从O(N) 降为O(1)。
|
||||
|
||||
有了好的算法,还需要好的架构,才能高效地调动系统资源。当摩尔定律在CPU频率上失效后,CPU都在向多核发展,所以高性能必须充分使用多核的计算力。此时,我们需要谨慎设计多线程间的负载均衡和数据同步,尽量减少访问共享资源带来的损耗。选择与业务场景匹配的内存池也很重要,对于RPS上百万的服务来说,申请内存的时间不再是一个忽略项。
|
||||
|
||||
选择网络协议时,如果消息长度大于MTU,那么选择TCP更有利,但TCP解决了流控、可靠性等很多问题,优化起来较为困难。对于不要求可靠传输,长度通常不大的心跳包来说,UDP协议通常是更好的选择。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,还是留给你点思考题。你遇到过心跳服务吗?它是怎么设计的?还有哪些优化空间?欢迎你在留言区与我探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
10
极客时间专栏/系统性能调优必知必会/结束语/来领奖啦!你填写毕业问卷了吗?.md
Normal file
10
极客时间专栏/系统性能调优必知必会/结束语/来领奖啦!你填写毕业问卷了吗?.md
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
截至今天,本专栏结课已经有两周的时间了,十分感谢你的支持。
|
||||
|
||||
那学习了3个多月,不知道你有哪些特别想和我说的话呢?对于本专栏的内容,你还有什么建议或意见?为了能更好地了解你的想法,以便我后面酌情安排加餐,在此希望能得到更多的反馈,听到更多的声音。
|
||||
|
||||
所以我特别邀请你填写毕业问卷,另外今天也是有奖征集的最后1天啦,欢迎大家畅所欲言!
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/73/35/7307786d2cfb778683e6c294ef382235.jpg" alt="">](https://jinshuju.net/f/TSUkPN)
|
||||
10
极客时间专栏/系统性能调优必知必会/结束语/毕业问卷获奖用户名单.md
Normal file
10
极客时间专栏/系统性能调优必知必会/结束语/毕业问卷获奖用户名单.md
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
你好!
|
||||
|
||||
截至今天,本专栏有奖收集毕业问卷阶段就结束了,十分感谢你的参与。现在我们来公布一下获奖用户名单。
|
||||
|
||||
在这里,我首先要感谢各位同学给我们的反馈,你们的声音可以促使我们精益求精。在这些反馈中,我们看到了很多非常有价值的信息,也收获了很多的支持与肯定。在此,我们精选出了反馈最为具体、丰富,最有实际价值的 5 位用户,送出“价值 99 元的极客时间课程阅码”,或者“极客时间原创 Be Curious 效率手册”。中奖名单如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/8c/618e32c255bef7114c8069c5761bf58c.jpg" alt="">
|
||||
|
||||
恭喜这 5 位同学,也再次感谢所有参与调研的同学。希望大家今后还能多多支持,给予宝贵意见。
|
||||
53
极客时间专栏/系统性能调优必知必会/结束语/结束语 | 从业 IT 20年后,我将最看重什么?.md
Normal file
53
极客时间专栏/系统性能调优必知必会/结束语/结束语 | 从业 IT 20年后,我将最看重什么?.md
Normal file
@@ -0,0 +1,53 @@
|
||||
<audio id="audio" title="结束语 | 从业 IT 20年后,我将最看重什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/38/10/388a42f4627692c3a8079ddd65508b10.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
经过3个多月的更新,咱们这门课今天就正式结课了!很感谢你的一路相伴,我有收获很多的分享和故事,这份信任弥足珍贵。
|
||||
|
||||
走到今天,我入行已经有20年了,那作为结束语,最后我特别想和你聊聊我的一些心得体会,就说说我最看重的两大能力吧,希望能给你的未来发展带来一些正反馈!
|
||||
|
||||
每次聊天提到程序员,大部分人的脑海中总会跳出两个词:996与35岁危机,它们都反应了1个现实:程序员的竞争压力太大了。**其实,老板是无法逼迫你996的,只有大量虎视眈眈盯着你职位、薪酬包的竞争者,才能让你心甘情愿的加班,让你为了度过35岁危机而不停地试图突破天花板!**为什么程序员的供给量这么大呢?这既来源于中国庞大的人口基数,也因为资本、人才密集的互联网行业,为年轻人提供了罕见的高薪资、高成长机会。
|
||||
|
||||
程序员的平均薪资远高于其他职业,这造成大量职场新人转行入互联网,从Javascript、Android、Python等工程师做起(这些语言形成闭环的路径更短,入门更快),寻求更好的个人发展。我有一些半路转行的朋友,经常见到他们凌晨还在提交代码,痛苦的转行过程让他们的自我驱动力无比强大。
|
||||
|
||||
各类院校也在为这个职业输送大量人才。在我上大学那会,只有计算机科学与技术这一个编程专业。现在,软件工程、网页设计各类专业层出不穷,而且,计算机课程已经成为各理工专业必修的基础课。
|
||||
|
||||
所以程序员普遍年轻,35+的我已经是我们团队年纪最大的程序员了,这在其他行业实在是不可思议的事!另一方面,互联网仍然是一个朝阳行业,变化是永恒的主题。在开源盛行的当下,注定会有层出不穷却又不太稳定的新框架,只有深入的学习才能用好、改进它。所以,我们常常会让自己“忙”起来、“学”起来,这样似乎可以忘却焦虑。
|
||||
|
||||
**那焦虑是什么呢?在我看来,就是对未来的自己是否仍具备竞争力的不确定感。**如果只是在不停地学习新语言、新框架,那么35岁的你,竞争力一定不如25岁的你,因为对框架的熟练度是不值几倍薪资差距的。我认为,有两个能力可以消除这种不确定性,它们不会因为新技术迭代,每次都让你数据清零后重新开始。
|
||||
|
||||
## 构建知识体系最需要什么?
|
||||
|
||||
**首先,是不局限于一招一式、构建知识体系的能力。**当你还未对某个领域形成知识体系时,只能解决曾经遇到过的相似问题,或者仅涉及单一知识点、能够从网上查询到答案的简单问题。而基于新技术做架构设计,或者定位涉及多个系统的复杂问题时,知识体系是最值得依赖的灯塔,它能指引你前进的方向。
|
||||
|
||||
知识体系可以将散落在脑海中的知识,通过逻辑联系在一起,形成庞大的网络。**知识点之间的联系线条越多,网络就越健壮,越能应对不确定性;网络越大,覆盖的领域越广,你能解决问题的价值就越高!**所以,我们需要结网的能力!
|
||||
|
||||
比如,你学习了某个人脸识别框架的用法,通过API把它集成到系统中,此时该技术与已有知识体系是割裂的。在你了解CNN网络,知道它只不过是一种聚类函数后,就能通过数学知识把它联结到知识网络中;在了解到密集浮点运算下保持精度的方案,与主流IEEE-754方案间的差别后,你又可以从数值计算维度上与常用的编程语言关联起来;从GPU、CPU对并行计算的设计差别上,你还可以在计算体系架构这条线上增强知识体系;从分布式模型训练系统中找到分而治之的思想,你就可以从算法上连接网络,等等。
|
||||
|
||||
概括下的话,我认为构建知识体系最需要的其实是底层知识!两个看似无关的技术,可以再往下看一层,找找它们共同的理论基础,建立逻辑关联。我认为,下面3个底层知识对结网的帮助最大:
|
||||
|
||||
- 数据结构与算法,我推荐你精读《算法导论》这本书,程序是由数据与算法构成的,这条线几乎可以连接所有技术点。
|
||||
- 计算机网络知识,它可以连接所有涉及互联网的技术,这也是我推出[《Web协议详解与抓包实战》](https://time.geekbang.org/course/intro/100026801)这门课的初衷。
|
||||
- 最后是操作系统知识,毕竟所有的软件都需要通过操作系统才能操作硬件。
|
||||
|
||||
另外,咱们在给知识体系添砖加瓦时,一定要注意知识的正确性,否则网络越密,后续修复成本就越高。我一般建议你尽量去源头寻找第一手知识,虽然有时这并不容易。比如学习HTTP/3协议时,我们只能去看那5份RFC文档,但RFC文档的结构设计是作为参考手册使用的,它并不适合首次学习,此时我们还可以借鉴一些权威高手基于一手知识分享的“二手”文章,这能让你更快地看懂RFC。
|
||||
|
||||
## 好的“表达力”能为你插上翅膀
|
||||
|
||||
**第2个最能对抗“焦虑”的是表达力。**做技术的同学通常对跟自己技术水平差不多,但因为表达力更好、从而拥有更高职位的同学不屑一顾,我曾经也是这样,总觉得咱靠的是“真才实料”,不是嘴皮子。这是一个很大的误区,它与你的职业发展密切相关。
|
||||
|
||||
如果团队里有3位同学:1位技术最好却不擅言辞,1位擅长管理、调动团队氛围,1位擅长表达,你觉得谁的职业发展最好呢?其实是最后那位,因为当团队向上级汇报工作时,他最容易让团队的工作得到认可,从而获取更多的资源把事情做成,使人人都有好处。久而久之,他就会获得更多的提拔机会。
|
||||
|
||||
我想你多半遇到过那种沟通起来特“费劲”的人,说话总是找不到重点,这样他就只能做一些简单的、不需要协作的工作。现代社会是需要高度协作的,如果没办法说清楚你的工作成果,你的价值就会大大缩水。当然,好的表达力不是无中生有,也不是能说会道,毕竟你面对的都是专业人士。
|
||||
|
||||
在职场中,由于沟通对象时间有限,所以采用金字塔方式表达效果最好。比如,当我向老板汇报工作时,我会在第一时间讲结论,然后再按照重要性顺序讲论据。否则,他同时要处理的事务比我广得多,如果我不能快速让他抓住重点,就很容易失去这次沟通机会。再比如我写这个专栏时,大家同时订阅了那么多课程,时间非常珍贵,我必须在每节课起始就开宗明义地给出场景,把各种对立面引发的冲突列出来,如果它能聚焦你的视线,我就会在正文中层层递进地讲下去,每一段总是为了引出下一段,防止在碎片化阅读时代里丢掉你的注意力。
|
||||
|
||||
那如果你想学习金字塔表达方式的话,我十分建议你精读《金字塔原理》这本书,这也是写作本专栏时,我的编辑推荐给我的,对我的帮助非常大,现学现卖推荐给你。
|
||||
|
||||
总结而言,我希望你拥有“结网能力”。构建广泛、结实的知识网络,可以帮助你提升竞争力、减轻“焦虑”,面对新技术时不用清零重来;但当你无法将新学的知识纳入已有知识体系时,不妨把眼光放低点,从底层技术中找找关联。 我还希望你拥有“表达能力”。再硬核的知识体系也需要通过优秀的表达力,转换为动听的语言和精练的文字,再通过互联网跨越时空,让你跳出公司,在整个行业中提升影响力、竞争力。
|
||||
|
||||
希望这2种能力能帮助你在大的技术洪流中站稳脚跟,从无数的竞争对手中脱颖而出。最后,祝你工作顺利,也希望这门课能成为你抵抗焦虑的武器之一。
|
||||
|
||||
最后的最后,我还为你准备了一份毕业问卷,希望你能用2分钟的时间填写一下。大胆表达自己,期待听到你的声音!
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/05/ca/05d7e6b6ccff9f23968349fff4a5d2ca.jpg" alt="">](https://jinshuju.net/f/TSUkPN)
|
||||
Reference in New Issue
Block a user